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: {