From e5f5d4d33f5ab47f956ff71246a1611d9bc45bf5 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Fri, 10 Jul 2026 08:21:11 +0800 Subject: [PATCH] feat: route work session candidates to journal --- frontend/e2e/activity.spec.js | 79 ++++- frontend/e2e/ux-today.spec.js | 32 +- frontend/src/lib/shell/TodaySurface.svelte | 34 +- frontend/src/lib/shell/WorkspaceHost.svelte | 30 +- frontend/src/lib/test/wails-mock.js | 344 +++++++++++++++++--- 5 files changed, 428 insertions(+), 91 deletions(-) diff --git a/frontend/e2e/activity.spec.js b/frontend/e2e/activity.spec.js index 4e95537..a1b1b01 100644 --- a/frontend/e2e/activity.spec.js +++ b/frontend/e2e/activity.spec.js @@ -27,7 +27,7 @@ test.describe('Activity workflow', () => { await expect(activity.locator('[data-activity-action="clear"]')).toBeDisabled(); }); - test('workspace activity renders stored events and worklog suggestions', async ({ page }) => { + test('workspace activity keeps raw events and renders factual work session candidates', async ({ page }) => { await page.evaluate(async () => { await window.go.api.App.WritePluginSettings('verstak.activity', { 'events:workspace:Project': [ @@ -42,7 +42,7 @@ test.describe('Activity workflow', () => { }, { activityId: 'activity-e2e-note', - occurredAt: '2026-06-30T08:25:00.000Z', + occurredAt: '2026-06-30T08:20:00.000Z', type: 'note.saved', title: 'Saved note', summary: 'Project/Notes/Research Capture.md', @@ -65,12 +65,77 @@ test.describe('Activity workflow', () => { await page.getByRole('tab', { name: 'Activity' }).click(); const activity = page.locator('.activity-root'); - await expect(activity.locator('.activity-count')).toHaveText('2 events'); + await expect(activity.locator('.activity-count')).toHaveText('3 events'); await expect(activity.locator('[data-activity-id="activity-e2e-capture"]')).toContainText('Research Capture'); await expect(activity.locator('[data-activity-id="activity-e2e-note"]')).toContainText('Saved note'); - await expect(activity.locator('[data-activity-id="activity-e2e-open"]')).toHaveCount(0); - await expect(activity.locator('[data-activity-section="worklog-suggestions"]')).toContainText('Worklog suggestions'); - await expect(activity.locator('[data-worklog-suggestion="worklog:Project:2026-06-30"]')).toContainText('Project work on 2026-06-30'); - await expect(activity.locator('[data-worklog-suggestion="worklog:Project:2026-06-30"]')).toContainText('50 min'); + await expect(activity.locator('[data-activity-id="activity-e2e-open"]')).toContainText('Selected file'); + + const candidateSection = activity.locator('[data-activity-section="work-session-candidates"]'); + await expect(candidateSection).toContainText('Possible journal entries'); + const candidate = candidateSection.locator('[data-work-session-candidate]'); + await expect(candidate).toHaveCount(1); + await expect(candidate).toContainText('Workspace: Project'); + await expect(candidate).toContainText('Estimated duration: 20 min'); + await expect(candidate).toContainText('Activities: 2'); + await expect(candidate).not.toContainText('Project work on'); + await expect(candidate.locator('[data-work-session-action="review"]')).toBeVisible(); + await expect(candidate.locator('[data-work-session-action="dismiss"]')).toBeVisible(); + }); + + test('Review opens an empty Journal form with selectable candidate activities', async ({ page }) => { + await page.evaluate(async () => { + await window.go.api.App.WritePluginSettings('verstak.activity', { + 'events:workspace:Project': [ + { + activityId: 'review-capture', + occurredAt: '2026-06-30T08:00:00.000Z', + type: 'browser.capture.selection', + title: 'Research Capture', + workspaceRootPath: 'Project', + }, + { + activityId: 'review-note', + occurredAt: '2026-06-30T08:20:00.000Z', + type: 'note.saved', + title: 'Saved note', + workspaceRootPath: 'Project', + }, + ], + }); + }); + + await page.getByRole('tab', { name: 'Activity' }).click(); + await page.locator('[data-work-session-candidate] [data-work-session-action="review"]').click(); + + await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true'); + const journal = page.locator('.journal-root'); + await expect(journal).toBeVisible({ timeout: 10000 }); + await expect(journal.locator('[data-journal-candidate]')).toContainText('Workspace: Project'); + await expect(journal.locator('[data-journal-candidate]')).toContainText('Estimated duration: 20 min'); + await expect(journal.locator('[data-journal-input="title"]')).toHaveValue(''); + await expect(journal.locator('[data-journal-input="summary"]')).toHaveValue(''); + await expect(journal.locator('[data-journal-input="minutes"]')).toHaveValue('20'); + + const activityInputs = journal.locator('[data-journal-candidate-activity]'); + await expect(activityInputs).toHaveCount(2); + await expect(activityInputs.nth(0)).toBeChecked(); + await expect(activityInputs.nth(1)).toBeChecked(); + await journal.locator('[data-journal-input="title"]').fill('Review research capture'); + await journal.locator('[data-journal-input="summary"]').fill('Read the capture and updated the project note.'); + await activityInputs.nth(1).uncheck(); + await journal.locator('[data-journal-action="save-entry"]').click(); + + await expect(journal).toContainText('Review research capture'); + await expect(journal).toContainText('20 min'); + const stored = await page.evaluate(async () => { + const result = await window.go.api.App.ReadPluginSettings('verstak.journal'); + return Array.isArray(result) ? result[0]['worklog:workspace:Project'] : result['worklog:workspace:Project']; + }); + await expect(stored).toEqual([expect.objectContaining({ + title: 'Review research capture', + summary: 'Read the capture and updated the project note.', + sourceCandidateId: expect.any(String), + activityIds: ['review-capture'], + })]); }); }); diff --git a/frontend/e2e/ux-today.spec.js b/frontend/e2e/ux-today.spec.js index 2e480a6..fbf9cdb 100644 --- a/frontend/e2e/ux-today.spec.js +++ b/frontend/e2e/ux-today.spec.js @@ -145,6 +145,21 @@ test.describe('UX Overview workspace flow', () => { workspaceRootPath: 'Project', }, ], + 'work-session-candidates:workspace:Project': [ + { + candidateId: 'work-session:Project:overview-note:overview-file', + workspaceRootPath: 'Project', + startedAt: '2026-06-30T08:15:00.000Z', + endedAt: '2026-06-30T08:25:00.000Z', + estimatedMinutes: 10, + activityCount: 2, + activityIds: ['overview-file', 'overview-note'], + activities: [ + { activityId: 'overview-file', type: 'file.changed', occurredAt: '2026-06-30T08:20:00.000Z' }, + { activityId: 'overview-note', type: 'note.saved', occurredAt: '2026-06-30T08:25:00.000Z' }, + ], + }, + ], }); await window.go.api.App.WritePluginSettings('verstak.journal', { 'worklog:workspace:Project': [ @@ -157,15 +172,6 @@ test.describe('UX Overview workspace flow', () => { workspaceRootPath: 'Project', }, ], - 'suggestions:workspace:Project': [ - { - suggestionId: 'overview-suggestion-1', - date: '2026-06-30', - title: 'Project work on 2026-06-30', - minutes: 50, - workspaceRootPath: 'Project', - }, - ], }); }); @@ -179,6 +185,14 @@ test.describe('UX Overview workspace flow', () => { await expect(overview.locator('[data-overview-summary="activity"]')).toContainText('5 recorded events'); await expect(overview.locator('[data-overview-summary="journal"]')).toContainText('1'); await expect(overview.locator('[data-overview-summary="attention"]')).toContainText('3'); + const attention = overview.locator('[data-overview-section="attention"]'); + await expect(attention).toContainText('Possible journal entry'); + await expect(attention).toContainText('Workspace: Project · 10 min · 2 activities'); + await attention.locator('.overview-attention-row', { hasText: 'Possible journal entry' }).getByRole('button', { name: 'Review candidate' }).click(); + await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('.journal-root [data-journal-candidate]')).toContainText('Workspace: Project'); + await page.locator('.journal-modal-actions').getByRole('button', { name: 'Cancel' }).click(); + await page.getByRole('tab', { name: 'Overview' }).click(); const resume = overview.locator('[data-overview-section="continue"]'); const candidates = resume.locator('[data-overview-continue-item]'); diff --git a/frontend/src/lib/shell/TodaySurface.svelte b/frontend/src/lib/shell/TodaySurface.svelte index 1395e13..33ce589 100644 --- a/frontend/src/lib/shell/TodaySurface.svelte +++ b/frontend/src/lib/shell/TodaySurface.svelte @@ -31,7 +31,7 @@ let captures = []; let activityEvents = []; let journalEntries = []; - let worklogSuggestions = []; + let workSessionCandidates = []; let keyResources = []; let totalNotes = 0; let loadedWorkspaceRoot = ''; @@ -45,7 +45,9 @@ $: noteRecentChanges = countCategory(recentChanges, 'notes'); $: fileRecentChanges = countCategory(recentChanges, 'files'); $: unprocessedCaptures = captures.filter(item => item?.processed !== true); - $: needsAttention = buildNeedsAttention(unprocessedCaptures, worklogSuggestions); + $: linkedCandidateIds = new Set(journalEntries.map(item => String(item?.sourceCandidateId || '')).filter(Boolean)); + $: pendingWorkSessionCandidates = workSessionCandidates.filter(item => !linkedCandidateIds.has(String(item?.candidateId || ''))); + $: needsAttention = buildNeedsAttention(unprocessedCaptures, pendingWorkSessionCandidates); $: continueItems = buildContinueItems(activityEvents, unprocessedCaptures, journalEntries); $: attentionActionKind = needsAttention[0]?.actionKind || 'browser-inbox'; $: lastActive = lastActiveDate([...recentChanges, ...continueItems], captures, journalEntries); @@ -110,7 +112,7 @@ } function timeValue(item) { - return item?.capturedAt || item?.occurredAt || item?.receivedAt || item?.updatedAt || item?.modifiedAt || item?.date || item?.time || ''; + return item?.capturedAt || item?.endedAt || item?.startedAt || item?.occurredAt || item?.receivedAt || item?.updatedAt || item?.modifiedAt || item?.date || item?.time || ''; } function timeMs(item) { @@ -331,7 +333,7 @@ return [...captureCandidates, ...noteCandidates, ...fileCandidates, ...journalCandidates].slice(0, 4); } - function buildNeedsAttention(captureRows, suggestions) { + function buildNeedsAttention(captureRows, candidates) { const captureItems = sortByTime(captureRows).slice(0, 4).map(item => ({ id: item.captureId || `capture:${timeValue(item)}`, title: captureTitle(item), @@ -339,14 +341,15 @@ actionKind: 'browser-inbox', actionLabel: 'Review Inbox', })); - const suggestionItems = sortByTime(suggestions).slice(0, 4).map(item => ({ - id: item.suggestionId || item.entryId || `suggestion:${timeValue(item)}`, - title: item.title || item.summary || 'Possible journal entry', - meta: `${item.minutes ? item.minutes + ' min · ' : ''}${item.date || itemTimeLabel(item)}`, + const candidateItems = sortByTime(candidates).slice(0, 4).map(item => ({ + id: item.candidateId || `work-session:${timeValue(item)}`, + title: 'Possible journal entry', + meta: `Workspace: ${item.workspaceRootPath || workspaceRootPath || 'Unknown'} · ${item.estimatedMinutes || 0} min · ${item.activityCount || (item.activityIds || []).length || 0} activities`, actionKind: 'journal', actionLabel: 'Review candidate', + toolRequest: { type: 'work-session-candidate', candidate: item }, })); - return [...captureItems, ...suggestionItems].slice(0, 6); + return [...captureItems, ...candidateItems].slice(0, 6); } function countCategory(items, category) { @@ -430,17 +433,16 @@ workspaceKey('worklog:workspace:'), 'worklog', ]); - worklogSuggestions = rowsFor(journalSettings, [ - workspaceKey('suggestions:workspace:'), - 'suggestions', + workSessionCandidates = rowsFor(activitySettings, [ + workspaceKey('work-session-candidates:workspace:'), ]); keyResources = resources.keyResources; totalNotes = resources.totalNotes; loading = false; } - function openTool(kind) { - dispatch('openTool', { kind }); + function openTool(kind, toolRequest = null) { + dispatch('openTool', { kind, toolRequest }); } @@ -578,12 +580,12 @@
{item.title} {item.meta} - +
{/each} {:else} -

No pending captures or worklog suggestions.

+

No pending captures or possible journal entries.

{/if} {/if} diff --git a/frontend/src/lib/shell/WorkspaceHost.svelte b/frontend/src/lib/shell/WorkspaceHost.svelte index 1017cfa..95695e9 100644 --- a/frontend/src/lib/shell/WorkspaceHost.svelte +++ b/frontend/src/lib/shell/WorkspaceHost.svelte @@ -14,6 +14,9 @@ let workspaceTools = []; let toolsLoaded = false; let requestedToolKind = ''; + let requestedToolRequest = null; + let activeToolRequest = null; + let requestedWorkspaceRoot = ''; // TODO: Rename TodaySurface.svelte to OverviewSurface.svelte in a refactor-only follow-up. const overviewTool = { id: '__overview', title: 'Overview', pluginId: 'verstak.shell', component: 'TodaySurface', shell: true }; @@ -30,6 +33,11 @@ $: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || ''; $: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName; $: workspaceType = selectedWorkspace?.type || 'workspace'; + $: if (workspaceRootPath !== requestedWorkspaceRoot) { + requestedWorkspaceRoot = workspaceRootPath; + requestedToolRequest = null; + activeToolRequest = null; + } $: displayedTools = selectedWorkspace ? [overviewTool, ...workspaceTools] : []; $: activeTool = displayedTools.find(tool => toolKey(tool) === activeToolKey) || displayedTools[0] || null; $: if (displayedTools.length > 0 && (!activeToolKey || (toolsLoaded && !displayedTools.some(tool => toolKey(tool) === activeToolKey)))) { @@ -38,8 +46,10 @@ $: if (requestedToolKind && workspaceTools.length > 0) { const match = findWorkspaceTool(requestedToolKind); if (match) { + const toolRequest = requestedToolRequest; requestedToolKind = ''; - selectTool(match); + requestedToolRequest = null; + selectTool(match, toolRequest); } } $: if (selectedWorkspaceName) loadTools(); @@ -72,8 +82,9 @@ }); } - function selectTool(tool) { + function selectTool(tool, toolRequest = null) { activeToolKey = toolKey(tool); + activeToolRequest = toolRequest; window.dispatchEvent(new CustomEvent('verstak:workspace-tool-selected', { detail: { toolKey: activeToolKey, @@ -92,18 +103,23 @@ }); } - function requestWorkspaceTool(kind) { + function requestWorkspaceTool(kind, toolRequest = null) { requestedToolKind = String(kind || '').toLowerCase(); + requestedToolRequest = toolRequest; const match = findWorkspaceTool(requestedToolKind); - if (match) selectTool(match); + if (match) { + requestedToolKind = ''; + requestedToolRequest = null; + selectTool(match, toolRequest); + } } function openWorkspaceTool(event) { - requestWorkspaceTool(event?.detail?.kind); + requestWorkspaceTool(event?.detail?.kind, event?.detail?.toolRequest || null); } function handleWorkspaceOpenTool(event) { - requestWorkspaceTool(event?.detail?.kind); + requestWorkspaceTool(event?.detail?.kind, event?.detail?.toolRequest || null); } async function loadTools() { @@ -169,7 +185,7 @@ {/if} {/if} diff --git a/frontend/src/lib/test/wails-mock.js b/frontend/src/lib/test/wails-mock.js index ca09499..f58a987 100644 --- a/frontend/src/lib/test/wails-mock.js +++ b/frontend/src/lib/test/wails-mock.js @@ -214,11 +214,10 @@ name: 'Journal', version: '0.1.0', apiVersion: '0.1.0', - description: 'Workspace-scoped worklog journal.', + description: 'Workspace-scoped journal with user-authored entries and optional Activity links.', source: 'official', icon: 'book-open', provides: ['worklog', 'journal', 'report.worklog'], - optionalRequires: ['activity.reconstruction'], permissions: ['storage.namespace', 'ui.register'], frontend: { entry: 'frontend/dist/index.js' }, contributes: { @@ -1454,9 +1453,9 @@ '.activity-toolbar{display:flex;align-items:center;gap:.5rem;padding:.55rem .75rem;border-bottom:1px solid #16213e;background:#12122a;flex-wrap:wrap}', '.activity-title{font-size:.86rem;font-weight:600;color:#f0f0ff}.activity-count,.activity-status{font-size:.74rem;color:#8b8ba8}.activity-spacer{flex:1}', '.activity-btn{min-height:1.85rem;padding:.3rem .65rem;border:1px solid #1a3a5c;border-radius:4px;background:#0f3460;color:#e0e0f0;font-size:.76rem;cursor:pointer}.activity-btn:hover{background:#1a4a7a}.activity-btn:disabled{opacity:.45;cursor:default}.activity-btn.danger{border-color:#633;color:#ffb0b0}', - '.activity-suggestions{display:grid;gap:.5rem;padding:.65rem .75rem;border-bottom:1px solid rgba(22,33,62,.75);background:#111126}.activity-suggestions-title{font-size:.76rem;font-weight:600;color:#8b8ba8;text-transform:uppercase}.activity-suggestion{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.65rem;padding:.55rem .65rem;border:1px solid rgba(78,204,163,.24);border-radius:4px;background:#14142c}.activity-suggestion-title{font-size:.84rem;font-weight:600;color:#f4f7fb}.activity-suggestion-summary{margin-top:.22rem;color:#aaa;font-size:.76rem;line-height:1.4}.activity-suggestion-minutes{color:#4ecca3;font-size:.76rem;white-space:nowrap}', + '.activity-candidates{display:grid;gap:.5rem;padding:.65rem .75rem;border-bottom:1px solid rgba(22,33,62,.75);background:#111126}.activity-candidates-title{font-size:.76rem;font-weight:600;color:#8b8ba8;text-transform:uppercase}.activity-candidate{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.65rem;padding:.55rem .65rem;border:1px solid rgba(78,204,163,.32);border-radius:4px;background:#14142c}.activity-candidate-title{font-size:.84rem;font-weight:600;color:#f4f7fb}.activity-candidate-facts{margin-top:.22rem;display:grid;gap:.14rem;color:#aaa;font-size:.76rem;line-height:1.4}.activity-candidate-actions{display:flex;gap:.35rem;align-items:flex-start;flex-wrap:wrap}.activity-candidate-minutes{color:#4ecca3;font-size:.76rem;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;padding:1.5rem;color:#8b8ba8;font-size:.84rem;line-height:1.45;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,.7)}.activity-time{font-size:.72rem;color:#777;white-space:nowrap}.activity-main{min-width:0}.activity-row-head{display:flex;align-items:center;gap:.45rem;min-width:0}.activity-type{font-size:.68rem;color:#4ecca3;text-transform:uppercase;letter-spacing:.04em}.activity-title-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#e0e0f0;font-size:.86rem}.activity-summary{margin-top:.25rem;color:#aaa;font-size:.78rem;line-height:1.4}.activity-source{margin-top:.25rem;color:#777;font-size:.72rem}', - '@media(max-width:760px){.activity-row,.activity-suggestion{grid-template-columns:1fr;gap:.25rem}.activity-status{width:100%}}' + '@media(max-width:760px){.activity-row,.activity-candidate{grid-template-columns:1fr;gap:.25rem}.activity-status{width:100%}}' ].join(''); document.head.appendChild(style); } @@ -1495,22 +1494,11 @@ return Array.isArray(value) ? value.filter(function (item) { return item && typeof item === 'object'; }) : []; } - function meaningfulRows(value) { - return rows(value).filter(function (activity) { - return !LOW_VALUE_EVENT_TYPES[String(activity.type || '').toLowerCase()]; - }); - } - function eventTime(value) { var date = new Date(value || 0); return isNaN(date.getTime()) ? 0 : date.getTime(); } - function eventDay(activity) { - var value = activity.occurredAt || activity.receivedAt || new Date().toISOString(); - return String(value).slice(0, 10); - } - function formatDate(value) { var date = new Date(value || 0); if (isNaN(date.getTime())) return '-'; @@ -1521,29 +1509,60 @@ return activity.title || activity.summary || activity.type || activity.activityId || 'Activity event'; } - function summarize(group) { - return group.map(title).filter(Boolean).slice(0, 3).join('; ') || group.length + ' activity events'; - } - - function suggestions(events, root) { - var groups = {}; - events.forEach(function (activity) { - var workspace = activity.workspaceRootPath || root || 'Global'; - var day = eventDay(activity); - var key = workspace + '|' + day; - groups[key] = groups[key] || { workspaceRootPath: workspace, date: day, events: [] }; - groups[key].events.push(activity); + function workSessionCandidates(events, root) { + var ordered = rows(events).filter(function (activity) { + return !LOW_VALUE_EVENT_TYPES[String(activity.type || '').toLowerCase()] && eventTime(activity.occurredAt || activity.receivedAt); + }).sort(function (a, b) { + return eventTime(a.occurredAt || a.receivedAt) - eventTime(b.occurredAt || b.receivedAt); }); - return Object.keys(groups).map(function (key) { - var group = groups[key]; - var ordered = group.events.slice().sort(function (a, b) { return eventTime(a.occurredAt || a.receivedAt) - eventTime(b.occurredAt || b.receivedAt); }); - return { - suggestionId: 'worklog:' + group.workspaceRootPath + ':' + group.date, - title: group.workspaceRootPath + ' work on ' + group.date, - summary: summarize(ordered), - minutes: Math.max(25, ordered.length * 25) - }; - }).sort(function (a, b) { return b.suggestionId.localeCompare(a.suggestionId); }); + var candidates = []; + var current = null; + + function addCurrent() { + if (!current || current.events.length < 2) return; + var first = current.events[0]; + var last = current.events[current.events.length - 1]; + var duration = Math.round((eventTime(last.occurredAt || last.receivedAt) - eventTime(first.occurredAt || first.receivedAt)) / 60000); + if (duration < 10) return; + candidates.push({ + candidateId: 'work-session:' + encodeURIComponent(current.workspaceRootPath) + ':' + encodeURIComponent(first.activityId || '') + ':' + encodeURIComponent(last.activityId || ''), + workspaceRootPath: current.workspaceRootPath, + startedAt: new Date(eventTime(first.occurredAt || first.receivedAt)).toISOString(), + endedAt: new Date(eventTime(last.occurredAt || last.receivedAt)).toISOString(), + estimatedMinutes: duration, + activityCount: current.events.length, + activityIds: current.events.map(function (activity) { return activity.activityId; }), + activities: current.events.map(function (activity) { + return { + activityId: activity.activityId, + type: activity.type || 'activity.event', + occurredAt: new Date(eventTime(activity.occurredAt || activity.receivedAt)).toISOString(), + sourcePluginId: activity.sourcePluginId || '', + workspaceRootPath: activity.workspaceRootPath || root || '' + }; + }) + }); + } + + ordered.forEach(function (activity) { + var workspace = activity.workspaceRootPath || root || ''; + var time = eventTime(activity.occurredAt || activity.receivedAt); + if (!workspace) return; + if (!current) { + current = { workspaceRootPath: workspace, events: [activity] }; + return; + } + var firstTime = eventTime(current.events[0].occurredAt || current.events[0].receivedAt); + var lastTime = eventTime(current.events[current.events.length - 1].occurredAt || current.events[current.events.length - 1].receivedAt); + if (current.workspaceRootPath !== workspace || time - lastTime > 20 * 60 * 1000 || time - firstTime > 120 * 60 * 1000) { + addCurrent(); + current = { workspaceRootPath: workspace, events: [activity] }; + return; + } + current.events.push(activity); + }); + addCurrent(); + return candidates.sort(function (a, b) { return b.endedAt.localeCompare(a.endedAt); }); } function renderActivity(containerEl, props, api) { @@ -1551,6 +1570,7 @@ var rootPath = workspaceRoot(props || {}); var key = workspaceKey(rootPath); var events = []; + var dismissed = {}; var statusText = 'Listening for workspace activity'; containerEl.innerHTML = ''; @@ -1568,7 +1588,7 @@ persist().then(render); } }); - 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' }); toolbar.appendChild(titleEl); @@ -1577,7 +1597,7 @@ toolbar.appendChild(statusEl); toolbar.appendChild(clearBtn); root.appendChild(toolbar); - root.appendChild(suggestionsEl); + root.appendChild(candidatesEl); root.appendChild(listEl); containerEl.appendChild(root); @@ -1593,18 +1613,31 @@ }); } - function renderSuggestions() { - suggestionsEl.innerHTML = ''; - var items = suggestions(events, rootPath); + function renderCandidates() { + candidatesEl.innerHTML = ''; + var items = workSessionCandidates(events, rootPath).filter(function (item) { return !dismissed[item.candidateId]; }); if (!items.length) return; - suggestionsEl.appendChild(el('div', { className: 'activity-suggestions-title', textContent: 'Worklog suggestions' })); + candidatesEl.appendChild(el('div', { className: 'activity-candidates-title', textContent: 'Possible journal entries' })); items.forEach(function (item) { - suggestionsEl.appendChild(el('div', { className: 'activity-suggestion', 'data-worklog-suggestion': item.suggestionId }, [ + candidatesEl.appendChild(el('div', { className: 'activity-candidate', 'data-work-session-candidate': item.candidateId }, [ el('div', {}, [ - el('div', { className: 'activity-suggestion-title', textContent: item.title }), - el('div', { className: 'activity-suggestion-summary', textContent: item.summary }) + el('div', { className: 'activity-candidate-title', textContent: 'Possible journal entry' }), + el('div', { className: 'activity-candidate-facts' }, [ + el('div', { textContent: 'Workspace: ' + item.workspaceRootPath }), + el('div', { textContent: 'Estimated duration: ' + item.estimatedMinutes + ' min' }), + el('div', { textContent: 'Activities: ' + item.activityCount }) + ]) ]), - el('div', { className: 'activity-suggestion-minutes', textContent: item.minutes + ' min' }) + el('div', { className: 'activity-candidate-actions' }, [ + el('div', { className: 'activity-candidate-minutes', textContent: item.estimatedMinutes + ' min' }), + el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'review', textContent: 'Review', onClick: function () { + window.dispatchEvent(new CustomEvent('verstak:workspace-open-tool', { detail: { kind: 'journal', toolRequest: { type: 'work-session-candidate', candidate: item } } })); + } }), + el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'dismiss', textContent: 'Dismiss', onClick: function () { + dismissed[item.candidateId] = true; + render(); + } }) + ]) ])); }); } @@ -1637,12 +1670,12 @@ countEl.textContent = events.length + ' event' + (events.length === 1 ? '' : 's'); clearBtn.disabled = events.length === 0; statusEl.textContent = statusText; - renderSuggestions(); + renderCandidates(); renderList(); } readSettings().then(function (settings) { - events = meaningfulRows(settings[key]); + events = rows(settings[key]); render(); }); render(); @@ -1656,6 +1689,214 @@ }.toString() + ')();'; } + function journalBundle() { + return '(' + function () { + var PLUGIN_ID = 'verstak.journal'; + + function injectStyles() { + if (document.getElementById('mock-journal-style')) return; + var style = document.createElement('style'); + style.id = 'mock-journal-style'; + style.textContent = [ + '.journal-root{height:100%;min-height:0;display:flex;flex-direction:column;background:#0d0d1a;color:#e0e0f0}', + '.journal-toolbar{display:flex;align-items:center;gap:.5rem;padding:.55rem .75rem;border-bottom:1px solid #16213e;background:#12122a}.journal-title{font-size:.86rem;font-weight:600;color:#f0f0ff}.journal-count,.journal-status{font-size:.74rem;color:#8b8ba8}.journal-spacer{flex:1}', + '.journal-btn{min-height:1.85rem;padding:.3rem .65rem;border:1px solid #1a3a5c;border-radius:4px;background:#0f3460;color:#e0e0f0;font-size:.76rem;cursor:pointer}.journal-btn.primary{background:#4ecca3;border-color:#4ecca3;color:#102018}', + '.journal-list{flex:1;min-height:0;overflow:auto;padding:.5rem .75rem}.journal-empty{padding:1.5rem;color:#8b8ba8}.journal-row{display:grid;grid-template-columns:8rem minmax(0,1fr) auto;gap:.7rem;padding:.65rem 0;border-bottom:1px solid rgba(22,33,62,.75)}.journal-entry-title{font-weight:600}.journal-summary,.journal-meta{margin-top:.22rem;font-size:.76rem;color:#aaa}.journal-minutes{color:#4ecca3;font-size:.78rem;white-space:nowrap}', + '.journal-modal-host[hidden]{display:none}.journal-modal-overlay{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;padding:1rem;background:rgba(0,0,0,.58)}.journal-modal{width:520px;max-width:96vw;display:grid;gap:.75rem;padding:1rem;border:1px solid #2c456a;border-radius:8px;background:#15152c;box-shadow:0 18px 44px rgba(0,0,0,.38)}.journal-modal-title{font-size:.95rem;font-weight:600}.journal-modal-grid{display:grid;grid-template-columns:1fr 8rem;gap:.6rem}.journal-field{display:grid;gap:.3rem;font-size:.72rem;color:#8b8ba8}.journal-field.wide{grid-column:1/-1}.journal-input{min-width:0;padding:.38rem .5rem;border:1px solid #2c456a;border-radius:4px;background:#0f1424;color:#f4f7fb;font:inherit}.journal-input.textarea{min-height:6rem;resize:vertical}.journal-candidate-context{display:grid;gap:.2rem;padding:.65rem;border:1px solid rgba(78,204,163,.34);border-radius:6px;background:#111126;font-size:.76rem;color:#b7c0d4}.journal-candidate-activities{display:grid;gap:.35rem;margin:0;padding:.65rem;border:1px solid #202b46;border-radius:6px;font-size:.74rem;color:#b7c0d4}.journal-candidate-activity{display:flex;align-items:flex-start;gap:.45rem}.journal-modal-actions{display:flex;justify-content:flex-end;gap:.5rem}' + ].join(''); + document.head.appendChild(style); + } + + function el(tag, attrs, children) { + var node = document.createElement(tag); + attrs = attrs || {}; + Object.keys(attrs).forEach(function (key) { + if (key === 'textContent') node.textContent = attrs[key]; + else if (key === 'className') node.className = attrs[key]; + else if (key === 'disabled') node.disabled = !!attrs[key]; + else if (key === 'checked') node.checked = !!attrs[key]; + else if (key === 'value') node.value = attrs[key]; + else if (key.indexOf('data-') === 0) node.setAttribute(key, attrs[key]); + else if (key === 'onClick') node.addEventListener('click', attrs[key]); + else node[key] = attrs[key]; + }); + (children || []).forEach(function (child) { + if (child) node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child); + }); + return node; + } + + function workspaceRoot(props) { + return String(props.workspaceRootPath || (props.workspaceNode && (props.workspaceNode.rootPath || props.workspaceNode.name || props.workspaceNode.id)) || props.workspaceName || '').trim(); + } + + function workspaceKey(root) { + return 'worklog:workspace:' + encodeURIComponent(root || ''); + } + + function rows(value) { + return Array.isArray(value) ? value.filter(function (item) { return item && typeof item === 'object'; }) : []; + } + + function candidateFromProps(props, root) { + var request = props && props.toolRequest; + var candidate = request && request.type === 'work-session-candidate' ? request.candidate : null; + if (!candidate || candidate.workspaceRootPath !== root || !candidate.candidateId) return null; + var activities = rows(candidate.activities).filter(function (activity) { return activity.activityId; }); + return { + candidateId: String(candidate.candidateId), + workspaceRootPath: root, + startedAt: candidate.startedAt || '', + endedAt: candidate.endedAt || '', + estimatedMinutes: Number(candidate.estimatedMinutes || 0), + activityCount: Number(candidate.activityCount || activities.length), + activities: activities, + activityIds: Array.isArray(candidate.activityIds) ? candidate.activityIds : activities.map(function (activity) { return activity.activityId; }) + }; + } + + function candidateDate(value) { + var date = new Date(value || ''); + return isNaN(date.getTime()) ? new Date().toISOString().slice(0, 10) : date.toISOString().slice(0, 10); + } + + function candidateTime(value) { + var date = new Date(value || ''); + return isNaN(date.getTime()) ? String(value || '') : date.toLocaleString(undefined, { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }); + } + + function JournalView() {} + + JournalView.mount = function (containerEl, props, api) { + injectStyles(); + var rootPath = workspaceRoot(props || {}); + var key = workspaceKey(rootPath); + var entries = []; + var modalHost = el('div', { className: 'journal-modal-host', hidden: true }); + containerEl.innerHTML = ''; + var root = el('div', { className: 'journal-root', 'data-plugin-id': PLUGIN_ID }); + var toolbar = el('div', { className: 'journal-toolbar' }); + var countEl = el('span', { className: 'journal-count' }); + var addBtn = el('button', { className: 'journal-btn primary', 'data-journal-action': 'add', textContent: 'Add', onClick: function () { showEntryModal(null); } }); + var listEl = el('div', { className: 'journal-list' }); + toolbar.appendChild(el('span', { className: 'journal-title', textContent: rootPath ? 'Journal · ' + rootPath : 'Journal' })); + toolbar.appendChild(countEl); + toolbar.appendChild(el('span', { className: 'journal-spacer' })); + toolbar.appendChild(addBtn); + root.appendChild(toolbar); + root.appendChild(listEl); + root.appendChild(modalHost); + containerEl.appendChild(root); + + function closeModal() { + modalHost.innerHTML = ''; + modalHost.hidden = true; + } + + function persist() { + return api.settings.write(key, entries); + } + + function showEntryModal(candidate) { + var reviewing = !!candidate; + var titleInput = el('input', { className: 'journal-input', type: 'text', value: '', 'data-journal-input': 'title' }); + var summaryInput = el('textarea', { className: 'journal-input textarea', value: '', 'data-journal-input': 'summary' }); + var minutesInput = el('input', { className: 'journal-input', type: 'number', value: reviewing ? String(candidate.estimatedMinutes) : '30', 'data-journal-input': 'minutes' }); + var dateInput = el('input', { className: 'journal-input', type: 'date', value: reviewing ? candidateDate(candidate.startedAt) : new Date().toISOString().slice(0, 10), 'data-journal-input': 'date' }); + var billableInput = el('input', { type: 'checkbox', checked: false, 'data-journal-input': 'billable' }); + var activityInputs = reviewing ? candidate.activities.map(function (activity) { + return { activity: activity, input: el('input', { type: 'checkbox', checked: true, 'data-journal-candidate-activity': activity.activityId }) }; + }) : []; + var context = reviewing ? 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 linked = reviewing ? el('fieldset', { className: 'journal-candidate-activities' }, [ + el('legend', { textContent: 'Linked activities' }) + ].concat(activityInputs.map(function (item) { + return el('label', { className: 'journal-candidate-activity' }, [item.input, (item.activity.type || 'activity.event') + ' · ' + item.activity.activityId]); + }))) : null; + + function save() { + var title = String(titleInput.value || '').trim(); + if (!title) return; + var entry = { + entryId: 'journal:' + Date.now(), + workspaceRootPath: rootPath, + date: dateInput.value, + title: title, + summary: String(summaryInput.value || ''), + minutes: Number(minutesInput.value || 0), + billable: billableInput.checked === true, + sourceCandidateId: reviewing ? candidate.candidateId : '', + activityIds: reviewing ? activityInputs.filter(function (item) { return item.input.checked; }).map(function (item) { return item.activity.activityId; }) : [] + }; + entries = [entry].concat(entries); + closeModal(); + persist().then(render); + } + + modalHost.innerHTML = ''; + modalHost.hidden = false; + modalHost.appendChild(el('div', { className: 'journal-modal-overlay' }, [ + el('div', { className: 'journal-modal' }, [ + el('div', { className: 'journal-modal-title', textContent: reviewing ? 'Review possible journal entry' : 'Add journal entry' }), + context, + 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' }, ['Body', summaryInput]), + el('label', { className: 'journal-field wide' }, [billableInput, 'Billable']) + ]), + linked, + el('div', { className: 'journal-modal-actions' }, [ + el('button', { className: 'journal-btn', textContent: 'Cancel', onClick: closeModal }), + el('button', { className: 'journal-btn primary', 'data-journal-action': 'save-entry', textContent: 'Add entry', onClick: save }) + ]) + ]) + ])); + titleInput.focus(); + } + + function render() { + countEl.textContent = entries.length + ' entr' + (entries.length === 1 ? 'y' : 'ies'); + listEl.innerHTML = ''; + if (!entries.length) { + listEl.appendChild(el('div', { className: 'journal-empty', textContent: 'No journal entries yet.' })); + return; + } + entries.forEach(function (entry) { + var activityIds = Array.isArray(entry.activityIds) ? entry.activityIds : (Array.isArray(entry.eventIds) ? entry.eventIds : []); + listEl.appendChild(el('div', { className: 'journal-row', 'data-journal-entry': entry.entryId }, [ + el('div', { textContent: entry.date }), + el('div', {}, [ + 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 + (activityIds.length ? ' · ' + activityIds.length + ' linked activities' : '') }) + ]), + el('div', { className: 'journal-minutes', textContent: entry.minutes + ' min' }) + ])); + }); + } + + api.settings.read(key).then(function (stored) { + entries = rows(stored); + render(); + var candidate = candidateFromProps(props || {}, rootPath); + if (candidate) showEntryModal(candidate); + }).catch(function () { render(); }); + render(); + }; + + JournalView.unmount = function (containerEl) { containerEl.innerHTML = ''; }; + window.VerstakPluginRegister('verstak.journal', { components: { JournalView: JournalView } }); + }.toString() + ')();'; + } + function browserInboxBundle() { return '(' + function () { var PLUGIN_ID = 'verstak.browser-inbox'; @@ -2208,7 +2449,7 @@ return Promise.resolve(activityBundle()); } if (pluginId === 'verstak.journal' && assetPath === 'frontend/dist/index.js') { - return Promise.resolve(simplePluginBundle('verstak.journal', 'JournalView', 'journal-root', 'Journal')); + return Promise.resolve(journalBundle()); } if (pluginId === 'verstak.browser-inbox' && assetPath === 'frontend/dist/index.js') { return Promise.resolve(browserInboxBundle()); @@ -2771,11 +3012,10 @@ name: 'Journal', version: '0.1.0', apiVersion: '0.1.0', - description: 'Workspace-scoped worklog journal.', + description: 'Workspace-scoped journal with user-authored entries and optional Activity links.', source: 'official', icon: 'book-open', provides: ['worklog', 'journal', 'report.worklog'], - optionalRequires: ['activity.reconstruction'], permissions: ['storage.namespace', 'ui.register'], frontend: { entry: 'frontend/dist/index.js' }, contributes: {