diff --git a/plugins/activity/frontend/src/index.js b/plugins/activity/frontend/src/index.js index 3d685f1..f5d71be 100644 --- a/plugins/activity/frontend/src/index.js +++ b/plugins/activity/frontend/src/index.js @@ -8,9 +8,11 @@ var PLUGIN_ID = 'verstak.activity'; var MAX_EVENTS = 250; + var MAX_SUGGESTIONS = 12; var LEGACY_KEY = 'events'; var GLOBAL_KEY = 'events:global'; var WORKSPACE_PREFIX = 'events:workspace:'; + var WORKLOG_COMMAND_ID = 'verstak.activity.suggestWorklog'; var ACTIVITY_EVENTS = [ 'file.opened', 'file.changed', @@ -43,6 +45,12 @@ '.activity-btn.danger{border-color:#633;color:#ff9a9a}', '.activity-status{font-size:.72rem;color:#8b8ba8;white-space:nowrap}', '.activity-status.error{color:#e74c3c}', + '.activity-suggestions{border-bottom:1px solid rgba(22,33,62,.65);background:#111126;padding:.55rem .75rem;display:grid;gap:.5rem}', + '.activity-suggestions-title{font-size:.76rem;font-weight:600;color:#8b8ba8;text-transform:uppercase;letter-spacing:.04em}', + '.activity-suggestion{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.65rem;align-items:start;padding:.55rem .65rem;border:1px solid rgba(78,204,163,.25);border-radius:4px;background:#14142c}', + '.activity-suggestion-title{font-size:.84rem;color:#e0e0e0;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', + '.activity-suggestion-summary{margin-top:.22rem;font-size:.76rem;color:#aaa;line-height:1.4;white-space:pre-wrap;overflow-wrap:anywhere}', + '.activity-suggestion-minutes{font-size:.76rem;color:#4ecca3;white-space:nowrap}', '.activity-list{flex:1;min-height:0;overflow:auto;background:#101020}', '.activity-empty{height:100%;display:flex;align-items:center;justify-content:center;color:#666;font-size:.86rem;padding:2rem;text-align:center}', '.activity-row{display:grid;grid-template-columns:9.5rem minmax(0,1fr);gap:.75rem;padding:.72rem .85rem;border-bottom:1px solid rgba(22,33,62,.6)}', @@ -53,7 +61,7 @@ '.activity-title-text{font-size:.86rem;color:#e0e0e0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', '.activity-summary{margin-top:.25rem;font-size:.78rem;line-height:1.4;color:#aaa;white-space:pre-wrap;overflow-wrap:anywhere}', '.activity-source{margin-top:.25rem;font-size:.72rem;color:#777}', - '@media(max-width:760px){.activity-row{grid-template-columns:1fr;gap:.25rem}.activity-toolbar{align-items:stretch}.activity-status{width:100%}}' + '@media(max-width:760px){.activity-row,.activity-suggestion{grid-template-columns:1fr;gap:.25rem}.activity-toolbar{align-items:stretch}.activity-status{width:100%}}' ].join('\n'); function el(tag, attrs, children) { @@ -168,6 +176,83 @@ }).slice(0, MAX_EVENTS); } + function eventTimeMs(activity) { + var value = activity && (activity.occurredAt || activity.receivedAt); + var date = value ? new Date(value) : null; + 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 suggestionWorkspace(activity) { + return cleanWorkspace(activity && (activity.workspaceRootPath || workspaceFromPayload(activity.payload || {}))) || 'Global'; + } + + function roundUpQuarterHour(minutes) { + return Math.ceil(minutes / 15) * 15; + } + + 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 eventLabel(activity) { + return text(activity && (activity.title || activity.summary || activity.type || activity.activityId)).trim(); + } + + 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 || []).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) { @@ -176,6 +261,27 @@ return keys; } + function eventsFromSettings(settings, workspaceRoot) { + settings = settings || {}; + var workspace = cleanWorkspace(workspaceRoot); + if (!workspace) { + var all = []; + globalEventKeys(settings).forEach(function (key) { + all = all.concat(normalizeStoredEvents(settings[key], key)); + }); + return sortEvents(all); + } + var workspaceKey = WORKSPACE_PREFIX + encodeKey(workspace); + var scopedEvents = normalizeStoredEvents(settings[workspaceKey], workspaceKey); + var globalEvents = normalizeStoredEvents(settings[GLOBAL_KEY], GLOBAL_KEY).filter(function (item) { + return item.workspaceRootPath === workspace; + }); + var legacyEvents = normalizeStoredEvents(settings[LEGACY_KEY], LEGACY_KEY).filter(function (item) { + return item.workspaceRootPath === workspace; + }); + return sortEvents(scopedEvents.concat(globalEvents, legacyEvents)); + } + function formatDate(value) { if (!value) return ''; var date = new Date(value); @@ -193,6 +299,7 @@ var scope = scopeFromProps(props || {}); var events = []; + var suggestions = []; var statusText = 'Loading activity...'; var statusClass = ''; var disposed = false; @@ -212,6 +319,7 @@ return; } events = []; + updateSuggestions(); persist().then(render); } }); @@ -221,10 +329,19 @@ toolbar.appendChild(statusEl); toolbar.appendChild(clearBtn); + var suggestionsEl = el('div', { + className: 'activity-suggestions', + 'data-activity-section': 'worklog-suggestions' + }); var listEl = el('div', { className: 'activity-list' }); containerEl.appendChild(toolbar); + containerEl.appendChild(suggestionsEl); containerEl.appendChild(listEl); + function updateSuggestions() { + suggestions = buildWorklogSuggestions(events, scope.mode === 'workspace' ? scope.workspaceRoot : ''); + } + function persist() { if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve(); var toStore = scope.mode === 'global' @@ -280,11 +397,36 @@ }); } + function renderSuggestions() { + suggestionsEl.innerHTML = ''; + if (!suggestions.length) { + suggestionsEl.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 + }, [ + el('div', {}, [ + el('div', { className: 'activity-suggestion-title', textContent: suggestion.title }), + el('div', { className: 'activity-suggestion-summary', textContent: suggestion.summary }) + ]), + el('div', { className: 'activity-suggestion-minutes', textContent: suggestion.minutes + ' min' }) + ])); + }); + } + function render() { countEl.textContent = events.length + ' event' + (events.length === 1 ? '' : 's'); clearBtn.disabled = events.length === 0; statusEl.textContent = statusText; statusEl.className = 'activity-status' + (statusClass ? ' ' + statusClass : ''); + updateSuggestions(); + renderSuggestions(); renderList(); } @@ -292,31 +434,43 @@ if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve(); if (scope.mode === 'global') { return api.settings.read().then(function (settings) { - var all = []; - globalEventKeys(settings || {}).forEach(function (key) { - all = all.concat(normalizeStoredEvents((settings || {})[key], key)); - }); - events = sortEvents(all); + 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) { - var scopedEvents = normalizeStoredEvents((settings || {})[scope.key], scope.key); - var globalEvents = normalizeStoredEvents((settings || {})[GLOBAL_KEY], GLOBAL_KEY).filter(function (item) { - return item.workspaceRootPath === scope.workspaceRoot; - }); - var legacyEvents = normalizeStoredEvents((settings || {})[LEGACY_KEY], LEGACY_KEY).filter(function (item) { - return item.workspaceRootPath === scope.workspaceRoot; - }); - events = sortEvents(scopedEvents.concat(globalEvents, legacyEvents)); + events = eventsFromSettings(settings || {}, scope.workspaceRoot); }).catch(function (err) { statusText = 'Could not load activity: ' + (err && err.message ? err.message : String(err)); statusClass = 'error'; }); } + function suggestWorklog(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 api.settings.read().then(function (settings) { + var sourceEvents = eventsFromSettings(settings || {}, workspace); + return { suggestions: buildWorklogSuggestions(sourceEvents, workspace) }; + }).catch(function () { + return { suggestions: [] }; + }); + } + + 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) { + if (typeof unregister === 'function') unsubscribers.push(unregister); + }).catch(function (err) { + statusText = 'Activity commands unavailable: ' + (err && err.message ? err.message : String(err)); + statusClass = 'error'; + }); + } + function subscribeEvents() { if (!api || !api.events || typeof api.events.subscribe !== 'function') return Promise.resolve(); return Promise.all(ACTIVITY_EVENTS.map(function (eventName) { @@ -340,6 +494,9 @@ loadStored().then(function () { if (disposed) return; render(); + return registerCommands(); + }).then(function () { + if (disposed) return; return subscribeEvents(); }).then(function () { if (!disposed) render(); diff --git a/plugins/activity/plugin.json b/plugins/activity/plugin.json index 90de996..c92c6f0 100644 --- a/plugins/activity/plugin.json +++ b/plugins/activity/plugin.json @@ -15,7 +15,8 @@ "permissions": [ "events.subscribe", "storage.namespace", - "ui.register" + "ui.register", + "commands.register" ], "frontend": { "entry": "frontend/src/index.js" @@ -46,6 +47,13 @@ "component": "ActivityView" } ], + "commands": [ + { + "id": "verstak.activity.suggestWorklog", + "title": "Suggest Worklog From Activity", + "handler": "verstak.activity.suggestWorklog" + } + ], "activityProviders": [ { "id": "verstak.activity.log", diff --git a/scripts/smoke-activity-plugin.js b/scripts/smoke-activity-plugin.js index 01f446b..da7cf86 100644 --- a/scripts/smoke-activity-plugin.js +++ b/scripts/smoke-activity-plugin.js @@ -5,7 +5,10 @@ const vm = require('vm'); const root = path.resolve(__dirname, '..'); const sourcePath = path.join(root, 'plugins', 'activity', 'frontend', 'src', 'index.js'); +const manifestPath = path.join(root, 'plugins', 'activity', 'plugin.json'); const source = fs.readFileSync(sourcePath, 'utf8'); +const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); +const WORKLOG_COMMAND_ID = 'verstak.activity.suggestWorklog'; class FakeNode { constructor(tagName) { @@ -116,9 +119,11 @@ function loadComponent(document) { function makeApi(initialSettings = {}) { const settings = { ...initialSettings }; const handlers = {}; + const commandHandlers = new Map(); const unsubscribed = []; return { handlers, + commandHandlers, unsubscribed, settings: { read: async (key) => (key ? settings[key] : { ...settings }), @@ -136,6 +141,12 @@ function makeApi(initialSettings = {}) { }; }, }, + commands: { + register: async (commandId, handler) => { + commandHandlers.set(commandId, handler); + return () => commandHandlers.delete(commandId); + }, + }, storedEvents(key = 'events') { return settings[key] || []; }, @@ -161,6 +172,11 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w const clientKey = 'events:workspace:ClientA'; const globalKey = 'events:global'; + if (!manifest.permissions.includes('commands.register')) throw new Error('activity manifest must request commands.register'); + const worklogCommand = (manifest.contributes.commands || []).find((item) => item.id === WORKLOG_COMMAND_ID); + if (!worklogCommand || worklogCommand.handler !== WORKLOG_COMMAND_ID) throw new Error('activity worklog suggestion command contribution is missing'); + if (typeof api.commandHandlers.get(WORKLOG_COMMAND_ID) !== 'function') throw new Error('activity worklog suggestion command was not registered'); + for (const name of ['file.opened', 'file.changed', 'note.saved', 'action.started', 'browser.capture.received', 'case.selected', 'browser.capture.selection']) { if (typeof api.handlers[name] !== 'function') throw new Error(`${name} subscription missing`); } @@ -181,6 +197,19 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w text: 'Selected text', workspaceRootPath: 'Project', }, + }, { + activityId: 'note-1', + type: 'note.saved', + title: 'Saved note', + summary: 'Project/Notes/Case.md', + occurredAt: '2026-06-27T00:20:00Z', + sourcePluginId: 'verstak.notes', + workspaceRootPath: 'Project', + payload: { + title: 'Saved note', + path: 'Project/Notes/Case.md', + workspaceRootPath: 'Project', + }, }]); await api.handlers['browser.capture.selection']({ name: 'browser.capture.selection', @@ -197,12 +226,25 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w await flush(); const stored = api.storedEvents(projectKey); - if (stored.length !== 1) throw new Error(`expected one stored activity event, got ${stored.length}`); + if (stored.length !== 2) throw new Error(`expected two stored activity events, got ${stored.length}`); if (stored[0].type !== 'browser.capture.selection') throw new Error('stored event type mismatch'); if (stored[0].sourcePluginId !== 'verstak.browser-inbox') throw new Error('stored event source plugin mismatch'); if (api.storedEvents(globalKey).length !== 0) throw new Error('workspace activity leaked into global storage'); 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('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'); + + 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') throw new Error('worklog suggestion event ids mismatch'); 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'); @@ -233,11 +275,14 @@ 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'); 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'); component.unmount && component.unmount(globalView.container); const manualButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-activity-action') === 'manual'); @@ -248,6 +293,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'); component.unmount && component.unmount(container); if (api.unsubscribed.length !== 27) throw new Error(`expected 27 unsubscribers, got ${api.unsubscribed.length}`);