feat: route work session candidates to journal
This commit is contained in:
parent
b056016929
commit
e5f5d4d33f
|
|
@ -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'],
|
||||||
|
})]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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]');
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
function suggestions(events, root) {
|
return eventTime(a.occurredAt || a.receivedAt) - eventTime(b.occurredAt || b.receivedAt);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
return Object.keys(groups).map(function (key) {
|
var candidates = [];
|
||||||
var group = groups[key];
|
var current = null;
|
||||||
var ordered = group.events.slice().sort(function (a, b) { return eventTime(a.occurredAt || a.receivedAt) - eventTime(b.occurredAt || b.receivedAt); });
|
|
||||||
return {
|
function addCurrent() {
|
||||||
suggestionId: 'worklog:' + group.workspaceRootPath + ':' + group.date,
|
if (!current || current.events.length < 2) return;
|
||||||
title: group.workspaceRootPath + ' work on ' + group.date,
|
var first = current.events[0];
|
||||||
summary: summarize(ordered),
|
var last = current.events[current.events.length - 1];
|
||||||
minutes: Math.max(25, ordered.length * 25)
|
var duration = Math.round((eventTime(last.occurredAt || last.receivedAt) - eventTime(first.occurredAt || first.receivedAt)) / 60000);
|
||||||
};
|
if (duration < 10) return;
|
||||||
}).sort(function (a, b) { return b.suggestionId.localeCompare(a.suggestionId); });
|
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) {
|
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: {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue