feat: route work session candidates to journal

This commit is contained in:
mirivlad 2026-07-10 08:21:11 +08:00
parent b056016929
commit e5f5d4d33f
5 changed files with 428 additions and 91 deletions

View File

@ -27,7 +27,7 @@ test.describe('Activity workflow', () => {
await expect(activity.locator('[data-activity-action="clear"]')).toBeDisabled(); 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 page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.activity', { await window.go.api.App.WritePluginSettings('verstak.activity', {
'events:workspace:Project': [ 'events:workspace:Project': [
@ -42,7 +42,7 @@ test.describe('Activity workflow', () => {
}, },
{ {
activityId: 'activity-e2e-note', activityId: 'activity-e2e-note',
occurredAt: '2026-06-30T08:25:00.000Z', occurredAt: '2026-06-30T08:20:00.000Z',
type: 'note.saved', type: 'note.saved',
title: 'Saved note', title: 'Saved note',
summary: 'Project/Notes/Research Capture.md', summary: 'Project/Notes/Research Capture.md',
@ -65,12 +65,77 @@ test.describe('Activity workflow', () => {
await page.getByRole('tab', { name: 'Activity' }).click(); await page.getByRole('tab', { name: 'Activity' }).click();
const activity = page.locator('.activity-root'); 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-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-note"]')).toContainText('Saved note');
await expect(activity.locator('[data-activity-id="activity-e2e-open"]')).toHaveCount(0); await expect(activity.locator('[data-activity-id="activity-e2e-open"]')).toContainText('Selected file');
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'); const candidateSection = activity.locator('[data-activity-section="work-session-candidates"]');
await expect(activity.locator('[data-worklog-suggestion="worklog:Project:2026-06-30"]')).toContainText('50 min'); 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'],
})]);
}); });
}); });

View File

@ -145,6 +145,21 @@ test.describe('UX Overview workspace flow', () => {
workspaceRootPath: 'Project', 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', { await window.go.api.App.WritePluginSettings('verstak.journal', {
'worklog:workspace:Project': [ 'worklog:workspace:Project': [
@ -157,15 +172,6 @@ test.describe('UX Overview workspace flow', () => {
workspaceRootPath: 'Project', 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="activity"]')).toContainText('5 recorded events');
await expect(overview.locator('[data-overview-summary="journal"]')).toContainText('1'); await expect(overview.locator('[data-overview-summary="journal"]')).toContainText('1');
await expect(overview.locator('[data-overview-summary="attention"]')).toContainText('3'); 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 resume = overview.locator('[data-overview-section="continue"]');
const candidates = resume.locator('[data-overview-continue-item]'); const candidates = resume.locator('[data-overview-continue-item]');

View File

@ -31,7 +31,7 @@
let captures = []; let captures = [];
let activityEvents = []; let activityEvents = [];
let journalEntries = []; let journalEntries = [];
let worklogSuggestions = []; let workSessionCandidates = [];
let keyResources = []; let keyResources = [];
let totalNotes = 0; let totalNotes = 0;
let loadedWorkspaceRoot = ''; let loadedWorkspaceRoot = '';
@ -45,7 +45,9 @@
$: noteRecentChanges = countCategory(recentChanges, 'notes'); $: noteRecentChanges = countCategory(recentChanges, 'notes');
$: fileRecentChanges = countCategory(recentChanges, 'files'); $: fileRecentChanges = countCategory(recentChanges, 'files');
$: unprocessedCaptures = captures.filter(item => item?.processed !== true); $: 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); $: continueItems = buildContinueItems(activityEvents, unprocessedCaptures, journalEntries);
$: attentionActionKind = needsAttention[0]?.actionKind || 'browser-inbox'; $: attentionActionKind = needsAttention[0]?.actionKind || 'browser-inbox';
$: lastActive = lastActiveDate([...recentChanges, ...continueItems], captures, journalEntries); $: lastActive = lastActiveDate([...recentChanges, ...continueItems], captures, journalEntries);
@ -110,7 +112,7 @@
} }
function timeValue(item) { 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) { function timeMs(item) {
@ -331,7 +333,7 @@
return [...captureCandidates, ...noteCandidates, ...fileCandidates, ...journalCandidates].slice(0, 4); 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 => ({ const captureItems = sortByTime(captureRows).slice(0, 4).map(item => ({
id: item.captureId || `capture:${timeValue(item)}`, id: item.captureId || `capture:${timeValue(item)}`,
title: captureTitle(item), title: captureTitle(item),
@ -339,14 +341,15 @@
actionKind: 'browser-inbox', actionKind: 'browser-inbox',
actionLabel: 'Review Inbox', actionLabel: 'Review Inbox',
})); }));
const suggestionItems = sortByTime(suggestions).slice(0, 4).map(item => ({ const candidateItems = sortByTime(candidates).slice(0, 4).map(item => ({
id: item.suggestionId || item.entryId || `suggestion:${timeValue(item)}`, id: item.candidateId || `work-session:${timeValue(item)}`,
title: item.title || item.summary || 'Possible journal entry', title: 'Possible journal entry',
meta: `${item.minutes ? item.minutes + ' min · ' : ''}${item.date || itemTimeLabel(item)}`, meta: `Workspace: ${item.workspaceRootPath || workspaceRootPath || 'Unknown'} · ${item.estimatedMinutes || 0} min · ${item.activityCount || (item.activityIds || []).length || 0} activities`,
actionKind: 'journal', actionKind: 'journal',
actionLabel: 'Review candidate', 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) { function countCategory(items, category) {
@ -430,17 +433,16 @@
workspaceKey('worklog:workspace:'), workspaceKey('worklog:workspace:'),
'worklog', 'worklog',
]); ]);
worklogSuggestions = rowsFor(journalSettings, [ workSessionCandidates = rowsFor(activitySettings, [
workspaceKey('suggestions:workspace:'), workspaceKey('work-session-candidates:workspace:'),
'suggestions',
]); ]);
keyResources = resources.keyResources; keyResources = resources.keyResources;
totalNotes = resources.totalNotes; totalNotes = resources.totalNotes;
loading = false; loading = false;
} }
function openTool(kind) { function openTool(kind, toolRequest = null) {
dispatch('openTool', { kind }); dispatch('openTool', { kind, toolRequest });
} }
</script> </script>
@ -578,12 +580,12 @@
<div class="today-row overview-attention-row"> <div class="today-row overview-attention-row">
<strong title={item.title}>{item.title}</strong> <strong title={item.title}>{item.title}</strong>
<span>{item.meta}</span> <span>{item.meta}</span>
<button type="button" on:click={() => openTool(item.actionKind)}>{item.actionLabel}</button> <button type="button" on:click={() => openTool(item.actionKind, item.toolRequest)}>{item.actionLabel}</button>
</div> </div>
{/each} {/each}
</div> </div>
{:else} {:else}
<p class="today-empty compact">No pending captures or worklog suggestions.</p> <p class="today-empty compact">No pending captures or possible journal entries.</p>
{/if} {/if}
</section> </section>
{/if} {/if}

View File

@ -14,6 +14,9 @@
let workspaceTools = []; let workspaceTools = [];
let toolsLoaded = false; let toolsLoaded = false;
let requestedToolKind = ''; let requestedToolKind = '';
let requestedToolRequest = null;
let activeToolRequest = null;
let requestedWorkspaceRoot = '';
// TODO: Rename TodaySurface.svelte to OverviewSurface.svelte in a refactor-only follow-up. // 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 }; const overviewTool = { id: '__overview', title: 'Overview', pluginId: 'verstak.shell', component: 'TodaySurface', shell: true };
@ -30,6 +33,11 @@
$: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || ''; $: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || '';
$: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName; $: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName;
$: workspaceType = selectedWorkspace?.type || 'workspace'; $: workspaceType = selectedWorkspace?.type || 'workspace';
$: if (workspaceRootPath !== requestedWorkspaceRoot) {
requestedWorkspaceRoot = workspaceRootPath;
requestedToolRequest = null;
activeToolRequest = null;
}
$: displayedTools = selectedWorkspace ? [overviewTool, ...workspaceTools] : []; $: displayedTools = selectedWorkspace ? [overviewTool, ...workspaceTools] : [];
$: activeTool = displayedTools.find(tool => toolKey(tool) === activeToolKey) || displayedTools[0] || null; $: activeTool = displayedTools.find(tool => toolKey(tool) === activeToolKey) || displayedTools[0] || null;
$: if (displayedTools.length > 0 && (!activeToolKey || (toolsLoaded && !displayedTools.some(tool => toolKey(tool) === activeToolKey)))) { $: if (displayedTools.length > 0 && (!activeToolKey || (toolsLoaded && !displayedTools.some(tool => toolKey(tool) === activeToolKey)))) {
@ -38,8 +46,10 @@
$: if (requestedToolKind && workspaceTools.length > 0) { $: if (requestedToolKind && workspaceTools.length > 0) {
const match = findWorkspaceTool(requestedToolKind); const match = findWorkspaceTool(requestedToolKind);
if (match) { if (match) {
const toolRequest = requestedToolRequest;
requestedToolKind = ''; requestedToolKind = '';
selectTool(match); requestedToolRequest = null;
selectTool(match, toolRequest);
} }
} }
$: if (selectedWorkspaceName) loadTools(); $: if (selectedWorkspaceName) loadTools();
@ -72,8 +82,9 @@
}); });
} }
function selectTool(tool) { function selectTool(tool, toolRequest = null) {
activeToolKey = toolKey(tool); activeToolKey = toolKey(tool);
activeToolRequest = toolRequest;
window.dispatchEvent(new CustomEvent('verstak:workspace-tool-selected', { window.dispatchEvent(new CustomEvent('verstak:workspace-tool-selected', {
detail: { detail: {
toolKey: activeToolKey, toolKey: activeToolKey,
@ -92,18 +103,23 @@
}); });
} }
function requestWorkspaceTool(kind) { function requestWorkspaceTool(kind, toolRequest = null) {
requestedToolKind = String(kind || '').toLowerCase(); requestedToolKind = String(kind || '').toLowerCase();
requestedToolRequest = toolRequest;
const match = findWorkspaceTool(requestedToolKind); const match = findWorkspaceTool(requestedToolKind);
if (match) selectTool(match); if (match) {
requestedToolKind = '';
requestedToolRequest = null;
selectTool(match, toolRequest);
}
} }
function openWorkspaceTool(event) { function openWorkspaceTool(event) {
requestWorkspaceTool(event?.detail?.kind); requestWorkspaceTool(event?.detail?.kind, event?.detail?.toolRequest || null);
} }
function handleWorkspaceOpenTool(event) { function handleWorkspaceOpenTool(event) {
requestWorkspaceTool(event?.detail?.kind); requestWorkspaceTool(event?.detail?.kind, event?.detail?.toolRequest || null);
} }
async function loadTools() { async function loadTools() {
@ -169,7 +185,7 @@
<PluginBundleHost <PluginBundleHost
pluginId={activeTool.pluginId} pluginId={activeTool.pluginId}
componentId={activeTool.component} componentId={activeTool.component}
componentProps={{ workspaceName: selectedWorkspaceName, workspaceNodeId: selectedWorkspaceName, workspaceNode: selectedWorkspace, workspaceRootPath }} componentProps={{ workspaceName: selectedWorkspaceName, workspaceNodeId: selectedWorkspaceName, workspaceNode: selectedWorkspace, workspaceRootPath, toolRequest: activeToolRequest }}
/> />
{/if} {/if}
{/if} {/if}

View File

@ -214,11 +214,10 @@
name: 'Journal', name: 'Journal',
version: '0.1.0', version: '0.1.0',
apiVersion: '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', source: 'official',
icon: 'book-open', icon: 'book-open',
provides: ['worklog', 'journal', 'report.worklog'], provides: ['worklog', 'journal', 'report.worklog'],
optionalRequires: ['activity.reconstruction'],
permissions: ['storage.namespace', 'ui.register'], permissions: ['storage.namespace', 'ui.register'],
frontend: { entry: 'frontend/dist/index.js' }, frontend: { entry: 'frontend/dist/index.js' },
contributes: { 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-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-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-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}', '.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(''); ].join('');
document.head.appendChild(style); document.head.appendChild(style);
} }
@ -1495,22 +1494,11 @@
return Array.isArray(value) ? value.filter(function (item) { return item && typeof item === 'object'; }) : []; 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) { function eventTime(value) {
var date = new Date(value || 0); var date = new Date(value || 0);
return isNaN(date.getTime()) ? 0 : date.getTime(); 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) { function formatDate(value) {
var date = new Date(value || 0); var date = new Date(value || 0);
if (isNaN(date.getTime())) return '-'; if (isNaN(date.getTime())) return '-';
@ -1521,29 +1509,60 @@
return activity.title || activity.summary || activity.type || activity.activityId || 'Activity event'; return activity.title || activity.summary || activity.type || activity.activityId || 'Activity event';
} }
function summarize(group) { function workSessionCandidates(events, root) {
return group.map(title).filter(Boolean).slice(0, 3).join('; ') || group.length + ' activity events'; 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);
});
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 || ''
};
})
});
} }
function suggestions(events, root) { ordered.forEach(function (activity) {
var groups = {}; var workspace = activity.workspaceRootPath || root || '';
events.forEach(function (activity) { var time = eventTime(activity.occurredAt || activity.receivedAt);
var workspace = activity.workspaceRootPath || root || 'Global'; if (!workspace) return;
var day = eventDay(activity); if (!current) {
var key = workspace + '|' + day; current = { workspaceRootPath: workspace, events: [activity] };
groups[key] = groups[key] || { workspaceRootPath: workspace, date: day, events: [] }; return;
groups[key].events.push(activity); }
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);
}); });
return Object.keys(groups).map(function (key) { addCurrent();
var group = groups[key]; return candidates.sort(function (a, b) { return b.endedAt.localeCompare(a.endedAt); });
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); });
} }
function renderActivity(containerEl, props, api) { function renderActivity(containerEl, props, api) {
@ -1551,6 +1570,7 @@
var rootPath = workspaceRoot(props || {}); var rootPath = workspaceRoot(props || {});
var key = workspaceKey(rootPath); var key = workspaceKey(rootPath);
var events = []; var events = [];
var dismissed = {};
var statusText = 'Listening for workspace activity'; var statusText = 'Listening for workspace activity';
containerEl.innerHTML = ''; containerEl.innerHTML = '';
@ -1568,7 +1588,7 @@
persist().then(render); 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' }); var listEl = el('div', { className: 'activity-list' });
toolbar.appendChild(titleEl); toolbar.appendChild(titleEl);
@ -1577,7 +1597,7 @@
toolbar.appendChild(statusEl); toolbar.appendChild(statusEl);
toolbar.appendChild(clearBtn); toolbar.appendChild(clearBtn);
root.appendChild(toolbar); root.appendChild(toolbar);
root.appendChild(suggestionsEl); root.appendChild(candidatesEl);
root.appendChild(listEl); root.appendChild(listEl);
containerEl.appendChild(root); containerEl.appendChild(root);
@ -1593,18 +1613,31 @@
}); });
} }
function renderSuggestions() { function renderCandidates() {
suggestionsEl.innerHTML = ''; candidatesEl.innerHTML = '';
var items = suggestions(events, rootPath); var items = workSessionCandidates(events, rootPath).filter(function (item) { return !dismissed[item.candidateId]; });
if (!items.length) return; 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) { 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', {}, [
el('div', { className: 'activity-suggestion-title', textContent: item.title }), el('div', { className: 'activity-candidate-title', textContent: 'Possible journal entry' }),
el('div', { className: 'activity-suggestion-summary', textContent: item.summary }) 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'); countEl.textContent = events.length + ' event' + (events.length === 1 ? '' : 's');
clearBtn.disabled = events.length === 0; clearBtn.disabled = events.length === 0;
statusEl.textContent = statusText; statusEl.textContent = statusText;
renderSuggestions(); renderCandidates();
renderList(); renderList();
} }
readSettings().then(function (settings) { readSettings().then(function (settings) {
events = meaningfulRows(settings[key]); events = rows(settings[key]);
render(); render();
}); });
render(); render();
@ -1656,6 +1689,214 @@
}.toString() + ')();'; }.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() { function browserInboxBundle() {
return '(' + function () { return '(' + function () {
var PLUGIN_ID = 'verstak.browser-inbox'; var PLUGIN_ID = 'verstak.browser-inbox';
@ -2208,7 +2449,7 @@
return Promise.resolve(activityBundle()); return Promise.resolve(activityBundle());
} }
if (pluginId === 'verstak.journal' && assetPath === 'frontend/dist/index.js') { 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') { if (pluginId === 'verstak.browser-inbox' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(browserInboxBundle()); return Promise.resolve(browserInboxBundle());
@ -2771,11 +3012,10 @@
name: 'Journal', name: 'Journal',
version: '0.1.0', version: '0.1.0',
apiVersion: '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', source: 'official',
icon: 'book-open', icon: 'book-open',
provides: ['worklog', 'journal', 'report.worklog'], provides: ['worklog', 'journal', 'report.worklog'],
optionalRequires: ['activity.reconstruction'],
permissions: ['storage.namespace', 'ui.register'], permissions: ['storage.namespace', 'ui.register'],
frontend: { entry: 'frontend/dist/index.js' }, frontend: { entry: 'frontend/dist/index.js' },
contributes: { contributes: {