diff --git a/plugins/activity/frontend/src/index.js b/plugins/activity/frontend/src/index.js index 063790b..0ab63a4 100644 --- a/plugins/activity/frontend/src/index.js +++ b/plugins/activity/frontend/src/index.js @@ -8,11 +8,17 @@ var PLUGIN_ID = 'verstak.activity'; var MAX_EVENTS = 250; - var MAX_SUGGESTIONS = 12; + var MAX_CANDIDATES = 12; var LEGACY_KEY = 'events'; var GLOBAL_KEY = 'events:global'; var WORKSPACE_PREFIX = 'events:workspace:'; + var CANDIDATE_PREFIX = 'work-session-candidates:workspace:'; + var DISMISSAL_PREFIX = 'work-session-dismissals:workspace:'; var WORKLOG_COMMAND_ID = 'verstak.activity.suggestWorklog'; + var MIN_SESSION_DURATION_MINUTES = 10; + var MIN_SESSION_ACTIVITY_COUNT = 2; + var MAX_IDLE_GAP_MINUTES = 20; + var MAX_SESSION_DURATION_MINUTES = 120; var ACTIVITY_EVENTS = [ 'file.opened', 'file.changed', @@ -68,12 +74,14 @@ '.activity-btn.danger{border-color:rgba(233,69,96,.42);color:#ff9a9a}', '.activity-status{font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3);white-space:nowrap}', '.activity-status.error{display:inline-flex;border:1px solid rgba(233,69,96,.45);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-danger-muted,rgba(233,69,96,.14));color:#ffc6ce;padding:.18rem .4rem}', - '.activity-suggestions{border-bottom:1px solid rgba(32,43,70,.72);background:var(--vt-color-surface-muted,#111629);padding:.65rem .75rem;display:grid;gap:.5rem}', - '.activity-suggestions-title{font-size:.76rem;font-weight:600;color:var(--vt-color-text-muted,#7f8aa3);text-transform:uppercase;letter-spacing:.04em}', - '.activity-suggestion{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.65rem;align-items:start;padding:.65rem .75rem;border:1px solid var(--vt-color-border,#202b46);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c)}', - '.activity-suggestion-title{font-size:.84rem;color:var(--vt-color-text-primary,#f4f7fb);font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', - '.activity-suggestion-summary{margin-top:.22rem;font-size:.76rem;color:var(--vt-color-text-secondary,#b7c0d4);line-height:1.4;white-space:pre-wrap;overflow-wrap:anywhere}', - '.activity-suggestion-minutes{font-size:.76rem;color:var(--vt-color-accent,#4ecca3);white-space:nowrap}', + '.activity-candidates{border-bottom:1px solid rgba(32,43,70,.72);background:var(--vt-color-surface-muted,#111629);padding:.65rem .75rem;display:grid;gap:.5rem}', + '.activity-candidates-title{font-size:.76rem;font-weight:600;color:var(--vt-color-text-muted,#7f8aa3);text-transform:uppercase;letter-spacing:.04em}', + '.activity-candidate{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.65rem;align-items:start;padding:.65rem .75rem;border:1px solid rgba(78,204,163,.34);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c)}', + '.activity-candidate-title{font-size:.84rem;color:var(--vt-color-text-primary,#f4f7fb);font-weight:600}', + '.activity-candidate-facts{margin-top:.28rem;display:grid;gap:.16rem;font-size:.76rem;color:var(--vt-color-text-secondary,#b7c0d4)}', + '.activity-candidate-activities{margin-top:.38rem;font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}.activity-candidate-activities summary{cursor:pointer}.activity-candidate-activity{margin-top:.2rem;overflow-wrap:anywhere}', + '.activity-candidate-actions{display:flex;gap:.35rem;align-items:center;flex-wrap:wrap;justify-content:flex-end}', + '.activity-candidate-duration{font-size:.76rem;color:var(--vt-color-accent,#4ecca3);white-space:nowrap}', '.activity-list{flex:1;min-height:0;overflow:auto;background:var(--vt-color-background,#101020)}', '.activity-empty{height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.35rem;color:var(--vt-color-text-muted,#7f8aa3);font-size:.86rem;padding:2rem;text-align:center}', '.activity-empty-title{color:var(--vt-color-text-secondary,#b7c0d4);font-weight:650}', @@ -87,7 +95,7 @@ '.activity-summary{margin-top:.25rem;font-size:.78rem;line-height:1.4;color:var(--vt-color-text-secondary,#b7c0d4);white-space:pre-wrap;overflow-wrap:anywhere}', '.activity-source{margin-top:.25rem;font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}', '.activity-details{margin-top:.25rem;font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}.activity-details summary{cursor:pointer}', - '@media(max-width:760px){.activity-row,.activity-suggestion{grid-template-columns:1fr;gap:.25rem}.activity-toolbar{align-items:stretch}.activity-status{width:100%}}' + '@media(max-width:760px){.activity-row,.activity-candidate{grid-template-columns:1fr;gap:.25rem}.activity-candidate-actions{justify-content:flex-start}.activity-toolbar{align-items:stretch}.activity-status{width:100%}}' ].join('\n'); function el(tag, attrs, children) { @@ -213,32 +221,80 @@ return date && !isNaN(date.getTime()) ? date.getTime() : 0; } - function eventDay(activity) { - var value = activity && (activity.occurredAt || activity.receivedAt); - var date = value ? new Date(value) : null; - if (date && !isNaN(date.getTime())) return date.toISOString().slice(0, 10); - return text(value).slice(0, 10) || 'unknown-date'; + function candidateWorkspace(activity) { + return cleanWorkspace(activity && (activity.workspaceRootPath || workspaceFromPayload(activity.payload || {}))); } - function suggestionWorkspace(activity) { - return cleanWorkspace(activity && (activity.workspaceRootPath || workspaceFromPayload(activity.payload || {}))) || 'Global'; + function toISOTime(time) { + var date = new Date(time); + return isNaN(date.getTime()) ? '' : date.toISOString(); } - function roundUpQuarterHour(minutes) { - return Math.ceil(minutes / 15) * 15; + function candidateId(workspaceRootPath, firstActivity, lastActivity) { + return 'work-session:' + encodeKey(workspaceRootPath) + ':' + encodeKey(firstActivity.activityId) + ':' + encodeKey(lastActivity.activityId); } - function estimateMinutes(groupEvents) { - if (!groupEvents.length) return 0; - if (groupEvents.length === 1) return 15; - var first = eventTimeMs(groupEvents[0]); - var last = eventTimeMs(groupEvents[groupEvents.length - 1]); - if (!first || !last || last <= first) return 15; - return Math.max(15, Math.min(480, roundUpQuarterHour((last - first) / 60000))); + function candidateActivity(activity) { + return { + activityId: text(activity.activityId), + type: text(activity.type), + occurredAt: toISOTime(eventTimeMs(activity)), + sourcePluginId: text(activity.sourcePluginId), + workspaceRootPath: candidateWorkspace(activity) + }; } - function eventLabel(activity) { - return humanEventTitle(activity); + function buildCandidate(session) { + var first = session.activities[0]; + var last = session.activities[session.activities.length - 1]; + var duration = Math.round((eventTimeMs(last) - eventTimeMs(first)) / 60000); + if (session.activities.length < MIN_SESSION_ACTIVITY_COUNT || duration < MIN_SESSION_DURATION_MINUTES) return null; + return { + candidateId: candidateId(session.workspaceRootPath, first, last), + workspaceRootPath: session.workspaceRootPath, + startedAt: toISOTime(eventTimeMs(first)), + endedAt: toISOTime(eventTimeMs(last)), + estimatedMinutes: duration, + activityCount: session.activities.length, + activityIds: session.activities.map(function (activity) { return activity.activityId; }).filter(Boolean), + activities: session.activities.map(candidateActivity) + }; + } + + function buildWorkSessionCandidates(activityList, workspaceFilter) { + var filter = cleanWorkspace(workspaceFilter); + var ordered = sortEvents(activityList || []).filter(function (activity) { + return isMeaningfulActivity(activity) && candidateWorkspace(activity) && eventTimeMs(activity); + }).slice().sort(function (a, b) { + return eventTimeMs(a) - eventTimeMs(b); + }); + var sessions = []; + var current = null; + ordered.forEach(function (activity) { + var workspace = candidateWorkspace(activity); + var time = eventTimeMs(activity); + if (!current) { + current = { workspaceRootPath: workspace, activities: [activity] }; + return; + } + var firstTime = eventTimeMs(current.activities[0]); + var lastTime = eventTimeMs(current.activities[current.activities.length - 1]); + var switchedWorkspace = current.workspaceRootPath !== workspace; + var idleGap = time - lastTime > MAX_IDLE_GAP_MINUTES * 60 * 1000; + var exceededMaximum = time - firstTime > MAX_SESSION_DURATION_MINUTES * 60 * 1000; + if (switchedWorkspace || idleGap || exceededMaximum) { + sessions.push(current); + current = { workspaceRootPath: workspace, activities: [activity] }; + return; + } + current.activities.push(activity); + }); + if (current) sessions.push(current); + return sessions.map(buildCandidate).filter(function (candidate) { + return candidate && (!filter || candidate.workspaceRootPath === filter); + }).sort(function (a, b) { + return b.endedAt.localeCompare(a.endedAt) || a.workspaceRootPath.localeCompare(b.workspaceRootPath); + }).slice(0, MAX_CANDIDATES); } function humanEventType(type) { @@ -262,49 +318,6 @@ return 'Activity'; } - function summarizeEvents(groupEvents) { - var labels = []; - groupEvents.forEach(function (activity) { - var label = eventLabel(activity); - if (label && labels.indexOf(label) === -1) labels.push(label); - }); - var visible = labels.slice(0, 3); - var suffix = labels.length > 3 ? ' +' + (labels.length - 3) + ' more' : ''; - return (visible.join('; ') || groupEvents.length + ' activity events') + suffix; - } - - function buildWorklogSuggestions(activityList, workspaceFilter) { - var filter = cleanWorkspace(workspaceFilter); - var groups = {}; - sortEvents(activityList || []).filter(isMeaningfulActivity).forEach(function (activity) { - var workspace = suggestionWorkspace(activity); - if (filter && workspace !== filter) return; - var day = eventDay(activity); - var key = workspace + '|' + day; - groups[key] = groups[key] || { workspaceRootPath: workspace, date: day, events: [] }; - groups[key].events.push(activity); - }); - return Object.keys(groups).map(function (key) { - var group = groups[key]; - var ordered = group.events.slice().sort(function (a, b) { - return eventTimeMs(a) - eventTimeMs(b); - }); - var eventIds = ordered.map(function (activity) { return activity.activityId; }).filter(Boolean); - var suggestionId = 'worklog:' + group.workspaceRootPath + ':' + group.date; - return { - suggestionId: suggestionId, - workspaceRootPath: group.workspaceRootPath, - date: group.date, - title: group.workspaceRootPath + ' work on ' + group.date, - summary: summarizeEvents(ordered), - minutes: estimateMinutes(ordered), - eventIds: eventIds - }; - }).sort(function (a, b) { - return b.date.localeCompare(a.date) || a.workspaceRootPath.localeCompare(b.workspaceRootPath); - }).slice(0, MAX_SUGGESTIONS); - } - function globalEventKeys(settings) { var keys = [LEGACY_KEY, GLOBAL_KEY]; Object.keys(settings || {}).forEach(function (key) { @@ -321,7 +334,7 @@ globalEventKeys(settings).forEach(function (key) { all = all.concat(normalizeStoredEvents(settings[key], key)); }); - return sortEvents(all).filter(isMeaningfulActivity); + return sortEvents(all); } var workspaceKey = WORKSPACE_PREFIX + encodeKey(workspace); var scopedEvents = normalizeStoredEvents(settings[workspaceKey], workspaceKey); @@ -331,7 +344,49 @@ var legacyEvents = normalizeStoredEvents(settings[LEGACY_KEY], LEGACY_KEY).filter(function (item) { return item.workspaceRootPath === workspace; }); - return sortEvents(scopedEvents.concat(globalEvents, legacyEvents)).filter(isMeaningfulActivity); + return sortEvents(scopedEvents.concat(globalEvents, legacyEvents)); + } + + function candidateStorageKey(workspaceRoot) { + return CANDIDATE_PREFIX + encodeKey(workspaceRoot); + } + + function dismissalStorageKey(workspaceRoot) { + return DISMISSAL_PREFIX + encodeKey(workspaceRoot); + } + + function decodeStoredWorkspace(key, prefix) { + if (key.indexOf(prefix) !== 0) return ''; + try { + return cleanWorkspace(decodeURIComponent(key.slice(prefix.length))); + } catch (err) { + return cleanWorkspace(key.slice(prefix.length)); + } + } + + function dismissedCandidatesFromSettings(settings) { + var dismissed = {}; + Object.keys(settings || {}).forEach(function (key) { + if (key.indexOf(DISMISSAL_PREFIX) !== 0 || !Array.isArray(settings[key])) return; + var workspace = decodeStoredWorkspace(key, DISMISSAL_PREFIX); + if (!workspace) return; + dismissed[workspace] = {}; + settings[key].forEach(function (candidateId) { + candidateId = text(candidateId).trim(); + if (candidateId) dismissed[workspace][candidateId] = true; + }); + }); + return dismissed; + } + + function isCandidateDismissed(candidate, dismissedByWorkspace) { + return !!(candidate && dismissedByWorkspace && dismissedByWorkspace[candidate.workspaceRootPath] && dismissedByWorkspace[candidate.workspaceRootPath][candidate.candidateId]); + } + + function visibleCandidates(activityList, workspaceFilter, dismissedByWorkspace) { + return buildWorkSessionCandidates(activityList, workspaceFilter).filter(function (candidate) { + return !isCandidateDismissed(candidate, dismissedByWorkspace); + }); } function formatDate(value) { @@ -351,7 +406,9 @@ var scope = scopeFromProps(props || {}); var events = []; - var suggestions = []; + var candidateSourceEvents = []; + var candidates = []; + var dismissedByWorkspace = {}; var statusText = 'Loading activity...'; var statusClass = ''; var disposed = false; @@ -371,7 +428,10 @@ return; } events = []; - updateSuggestions(); + candidateSourceEvents = candidateSourceEvents.filter(function (activity) { + return candidateWorkspace(activity) !== scope.workspaceRoot; + }); + updateCandidates(); persist().then(render); } }); @@ -381,17 +441,46 @@ toolbar.appendChild(statusEl); toolbar.appendChild(clearBtn); - var suggestionsEl = el('div', { - className: 'activity-suggestions', - 'data-activity-section': 'worklog-suggestions' + var candidatesEl = el('div', { + className: 'activity-candidates', + 'data-activity-section': 'work-session-candidates' }); var listEl = el('div', { className: 'activity-list' }); containerEl.appendChild(toolbar); - containerEl.appendChild(suggestionsEl); + containerEl.appendChild(candidatesEl); containerEl.appendChild(listEl); - function updateSuggestions() { - suggestions = buildWorklogSuggestions(events, scope.mode === 'workspace' ? scope.workspaceRoot : ''); + function candidatesForWorkspace(workspaceRoot) { + return visibleCandidates(candidateSourceEvents, workspaceRoot, dismissedByWorkspace); + } + + function updateCandidates() { + candidates = candidatesForWorkspace(scope.mode === 'workspace' ? scope.workspaceRoot : ''); + } + + function candidateWorkspaces() { + var workspaces = {}; + candidateSourceEvents.forEach(function (activity) { + var workspace = candidateWorkspace(activity); + if (workspace) workspaces[workspace] = true; + }); + if (scope.mode === 'workspace' && scope.workspaceRoot) workspaces[scope.workspaceRoot] = true; + return Object.keys(workspaces); + } + + function persistCandidateCache(workspaceRoot) { + if (!workspaceRoot || !api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve(); + return api.settings.write(candidateStorageKey(workspaceRoot), candidatesForWorkspace(workspaceRoot)); + } + + function persistCandidateCaches() { + return Promise.all(candidateWorkspaces().map(persistCandidateCache)); + } + + function persistDismissals(workspaceRoot) { + if (!workspaceRoot || !api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve(); + var dismissed = dismissedByWorkspace[workspaceRoot] || {}; + return api.settings.write(dismissalStorageKey(workspaceRoot), Object.keys(dismissed)); } function persist() { @@ -399,7 +488,7 @@ var toStore = scope.mode === 'global' ? events.filter(function (item) { return !item._storageKey || item._storageKey === GLOBAL_KEY; }) : events; - return api.settings.write(scope.key, storageEvents(toStore)).catch(function (err) { + return api.settings.write(scope.key, storageEvents(toStore)).then(persistCandidateCaches).catch(function (err) { statusText = 'Could not save activity: ' + (err && err.message ? err.message : String(err)); statusClass = 'error'; }); @@ -411,9 +500,16 @@ return Promise.resolve(); } return api.settings.read().then(function (settings) { - var keys = globalEventKeys(settings || {}); + var keys = globalEventKeys(settings || {}).concat(Object.keys(settings || {}).filter(function (key) { + return key.indexOf(CANDIDATE_PREFIX) === 0 || key.indexOf(DISMISSAL_PREFIX) === 0; + })); events = []; - return Promise.all(keys.map(function (key) { + candidateSourceEvents = []; + candidates = []; + dismissedByWorkspace = {}; + return Promise.all(keys.filter(function (key, index, all) { + return all.indexOf(key) === index; + }).map(function (key) { return api.settings.write(key, []); })); }).then(function () { @@ -455,25 +551,70 @@ }); } - function renderSuggestions() { - suggestionsEl.innerHTML = ''; - if (!suggestions.length) { - suggestionsEl.setAttribute('hidden', 'hidden'); + function candidateTimeRange(candidate) { + return formatDate(candidate.startedAt) + ' - ' + formatDate(candidate.endedAt); + } + + function reviewCandidate(candidate) { + if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function' || typeof CustomEvent !== 'function') return; + window.dispatchEvent(new CustomEvent('verstak:workspace-open-tool', { + detail: { + kind: 'journal', + toolRequest: { type: 'work-session-candidate', candidate: candidate } + } + })); + } + + function dismissCandidate(candidate) { + if (!candidate || !candidate.workspaceRootPath || !candidate.candidateId) return; + dismissedByWorkspace[candidate.workspaceRootPath] = dismissedByWorkspace[candidate.workspaceRootPath] || {}; + dismissedByWorkspace[candidate.workspaceRootPath][candidate.candidateId] = true; + updateCandidates(); + Promise.all([ + persistDismissals(candidate.workspaceRootPath), + persistCandidateCache(candidate.workspaceRootPath) + ]).then(function () { + statusText = 'Candidate dismissed'; + statusClass = ''; + }).catch(function (err) { + statusText = 'Could not dismiss candidate: ' + (err && err.message ? err.message : String(err)); + statusClass = 'error'; + }).then(render); + } + + function renderCandidates() { + candidatesEl.innerHTML = ''; + if (!candidates.length) { + candidatesEl.setAttribute('hidden', 'hidden'); return; } - if (typeof suggestionsEl.removeAttribute === 'function') suggestionsEl.removeAttribute('hidden'); - else delete suggestionsEl.attributes.hidden; - suggestionsEl.appendChild(el('div', { className: 'activity-suggestions-title', textContent: 'Worklog suggestions' })); - suggestions.forEach(function (suggestion) { - suggestionsEl.appendChild(el('div', { - className: 'activity-suggestion', - 'data-worklog-suggestion': suggestion.suggestionId + if (typeof candidatesEl.removeAttribute === 'function') candidatesEl.removeAttribute('hidden'); + else delete candidatesEl.attributes.hidden; + candidatesEl.appendChild(el('div', { className: 'activity-candidates-title', textContent: 'Possible journal entries' })); + candidates.forEach(function (candidate) { + candidatesEl.appendChild(el('div', { + className: 'activity-candidate', + 'data-work-session-candidate': candidate.candidateId }, [ el('div', {}, [ - el('div', { className: 'activity-suggestion-title', textContent: suggestion.title }), - el('div', { className: 'activity-suggestion-summary', textContent: suggestion.summary }) + el('div', { className: 'activity-candidate-title', textContent: 'Possible journal entry' }), + el('div', { className: 'activity-candidate-facts' }, [ + el('div', { textContent: 'Workspace: ' + candidate.workspaceRootPath }), + el('div', { textContent: 'Time: ' + candidateTimeRange(candidate) }), + el('div', { textContent: 'Estimated duration: ' + candidate.estimatedMinutes + ' min' }), + el('div', { textContent: 'Activities: ' + candidate.activityCount }) + ]), + el('details', { className: 'activity-candidate-activities' }, [ + el('summary', { textContent: 'Included activities (' + candidate.activityCount + ')' }) + ].concat(candidate.activities.map(function (activity) { + return el('div', { className: 'activity-candidate-activity', textContent: activity.occurredAt + ' · ' + activity.type + ' · ' + activity.activityId }); + }))) ]), - el('div', { className: 'activity-suggestion-minutes', textContent: suggestion.minutes + ' min' }) + el('div', { className: 'activity-candidate-actions' }, [ + el('div', { className: 'activity-candidate-duration', textContent: candidate.estimatedMinutes + ' min' }), + el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'review', textContent: 'Review', onClick: function () { reviewCandidate(candidate); } }), + el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'dismiss', textContent: 'Dismiss', onClick: function () { dismissCandidate(candidate); } }) + ]) ])); }); } @@ -483,45 +624,41 @@ clearBtn.disabled = events.length === 0; statusEl.textContent = statusText; statusEl.className = 'activity-status' + (statusClass ? ' ' + statusClass : ''); - updateSuggestions(); - renderSuggestions(); + renderCandidates(); renderList(); } function loadStored() { if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve(); - if (scope.mode === 'global') { - return api.settings.read().then(function (settings) { - events = eventsFromSettings(settings || {}, ''); - }).catch(function (err) { - statusText = 'Could not load activity: ' + (err && err.message ? err.message : String(err)); - statusClass = 'error'; - }); - } return api.settings.read().then(function (settings) { - events = eventsFromSettings(settings || {}, scope.workspaceRoot); + settings = settings || {}; + candidateSourceEvents = eventsFromSettings(settings, ''); + events = eventsFromSettings(settings, scope.mode === 'workspace' ? scope.workspaceRoot : ''); + dismissedByWorkspace = dismissedCandidatesFromSettings(settings); + updateCandidates(); + return persistCandidateCaches(); }).catch(function (err) { statusText = 'Could not load activity: ' + (err && err.message ? err.message : String(err)); statusClass = 'error'; }); } - function suggestWorklog(args) { + function listWorkSessionCandidates(args) { var workspace = cleanWorkspace(args && args.workspaceRootPath); if (!api || !api.settings || typeof api.settings.read !== 'function') { - return Promise.resolve({ suggestions: buildWorklogSuggestions(events, workspace || (scope.mode === 'workspace' ? scope.workspaceRoot : '')) }); + return Promise.resolve({ candidates: visibleCandidates(candidateSourceEvents, workspace || (scope.mode === 'workspace' ? scope.workspaceRoot : ''), dismissedByWorkspace) }); } return api.settings.read().then(function (settings) { - var sourceEvents = eventsFromSettings(settings || {}, workspace); - return { suggestions: buildWorklogSuggestions(sourceEvents, workspace) }; + settings = settings || {}; + return { candidates: visibleCandidates(eventsFromSettings(settings, ''), workspace, dismissedCandidatesFromSettings(settings)) }; }).catch(function () { - return { suggestions: [] }; + return { candidates: [] }; }); } function registerCommands() { if (!api || !api.commands || typeof api.commands.register !== 'function') return Promise.resolve(); - return api.commands.register(WORKLOG_COMMAND_ID, suggestWorklog).then(function (unregister) { + return api.commands.register(WORKLOG_COMMAND_ID, listWorkSessionCandidates).then(function (unregister) { if (typeof unregister === 'function') unsubscribers.push(unregister); }).catch(function (err) { statusText = 'Activity commands unavailable: ' + (err && err.message ? err.message : String(err)); diff --git a/plugins/activity/plugin.json b/plugins/activity/plugin.json index 724ce86..a03314f 100644 --- a/plugins/activity/plugin.json +++ b/plugins/activity/plugin.json @@ -50,7 +50,7 @@ "commands": [ { "id": "verstak.activity.suggestWorklog", - "title": "Suggest Worklog From Activity", + "title": "List Possible Journal Entries", "handler": "verstak.activity.suggestWorklog" } ], diff --git a/plugins/journal/frontend/src/index.js b/plugins/journal/frontend/src/index.js index 90d007a..811a58c 100644 --- a/plugins/journal/frontend/src/index.js +++ b/plugins/journal/frontend/src/index.js @@ -8,8 +8,6 @@ var PLUGIN_ID = 'verstak.journal'; var WORKLOG_PREFIX = 'worklog:workspace:'; - var ACTIVITY_PLUGIN_ID = 'verstak.activity'; - var ACTIVITY_WORKLOG_COMMAND = 'verstak.activity.suggestWorklog'; function injectStyles() { if (document.getElementById('journal-style-injected')) return; @@ -57,6 +55,11 @@ '.journal-modal-grid{display:grid;grid-template-columns:1fr 8rem;gap:.6rem}', '.journal-field{display:grid;gap:.3rem;font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}', '.journal-field.wide{grid-column:1/-1}', + '.journal-candidate-context{display:grid;gap:.22rem;padding:.65rem .7rem;border:1px solid rgba(78,204,163,.34);border-radius:var(--vt-radius-md,6px);background:var(--vt-color-surface-muted,#111629);font-size:.76rem;color:var(--vt-color-text-secondary,#b7c0d4)}', + '.journal-candidate-context strong{color:var(--vt-color-text-primary,#f4f7fb)}', + '.journal-candidate-activities{display:grid;gap:.35rem;margin:0;padding:.65rem .7rem;border:1px solid var(--vt-color-border,#202b46);border-radius:var(--vt-radius-md,6px);font-size:.74rem;color:var(--vt-color-text-secondary,#b7c0d4)}', + '.journal-candidate-activities legend{padding:0 .2rem;color:var(--vt-color-text-muted,#7f8aa3)}', + '.journal-candidate-activity{display:flex;align-items:flex-start;gap:.45rem;line-height:1.35;overflow-wrap:anywhere}', '.journal-modal-actions{display:flex;justify-content:flex-end;gap:.5rem}', '.journal-btn.primary{background:var(--vt-color-accent,#4ecca3);border-color:var(--vt-color-accent,#4ecca3);color:#101827}', '.journal-btn.ghost{background:transparent}', @@ -127,8 +130,10 @@ summary: text(value.summary), minutes: Math.max(0, Number(value.minutes || 0)), billable: value.billable === true, - sourceSuggestionId: text(value.sourceSuggestionId), - eventIds: Array.isArray(value.eventIds) ? value.eventIds.map(text) : [] + sourceCandidateId: text(value.sourceCandidateId || value.sourceSuggestionId), + activityIds: Array.isArray(value.activityIds) + ? value.activityIds.map(text) + : (Array.isArray(value.eventIds) ? value.eventIds.map(text) : []) }; } @@ -156,8 +161,8 @@ summary: entry.summary, minutes: entry.minutes, billable: entry.billable, - sourceSuggestionId: entry.sourceSuggestionId, - eventIds: entry.eventIds || [] + sourceCandidateId: entry.sourceCandidateId, + activityIds: entry.activityIds || [] }; }); } @@ -165,7 +170,7 @@ function sortEntries(entryList) { var seen = {}; return entryList.filter(function (entry) { - var key = entry.sourceSuggestionId || entry.entryId; + var key = entry.sourceCandidateId || entry.entryId; if (!key || seen[key]) return false; seen[key] = true; return true; @@ -184,6 +189,50 @@ return 'journal:' + cleanWorkspace(workspaceRoot || 'Global') + ':' + text(date) + ':' + encodeKey(title).slice(0, 48) + ':' + Date.now(); } + function candidateDate(value) { + var date = new Date(value || ''); + return isNaN(date.getTime()) ? today() : date.toISOString().slice(0, 10); + } + + function candidateTime(value) { + var date = new Date(value || ''); + if (isNaN(date.getTime())) return text(value); + return date.toLocaleString(undefined, { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }); + } + + function candidateFromRequest(request, workspaceRoot) { + var value = request && request.type === 'work-session-candidate' ? request.candidate : null; + if (!value || typeof value !== 'object') return null; + var workspace = cleanWorkspace(value.workspaceRootPath); + if (!workspace || workspace !== cleanWorkspace(workspaceRoot) || !text(value.candidateId).trim()) return null; + var activities = Array.isArray(value.activities) ? value.activities.filter(function (activity) { + return activity && text(activity.activityId).trim(); + }).map(function (activity) { + return { + activityId: text(activity.activityId), + type: text(activity.type || 'activity.event'), + occurredAt: text(activity.occurredAt), + sourcePluginId: text(activity.sourcePluginId) + }; + }) : []; + var activityIds = Array.isArray(value.activityIds) ? value.activityIds.map(text).filter(Boolean) : activities.map(function (activity) { return activity.activityId; }); + if (!activities.length) { + activities = activityIds.map(function (activityId) { + return { activityId: activityId, type: 'activity.event', occurredAt: '', sourcePluginId: '' }; + }); + } + return { + candidateId: text(value.candidateId), + workspaceRootPath: workspace, + startedAt: text(value.startedAt), + endedAt: text(value.endedAt), + estimatedMinutes: Math.max(0, Number(value.estimatedMinutes || 0)), + activityCount: Math.max(0, Number(value.activityCount || activities.length)), + activityIds: activityIds, + activities: activities + }; + } + var ICONS = { add: 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z', edit: 'M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z', @@ -212,12 +261,6 @@ var titleEl = el('span', { className: 'journal-title', textContent: scope.mode === 'global' ? 'Journal' : 'Journal · ' + scope.label }); var countEl = el('span', { className: 'journal-count' }); var statusEl = el('span', { className: 'journal-status' }); - var importBtn = el('button', { - className: 'journal-btn', - 'data-journal-action': 'import-activity', - textContent: 'Import Activity', - onClick: importActivity - }); var addBtn = el('button', { className: 'journal-btn primary', 'data-journal-action': 'add', @@ -229,7 +272,6 @@ toolbar.appendChild(el('span', { className: 'journal-spacer' })); toolbar.appendChild(statusEl); toolbar.appendChild(addBtn); - toolbar.appendChild(importBtn); var listEl = el('div', { className: 'journal-list' }); containerEl.appendChild(toolbar); @@ -276,16 +318,22 @@ modalHost.setAttribute('hidden', 'hidden'); } - function showEntryModal(existingEntry) { + function showEntryModal(existingEntry, candidate) { if (scope.mode !== 'workspace') return; var editing = !!existingEntry; - var dateInput = el('input', { className: 'journal-input', type: 'date', value: editing ? existingEntry.date : today(), 'data-journal-input': 'date' }); + var reviewingCandidate = !editing && !!candidate; + var dateInput = el('input', { className: 'journal-input', type: 'date', value: editing ? existingEntry.date : (reviewingCandidate ? candidateDate(candidate.startedAt) : today()), 'data-journal-input': 'date' }); var titleInput = el('input', { className: 'journal-input', type: 'text', placeholder: 'Work item', value: editing ? existingEntry.title : '', 'data-journal-input': 'title' }); - var summaryInput = el('textarea', { className: 'journal-input textarea', placeholder: 'Summary', 'data-journal-input': 'summary' }); + var summaryInput = el('textarea', { className: 'journal-input textarea', placeholder: 'Body', 'data-journal-input': 'summary' }); summaryInput.value = editing ? existingEntry.summary : ''; - var minutesInput = el('input', { className: 'journal-input', type: 'number', min: '0', step: '15', value: editing ? existingEntry.minutes : '30', 'data-journal-input': 'minutes' }); + var minutesInput = el('input', { className: 'journal-input', type: 'number', min: '0', step: '1', value: editing ? existingEntry.minutes : (reviewingCandidate ? candidate.estimatedMinutes : '30'), 'data-journal-input': 'minutes' }); var billableInput = el('input', { type: 'checkbox', 'data-journal-input': 'billable' }); billableInput.checked = editing ? existingEntry.billable === true : false; + var activityInputs = reviewingCandidate ? candidate.activities.map(function (activity) { + var input = el('input', { type: 'checkbox', value: activity.activityId, checked: 'checked', 'data-journal-candidate-activity': activity.activityId }); + input.checked = true; + return { input: input, activity: activity }; + }) : []; function saveEntry() { addOrUpdateEntry(existingEntry, { @@ -293,10 +341,28 @@ title: titleInput.value, summary: summaryInput.value, minutes: minutesInput.value, - billable: billableInput.checked === true + billable: billableInput.checked === true, + sourceCandidateId: reviewingCandidate ? candidate.candidateId : (existingEntry ? existingEntry.sourceCandidateId : ''), + activityIds: reviewingCandidate + ? activityInputs.filter(function (item) { return item.input.checked === true; }).map(function (item) { return item.activity.activityId; }) + : (existingEntry ? existingEntry.activityIds : []) }); } + var candidateContext = reviewingCandidate ? el('div', { className: 'journal-candidate-context', 'data-journal-candidate': candidate.candidateId }, [ + el('strong', { textContent: 'Possible journal entry' }), + el('div', { textContent: 'Workspace: ' + candidate.workspaceRootPath }), + el('div', { textContent: 'Time: ' + candidateTime(candidate.startedAt) + ' - ' + candidateTime(candidate.endedAt) }), + el('div', { textContent: 'Estimated duration: ' + candidate.estimatedMinutes + ' min' }), + el('div', { textContent: 'Activities: ' + candidate.activityCount }) + ]) : null; + var candidateActivities = reviewingCandidate ? el('fieldset', { className: 'journal-candidate-activities' }, [ + el('legend', { textContent: 'Linked activities' }) + ].concat(activityInputs.map(function (item) { + var detail = (item.activity.occurredAt ? candidateTime(item.activity.occurredAt) + ' · ' : '') + item.activity.type + ' · ' + item.activity.activityId; + return el('label', { className: 'journal-candidate-activity' }, [item.input, detail]); + }))) : null; + modalHost.innerHTML = ''; if (typeof modalHost.removeAttribute === 'function') modalHost.removeAttribute('hidden'); else delete modalHost.attributes.hidden; @@ -304,14 +370,16 @@ if (event.target === event.currentTarget) closeEntryModal(); } }, [ el('div', { className: 'journal-modal' }, [ - el('div', { className: 'journal-modal-title', textContent: editing ? 'Edit journal entry' : 'Add journal entry' }), + el('div', { className: 'journal-modal-title', textContent: editing ? 'Edit journal entry' : (reviewingCandidate ? 'Review possible journal entry' : 'Add journal entry') }), + candidateContext, el('div', { className: 'journal-modal-grid' }, [ el('label', { className: 'journal-field' }, ['Date', dateInput]), el('label', { className: 'journal-field' }, ['Minutes', minutesInput]), el('label', { className: 'journal-field wide' }, ['Title', titleInput]), - el('label', { className: 'journal-field wide' }, ['Summary', summaryInput]), + el('label', { className: 'journal-field wide' }, ['Body', summaryInput]), el('label', { className: 'journal-billable' }, [billableInput, 'Billable']) ]), + candidateActivities, el('div', { className: 'journal-modal-actions' }, [ el('button', { className: 'journal-btn ghost', type: 'button', textContent: 'Cancel', onClick: closeEntryModal }), el('button', { className: 'journal-btn primary', type: 'button', 'data-journal-action': 'save-entry', textContent: editing ? 'Save changes' : 'Add entry', onClick: saveEntry }) @@ -330,6 +398,13 @@ render(); return; } + var sourceCandidateId = text(formValue && formValue.sourceCandidateId || (existingEntry && existingEntry.sourceCandidateId)).trim(); + if (!existingEntry && sourceCandidateId && entries.some(function (entry) { return entry.sourceCandidateId === sourceCandidateId; })) { + statusText = 'A journal entry already references this candidate'; + statusClass = 'error'; + render(); + return; + } var entry = normalizeEntry({ entryId: existingEntry ? existingEntry.entryId : entryId(scope.workspaceRoot, formValue.date || today(), title), workspaceRootPath: scope.workspaceRoot, @@ -338,8 +413,8 @@ summary: formValue.summary, minutes: Number(formValue.minutes || 0), billable: formValue.billable === true, - sourceSuggestionId: existingEntry ? existingEntry.sourceSuggestionId : '', - eventIds: existingEntry ? existingEntry.eventIds : [] + sourceCandidateId: sourceCandidateId, + activityIds: Array.isArray(formValue.activityIds) ? formValue.activityIds : (existingEntry ? existingEntry.activityIds : []) }, scope.key); if (existingEntry) { entries = entries.map(function (item) { @@ -363,61 +438,6 @@ persist().then(render); } - function suggestionToEntry(suggestion) { - return normalizeEntry({ - entryId: 'journal:' + suggestion.suggestionId, - workspaceRootPath: suggestion.workspaceRootPath || scope.workspaceRoot, - date: suggestion.date || today(), - title: suggestion.title || 'Activity work', - summary: suggestion.summary || '', - minutes: Number(suggestion.minutes || 0), - billable: false, - sourceSuggestionId: suggestion.suggestionId, - eventIds: suggestion.eventIds || [] - }, scope.key); - } - - function importActivity() { - if (scope.mode !== 'workspace') return; - if (!api || !api.commands || typeof api.commands.executeFor !== 'function') { - statusText = 'Activity suggestions unavailable'; - statusClass = 'error'; - render(); - return; - } - importBtn.disabled = true; - statusText = 'Importing activity...'; - statusClass = ''; - render(); - api.commands.executeFor(ACTIVITY_PLUGIN_ID, ACTIVITY_WORKLOG_COMMAND, { - workspaceRootPath: scope.workspaceRoot - }).then(function (response) { - var suggestions = response && response.result && Array.isArray(response.result.suggestions) - ? response.result.suggestions - : []; - var imported = 0; - suggestions.forEach(function (suggestion) { - if (!suggestion || !suggestion.suggestionId) return; - var exists = entries.some(function (entry) { - return entry.sourceSuggestionId === suggestion.suggestionId; - }); - if (exists) return; - entries.push(suggestionToEntry(suggestion)); - imported += 1; - }); - entries = sortEntries(entries); - statusText = imported ? 'Imported ' + imported + ' activity suggestion' + (imported === 1 ? '' : 's') : 'No new activity suggestions'; - statusClass = ''; - return persist(); - }).catch(function (err) { - statusText = 'Activity suggestions unavailable: ' + (err && err.message ? err.message : String(err)); - statusClass = 'error'; - }).then(function () { - importBtn.disabled = false; - render(); - }); - } - function renderList() { listEl.innerHTML = ''; if (!entries.length) { @@ -433,7 +453,7 @@ el('div', { className: 'journal-main' }, [ el('div', { className: 'journal-entry-title', textContent: entry.title }), entry.summary ? el('div', { className: 'journal-summary', textContent: entry.summary }) : null, - el('div', { className: 'journal-meta', textContent: entry.workspaceRootPath + (entry.billable ? ' · billable' : ' · non-billable') }) + el('div', { className: 'journal-meta', textContent: entry.workspaceRootPath + (entry.billable ? ' · billable' : ' · non-billable') + (entry.activityIds.length ? ' · ' + entry.activityIds.length + ' linked activities' : '') }) ]), el('div', { className: 'journal-minutes', textContent: entry.minutes + ' min' }), scope.mode === 'workspace' ? el('div', { className: 'journal-row-actions' }, [ @@ -449,12 +469,15 @@ statusEl.textContent = statusText; statusEl.className = 'journal-status' + (statusClass ? ' ' + statusClass : ''); addBtn.disabled = scope.mode !== 'workspace'; - importBtn.disabled = scope.mode !== 'workspace' || importBtn.disabled; renderList(); } render(); - loadStored().then(render); + loadStored().then(function () { + render(); + var candidate = candidateFromRequest(props && props.toolRequest, scope.workspaceRoot); + if (candidate) showEntryModal(null, candidate); + }); }; JournalView.unmount = function (containerEl) { diff --git a/plugins/journal/plugin.json b/plugins/journal/plugin.json index fc0b874..9364abe 100644 --- a/plugins/journal/plugin.json +++ b/plugins/journal/plugin.json @@ -4,7 +4,7 @@ "name": "Journal", "version": "0.1.0", "apiVersion": "0.1.0", - "description": "Workspace-scoped worklog journal with manual entries and Activity suggestion import.", + "description": "Workspace-scoped journal with user-authored entries and optional Activity links.", "source": "official", "icon": "book-open", "provides": [ @@ -12,9 +12,6 @@ "journal", "report.worklog" ], - "optionalRequires": [ - "activity.reconstruction" - ], "permissions": [ "storage.namespace", "ui.register" diff --git a/scripts/smoke-activity-plugin.js b/scripts/smoke-activity-plugin.js index c0c00fa..6d22ec4 100644 --- a/scripts/smoke-activity-plugin.js +++ b/scripts/smoke-activity-plugin.js @@ -270,19 +270,32 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w if (!container.textContent.includes('Example Article')) throw new Error('browser capture title was not rendered'); if (!container.textContent.includes('browser.capture.selection')) throw new Error('event type was not rendered'); if (!container.textContent.includes('browser.capture.converted')) throw new Error('conversion event type was not rendered'); - if (!container.textContent.includes('Worklog suggestions')) throw new Error('worklog suggestions section was not rendered'); - if (!container.textContent.includes('Project work on 2026-06-27')) throw new Error('workspace worklog suggestion title was not rendered'); - if (!container.textContent.includes('30 min')) throw new Error('workspace worklog suggestion duration was not rendered'); - const suggestionNode = walk(container, (node) => node.getAttribute && node.getAttribute('data-worklog-suggestion') === 'worklog:Project:2026-06-27'); - if (!suggestionNode) throw new Error('worklog suggestion data attribute was not rendered'); + if (!container.textContent.includes('Possible journal entries')) throw new Error('work session candidate section was not rendered'); + if (container.textContent.includes('Project work on 2026-06-27')) throw new Error('candidate must not invent a worklog title'); + const candidateNode = walk(container, (node) => node.getAttribute && node.getAttribute('data-work-session-candidate')); + if (!candidateNode) throw new Error('work session candidate data attribute was not rendered'); + if (!candidateNode.textContent.includes('Workspace: Project')) throw new Error('candidate workspace was not rendered'); + if (!candidateNode.textContent.includes('Estimated duration: 30 min')) throw new Error('candidate duration was not rendered'); + if (!candidateNode.textContent.includes('Activities: 3')) throw new Error('candidate activity count was not rendered'); + if (!walk(candidateNode, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'review')) throw new Error('candidate review action was not rendered'); + if (!walk(candidateNode, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'dismiss')) throw new Error('candidate dismiss action was not rendered'); const commandResult = await api.commandHandlers.get(WORKLOG_COMMAND_ID)({ workspaceRootPath: 'Project' }); - const suggestions = commandResult && commandResult.suggestions; - if (!Array.isArray(suggestions) || suggestions.length !== 1) throw new Error('worklog suggestion command returned unexpected suggestions'); - if (suggestions[0].suggestionId !== 'worklog:Project:2026-06-27') throw new Error('worklog suggestion id mismatch'); - if (suggestions[0].minutes !== 30) throw new Error(`expected 30 suggested minutes, got ${suggestions[0].minutes}`); - if (!suggestions[0].summary.includes('Example Article') || !suggestions[0].summary.includes('Saved note')) throw new Error('worklog suggestion summary did not include event titles'); - if (suggestions[0].eventIds.join(',') !== 'capture-1,note-1,capture-1:browser.capture.converted') throw new Error('worklog suggestion event ids mismatch'); + const candidates = commandResult && commandResult.candidates; + if (!Array.isArray(candidates) || candidates.length !== 1) throw new Error('work session command returned unexpected candidates'); + const candidate = candidates[0]; + if (!candidate.candidateId) throw new Error('candidate id is missing'); + if (candidate.workspaceRootPath !== 'Project') throw new Error('candidate workspace mismatch'); + if (candidate.startedAt !== '2026-06-27T00:00:00.000Z' || candidate.endedAt !== '2026-06-27T00:30:00.000Z') throw new Error('candidate range mismatch'); + if (candidate.estimatedMinutes !== 30) throw new Error(`expected 30 candidate minutes, got ${candidate.estimatedMinutes}`); + if (candidate.activityCount !== 3) throw new Error(`expected three candidate activities, got ${candidate.activityCount}`); + if (candidate.activityIds.join(',') !== 'capture-1,note-1,capture-1:browser.capture.converted') throw new Error('candidate activity ids mismatch'); + if (!Array.isArray(candidate.activities) || candidate.activities.length !== 3) throw new Error('candidate activity list is missing'); + if ('title' in candidate || 'summary' in candidate || 'description' in candidate) throw new Error('candidate contains non-factual journal fields'); + const cachedCandidates = api.storedEvents('work-session-candidates:workspace:Project'); + if (!Array.isArray(cachedCandidates) || cachedCandidates.length !== 1 || cachedCandidates[0].candidateId !== candidate.candidateId) { + throw new Error('candidate cache was not persisted for Overview'); + } const clientView = await mountWithApi(api, { workspaceNode: { name: 'ClientA' }, workspaceRootPath: 'ClientA' }); if (clientView.container.textContent.includes('Example Article')) throw new Error('Project activity leaked into ClientA workspace view'); @@ -313,16 +326,22 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w await flush(); if (api.storedEvents(clientKey).length !== 1) throw new Error('ClientA activity was not stored under ClientA workspace key'); if (!clientView.container.textContent.includes('Client note')) throw new Error('ClientA activity was not rendered'); - if (!clientView.container.textContent.includes('ClientA work on 2026-06-27')) throw new Error('ClientA worklog suggestion was not rendered'); + if (clientView.container.textContent.includes('Possible journal entries')) throw new Error('a single activity must not create a work session candidate'); component.unmount && component.unmount(clientView.container); const globalView = await mountWithApi(api, {}); if (!globalView.container.textContent.includes('Example Article')) throw new Error('global activity did not aggregate Project activity'); if (!globalView.container.textContent.includes('Client note')) throw new Error('global activity did not aggregate ClientA activity'); - if (!globalView.container.textContent.includes('Project work on 2026-06-27')) throw new Error('global activity did not render Project worklog suggestion'); - if (!globalView.container.textContent.includes('ClientA work on 2026-06-27')) throw new Error('global activity did not render ClientA worklog suggestion'); + if (!globalView.container.textContent.includes('Possible journal entries')) throw new Error('global activity did not render work session candidates'); component.unmount && component.unmount(globalView.container); + const dismissButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'dismiss'); + if (!dismissButton) throw new Error('candidate dismiss action was not available'); + dismissButton.click(); + await flush(); + if (container.textContent.includes('Possible journal entries')) throw new Error('dismiss action did not remove the candidate from Activity'); + if (api.storedEvents('work-session-candidates:workspace:Project').length !== 0) throw new Error('dismiss action did not update the candidate cache'); + const manualButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-activity-action') === 'manual'); if (manualButton) throw new Error('manual activity button should not be rendered'); @@ -331,7 +350,7 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w clearButton.click(); await flush(); if (api.storedEvents(projectKey).length !== 0) throw new Error('clear action did not remove activity events'); - if (container.textContent.includes('Project work on 2026-06-27')) throw new Error('clear action did not remove worklog suggestions'); + if (api.storedEvents('work-session-candidates:workspace:Project').length !== 0) throw new Error('clear action did not remove cached candidates'); component.unmount && component.unmount(container); if (api.unsubscribed.length !== 33) throw new Error(`expected 33 unsubscribers, got ${api.unsubscribed.length}`); @@ -355,7 +374,7 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w }); const persisted = await mountWithApi(persistedApi); if (!persisted.container.textContent.includes('Saved note')) throw new Error('persisted activity was not rendered'); - if (persisted.container.textContent.includes('Selected file')) throw new Error('low-value file activity should not be rendered'); + if (!persisted.container.textContent.includes('Selected file')) throw new Error('raw Activity log must retain low-value technical events'); const legacyApi = makeApi({ events: [ @@ -402,6 +421,34 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w const taggedGlobalClient = await mountWithApi(taggedGlobalApi, { workspaceNode: { name: 'ClientA' }, workspaceRootPath: 'ClientA' }); if (taggedGlobalClient.container.textContent.includes('Global project capture')) throw new Error('workspace-tagged global activity leaked into another workspace'); + const sessionsApi = makeApi({ + 'events:workspace:Project': [ + { activityId: 'project-first', type: 'note.saved', occurredAt: '2026-06-27T00:00:00Z', workspaceRootPath: 'Project' }, + { activityId: 'project-second', type: 'file.changed', occurredAt: '2026-06-27T00:10:00Z', workspaceRootPath: 'Project' }, + { activityId: 'project-after-switch-first', type: 'note.saved', occurredAt: '2026-06-27T00:24:00Z', workspaceRootPath: 'Project' }, + { activityId: 'project-after-switch-second', type: 'file.changed', occurredAt: '2026-06-27T00:34:00Z', workspaceRootPath: 'Project' }, + { activityId: 'project-after-idle-first', type: 'note.saved', occurredAt: '2026-06-27T01:10:00Z', workspaceRootPath: 'Project' }, + { activityId: 'project-after-idle-second', type: 'file.changed', occurredAt: '2026-06-27T01:22:00Z', workspaceRootPath: 'Project' }, + ], + 'events:workspace:ClientA': [ + { activityId: 'client-first', type: 'note.saved', occurredAt: '2026-06-27T00:12:00Z', workspaceRootPath: 'ClientA' }, + { activityId: 'client-second', type: 'file.changed', occurredAt: '2026-06-27T00:22:00Z', workspaceRootPath: 'ClientA' }, + ], + }); + const sessionView = await mountWithApi(sessionsApi, {}); + const sessionResult = await sessionsApi.commandHandlers.get(WORKLOG_COMMAND_ID)({}); + const sessionCandidates = sessionResult && sessionResult.candidates; + if (!Array.isArray(sessionCandidates) || sessionCandidates.length !== 4) throw new Error('workspace switches and idle gaps must split candidates'); + const projectCandidates = sessionCandidates.filter((item) => item.workspaceRootPath === 'Project'); + if (projectCandidates.length !== 3) throw new Error('workspace switch must close the prior Project candidate'); + if (!projectCandidates.some((item) => item.activityIds.join(',') === 'project-after-idle-first,project-after-idle-second')) { + throw new Error('idle gap must start a separate Project candidate'); + } + if (sessionCandidates.some((item) => 'title' in item || 'summary' in item || 'description' in item)) { + throw new Error('session candidates must contain only factual fields'); + } + component.unmount && component.unmount(sessionView.container); + console.log('activity plugin smoke passed'); })().catch((err) => { console.error(err); diff --git a/scripts/smoke-journal-plugin.js b/scripts/smoke-journal-plugin.js index b327d52..5fbc845 100644 --- a/scripts/smoke-journal-plugin.js +++ b/scripts/smoke-journal-plugin.js @@ -90,6 +90,12 @@ function walk(node, fn) { return null; } +function walkAll(node, fn, matches = []) { + if (fn(node)) matches.push(node); + for (const child of node.children) walkAll(child, fn, matches); + return matches; +} + function makeDocument() { return { body: new FakeNode('body'), @@ -127,9 +133,7 @@ function loadComponent(document) { function makeApi(initialSettings = {}) { const settings = { ...initialSettings }; - const commandCalls = []; return { - commandCalls, settings: { read: async (key) => (key ? settings[key] : { ...settings }), write: async (key, value) => { @@ -137,30 +141,6 @@ function makeApi(initialSettings = {}) { return { ...settings }; }, }, - commands: { - executeFor: async (pluginId, commandId, args) => { - commandCalls.push({ pluginId, commandId, args }); - if (pluginId !== 'verstak.activity' || commandId !== 'verstak.activity.suggestWorklog') { - throw new Error(`unexpected command ${pluginId}:${commandId}`); - } - return { - status: 'handled', - pluginId, - commandId, - result: { - suggestions: [{ - suggestionId: 'worklog:Project:2026-06-27', - workspaceRootPath: 'Project', - date: '2026-06-27', - title: 'Project work on 2026-06-27', - summary: 'Example Article; Saved note', - minutes: 30, - eventIds: ['capture-1', 'note-1'], - }], - }, - }; - }, - }, storedEntries(key) { return settings[key] || []; }, @@ -190,7 +170,7 @@ function byData(container, attr, value) { for (const capability of ['worklog', 'journal', 'report.worklog']) { if (!manifest.provides.includes(capability)) throw new Error(`journal manifest missing capability ${capability}`); } - if (!manifest.optionalRequires.includes('activity.reconstruction')) throw new Error('journal manifest must optionally require activity.reconstruction'); + if ((manifest.optionalRequires || []).includes('activity.reconstruction')) throw new Error('Journal must remain available without Activity'); if (!manifest.permissions.includes('storage.namespace')) throw new Error('journal manifest must request storage.namespace'); if (!manifest.permissions.includes('ui.register')) throw new Error('journal manifest must request ui.register'); if (!(manifest.contributes.workspaceItems || []).some((item) => item.component === 'JournalView')) throw new Error('journal workspace item missing'); @@ -200,6 +180,10 @@ function byData(container, attr, value) { const { component, container } = await mountWithApi(api); const projectKey = 'worklog:workspace:Project'; + if (walk(container, (node) => node.getAttribute && node.getAttribute('data-journal-action') === 'import-activity')) { + throw new Error('Journal must not provide direct Activity import'); + } + byData(container, 'data-journal-action', 'add').click(); await flush(); byData(container, 'data-journal-input', 'date').value = '2026-06-27'; @@ -210,6 +194,7 @@ function byData(container, attr, value) { await flush(); if (api.storedEntries(projectKey).length !== 1) throw new Error('manual journal entry was not stored'); + if (api.storedEntries(projectKey)[0].activityIds.length !== 0) throw new Error('manual journal entry must not require activity links'); if (!container.textContent.includes('Draft brief')) throw new Error('manual journal entry was not rendered'); if (!container.textContent.includes('45 min')) throw new Error('manual journal entry minutes were not rendered'); @@ -225,28 +210,60 @@ function byData(container, attr, value) { if (api.storedEntries(projectKey)[0].title !== 'Draft brief updated') throw new Error('journal entry title was not updated'); if (!container.textContent.includes('60 min')) throw new Error('edited journal entry minutes were not rendered'); - byData(container, 'data-journal-action', 'import-activity').click(); + const candidate = { + candidateId: 'work-session:Project:capture-1:note-1', + workspaceRootPath: 'Project', + startedAt: '2026-06-27T10:12:00.000Z', + endedAt: '2026-06-27T11:03:00.000Z', + estimatedMinutes: 51, + activityCount: 2, + activityIds: ['capture-1', 'note-1'], + activities: [ + { activityId: 'capture-1', type: 'browser.capture.selection', occurredAt: '2026-06-27T10:12:00.000Z', sourcePluginId: 'verstak.browser-inbox' }, + { activityId: 'note-1', type: 'note.saved', occurredAt: '2026-06-27T11:03:00.000Z', sourcePluginId: 'verstak.notes' }, + ], + }; + const candidateView = await mountWithApi(api, { + workspaceNode: { name: 'Project' }, + workspaceRootPath: 'Project', + toolRequest: { type: 'work-session-candidate', candidate }, + }); + if (!candidateView.container.textContent.includes('Review possible journal entry')) throw new Error('candidate review modal was not opened'); + if (!candidateView.container.textContent.includes('Workspace: Project')) throw new Error('candidate workspace was not shown for review'); + if (!candidateView.container.textContent.includes('Estimated duration: 51 min')) throw new Error('candidate duration was not shown for review'); + if (byData(candidateView.container, 'data-journal-input', 'title').value !== '') throw new Error('candidate review must start with an empty title'); + if (byData(candidateView.container, 'data-journal-input', 'summary').value !== '') throw new Error('candidate review must start with an empty body'); + if (byData(candidateView.container, 'data-journal-input', 'minutes').value !== '51') throw new Error('candidate review must prefill the factual duration'); + const linkedActivityInputs = walkAll(candidateView.container, (node) => node.getAttribute && node.getAttribute('data-journal-candidate-activity')); + if (linkedActivityInputs.length !== 2 || linkedActivityInputs.some((node) => node.checked !== true)) throw new Error('candidate activities were not available for review'); + byData(candidateView.container, 'data-journal-input', 'title').value = 'Review research capture'; + byData(candidateView.container, 'data-journal-input', 'summary').value = 'Read the capture and updated the project note.'; + linkedActivityInputs[1].checked = false; + byData(candidateView.container, 'data-journal-action', 'save-entry').click(); await flush(); - if (api.commandCalls.length !== 1) throw new Error('activity suggestion command was not called'); - if (api.commandCalls[0].args.workspaceRootPath !== 'Project') throw new Error('activity suggestion command used wrong workspace'); - if (api.storedEntries(projectKey).length !== 2) throw new Error('activity suggestion was not imported as a journal entry'); - if (!container.textContent.includes('Project work on 2026-06-27')) throw new Error('imported activity suggestion was not rendered'); + if (api.storedEntries(projectKey).length !== 2) throw new Error('reviewed candidate was not saved as a journal entry'); + const linkedEntry = api.storedEntries(projectKey).find((entry) => entry.sourceCandidateId === candidate.candidateId); + if (!linkedEntry) throw new Error('candidate reference was not stored on the journal entry'); + if (linkedEntry.title !== 'Review research capture' || linkedEntry.summary !== 'Read the capture and updated the project note.') { + throw new Error('candidate review did not keep the user-authored entry fields'); + } + if (linkedEntry.activityIds.join(',') !== 'capture-1') throw new Error('candidate review did not persist selected activity ids'); + if (walk(candidateView.container, (node) => node.getAttribute && node.getAttribute('data-journal-action') === 'view-activity')) { + throw new Error('journal rows must not navigate to Activity by default'); + } - byData(container, 'data-journal-action', 'import-activity').click(); - await flush(); - if (api.storedEntries(projectKey).length !== 2) throw new Error('duplicate activity suggestion was imported'); - - byData(container, 'data-journal-action', 'delete').click(); + byData(candidateView.container, 'data-journal-action', 'delete').click(); await flush(); if (api.storedEntries(projectKey).length !== 1) throw new Error('journal entry was not deleted'); const globalView = await mountWithApi(api, {}); - if (!globalView.container.textContent.includes('Project work on 2026-06-27') && !globalView.container.textContent.includes('Draft brief updated')) { + if (!globalView.container.textContent.includes('Review research capture') && !globalView.container.textContent.includes('Draft brief updated')) { throw new Error('global journal did not aggregate remaining entries'); } component.unmount && component.unmount(container); + component.unmount && component.unmount(candidateView.container); component.unmount && component.unmount(globalView.container); console.log('journal plugin smoke passed');