From 16e1407440dc5367eba58ec580cb4f35ac3793b2 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Fri, 10 Jul 2026 20:49:15 +0800 Subject: [PATCH] feat: convert completed todos into journal entries --- plugins/journal/frontend/src/index.js | 54 +++++++++++++++++++++++---- plugins/todo/frontend/src/index.js | 23 ++++++++++++ scripts/smoke-journal-plugin.js | 52 ++++++++++++++++++++++++++ scripts/smoke-todo-plugin.js | 37 +++++++++++++++--- 4 files changed, 153 insertions(+), 13 deletions(-) diff --git a/plugins/journal/frontend/src/index.js b/plugins/journal/frontend/src/index.js index 811a58c..5802556 100644 --- a/plugins/journal/frontend/src/index.js +++ b/plugins/journal/frontend/src/index.js @@ -131,6 +131,7 @@ minutes: Math.max(0, Number(value.minutes || 0)), billable: value.billable === true, sourceCandidateId: text(value.sourceCandidateId || value.sourceSuggestionId), + sourceTodoId: text(value.sourceTodoId), activityIds: Array.isArray(value.activityIds) ? value.activityIds.map(text) : (Array.isArray(value.eventIds) ? value.eventIds.map(text) : []) @@ -162,6 +163,7 @@ minutes: entry.minutes, billable: entry.billable, sourceCandidateId: entry.sourceCandidateId, + sourceTodoId: entry.sourceTodoId, activityIds: entry.activityIds || [] }; }); @@ -170,7 +172,9 @@ function sortEntries(entryList) { var seen = {}; return entryList.filter(function (entry) { - var key = entry.sourceCandidateId || entry.entryId; + var key = entry.sourceCandidateId + ? 'candidate:' + entry.sourceCandidateId + : (entry.sourceTodoId ? 'todo:' + entry.sourceTodoId : 'entry:' + entry.entryId); if (!key || seen[key]) return false; seen[key] = true; return true; @@ -233,6 +237,22 @@ }; } + function completedTodoFromRequest(request, workspaceRoot) { + var value = request && request.type === 'completed-todo' ? request.todo : null; + if (!value || typeof value !== 'object') return null; + var workspace = cleanWorkspace(value.workspaceRootPath); + var todoId = text(value.id).trim(); + var title = text(value.title).trim(); + if (!todoId || !title || !workspace || workspace !== cleanWorkspace(workspaceRoot)) return null; + return { + id: todoId, + title: title, + description: text(value.description || value.body), + workspaceRootPath: workspace, + completedAt: text(value.completedAt) + }; + } + var ICONS = { add: 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z', edit: 'M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z', @@ -318,15 +338,16 @@ modalHost.setAttribute('hidden', 'hidden'); } - function showEntryModal(existingEntry, candidate) { + function showEntryModal(existingEntry, candidate, completedTodo) { if (scope.mode !== 'workspace') return; var editing = !!existingEntry; var reviewingCandidate = !editing && !!candidate; - var dateInput = el('input', { className: 'journal-input', type: 'date', value: editing ? existingEntry.date : (reviewingCandidate ? candidateDate(candidate.startedAt) : today()), 'data-journal-input': 'date' }); - var titleInput = el('input', { className: 'journal-input', type: 'text', placeholder: 'Work item', value: editing ? existingEntry.title : '', 'data-journal-input': 'title' }); + var reviewingTodo = !editing && !!completedTodo; + var dateInput = el('input', { className: 'journal-input', type: 'date', value: editing ? existingEntry.date : (reviewingCandidate ? candidateDate(candidate.startedAt) : (reviewingTodo ? candidateDate(completedTodo.completedAt) : today())), 'data-journal-input': 'date' }); + var titleInput = el('input', { className: 'journal-input', type: 'text', placeholder: 'Work item', value: editing ? existingEntry.title : (reviewingTodo ? completedTodo.title : ''), 'data-journal-input': 'title' }); var summaryInput = el('textarea', { className: 'journal-input textarea', placeholder: 'Body', 'data-journal-input': 'summary' }); - summaryInput.value = editing ? existingEntry.summary : ''; - var minutesInput = el('input', { className: 'journal-input', type: 'number', min: '0', step: '1', value: editing ? existingEntry.minutes : (reviewingCandidate ? candidate.estimatedMinutes : '30'), 'data-journal-input': 'minutes' }); + summaryInput.value = editing ? existingEntry.summary : (reviewingTodo ? completedTodo.description : ''); + var minutesInput = el('input', { className: 'journal-input', type: 'number', min: '0', step: '1', value: editing ? existingEntry.minutes : (reviewingCandidate ? candidate.estimatedMinutes : (reviewingTodo ? '0' : '30')), 'data-journal-input': 'minutes' }); var billableInput = el('input', { type: 'checkbox', 'data-journal-input': 'billable' }); billableInput.checked = editing ? existingEntry.billable === true : false; var activityInputs = reviewingCandidate ? candidate.activities.map(function (activity) { @@ -343,6 +364,7 @@ minutes: minutesInput.value, billable: billableInput.checked === true, sourceCandidateId: reviewingCandidate ? candidate.candidateId : (existingEntry ? existingEntry.sourceCandidateId : ''), + sourceTodoId: reviewingTodo ? completedTodo.id : (existingEntry ? existingEntry.sourceTodoId : ''), activityIds: reviewingCandidate ? activityInputs.filter(function (item) { return item.input.checked === true; }).map(function (item) { return item.activity.activityId; }) : (existingEntry ? existingEntry.activityIds : []) @@ -362,6 +384,11 @@ var detail = (item.activity.occurredAt ? candidateTime(item.activity.occurredAt) + ' · ' : '') + item.activity.type + ' · ' + item.activity.activityId; return el('label', { className: 'journal-candidate-activity' }, [item.input, detail]); }))) : null; + var todoContext = reviewingTodo ? el('div', { className: 'journal-candidate-context', 'data-journal-todo': completedTodo.id }, [ + el('strong', { textContent: 'Completed todo' }), + el('div', { textContent: 'Workspace: ' + completedTodo.workspaceRootPath }), + completedTodo.completedAt ? el('div', { textContent: 'Completed: ' + candidateTime(completedTodo.completedAt) }) : null + ]) : null; modalHost.innerHTML = ''; if (typeof modalHost.removeAttribute === 'function') modalHost.removeAttribute('hidden'); @@ -370,8 +397,9 @@ if (event.target === event.currentTarget) closeEntryModal(); } }, [ el('div', { className: 'journal-modal' }, [ - el('div', { className: 'journal-modal-title', textContent: editing ? 'Edit journal entry' : (reviewingCandidate ? 'Review possible journal entry' : 'Add journal entry') }), + el('div', { className: 'journal-modal-title', textContent: editing ? 'Edit journal entry' : (reviewingCandidate ? 'Review possible journal entry' : (reviewingTodo ? 'Create journal entry from completed todo' : 'Add journal entry')) }), candidateContext, + todoContext, el('div', { className: 'journal-modal-grid' }, [ el('label', { className: 'journal-field' }, ['Date', dateInput]), el('label', { className: 'journal-field' }, ['Minutes', minutesInput]), @@ -399,12 +427,19 @@ return; } var sourceCandidateId = text(formValue && formValue.sourceCandidateId || (existingEntry && existingEntry.sourceCandidateId)).trim(); + var sourceTodoId = text(formValue && formValue.sourceTodoId || (existingEntry && existingEntry.sourceTodoId)).trim(); if (!existingEntry && sourceCandidateId && entries.some(function (entry) { return entry.sourceCandidateId === sourceCandidateId; })) { statusText = 'A journal entry already references this candidate'; statusClass = 'error'; render(); return; } + if (!existingEntry && sourceTodoId && entries.some(function (entry) { return entry.sourceTodoId === sourceTodoId; })) { + statusText = 'A journal entry already references this todo'; + statusClass = 'error'; + render(); + return; + } var entry = normalizeEntry({ entryId: existingEntry ? existingEntry.entryId : entryId(scope.workspaceRoot, formValue.date || today(), title), workspaceRootPath: scope.workspaceRoot, @@ -414,6 +449,7 @@ minutes: Number(formValue.minutes || 0), billable: formValue.billable === true, sourceCandidateId: sourceCandidateId, + sourceTodoId: sourceTodoId, activityIds: Array.isArray(formValue.activityIds) ? formValue.activityIds : (existingEntry ? existingEntry.activityIds : []) }, scope.key); if (existingEntry) { @@ -453,7 +489,7 @@ el('div', { className: 'journal-main' }, [ 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 + (entry.billable ? ' · billable' : ' · non-billable') + (entry.activityIds.length ? ' · ' + entry.activityIds.length + ' linked activities' : '') }) + el('div', { className: 'journal-meta', textContent: entry.workspaceRootPath + (entry.billable ? ' · billable' : ' · non-billable') + (entry.activityIds.length ? ' · ' + entry.activityIds.length + ' linked activities' : '') + (entry.sourceTodoId ? ' · linked todo' : '') }) ]), el('div', { className: 'journal-minutes', textContent: entry.minutes + ' min' }), scope.mode === 'workspace' ? el('div', { className: 'journal-row-actions' }, [ @@ -476,7 +512,9 @@ loadStored().then(function () { render(); var candidate = candidateFromRequest(props && props.toolRequest, scope.workspaceRoot); + var completedTodo = completedTodoFromRequest(props && props.toolRequest, scope.workspaceRoot); if (candidate) showEntryModal(null, candidate); + else if (completedTodo) showEntryModal(null, null, completedTodo); }); }; diff --git a/plugins/todo/frontend/src/index.js b/plugins/todo/frontend/src/index.js index cd12c76..83eced3 100644 --- a/plugins/todo/frontend/src/index.js +++ b/plugins/todo/frontend/src/index.js @@ -488,6 +488,26 @@ window.dispatchEvent(new CustomEvent('verstak:workspace-open-tool', { detail: { kind: 'todo' } })); } + function createJournalEntry(todo) { + if (!todo || todo.status !== 'done' || scope.mode !== 'workspace') return; + if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function' || typeof CustomEvent === 'undefined') return; + window.dispatchEvent(new CustomEvent('verstak:workspace-open-tool', { + detail: { + kind: 'journal', + toolRequest: { + type: 'completed-todo', + todo: { + id: todo.id, + title: todo.title, + description: todo.description, + workspaceRootPath: scope.workspaceRoot, + completedAt: todo.completedAt + } + } + } + })); + } + function renderTodoMeta(todo) { var meta = []; var workspace = cleanWorkspace(todo.workspaceRootPath); @@ -522,6 +542,9 @@ } else { actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'reopen', textContent: 'Reopen', onClick: function () { setTodoStatus(todo, 'open'); } })); } + if (scope.mode === 'workspace' && todo.status === 'done') { + actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'create-journal-entry', textContent: 'Create Journal Entry', onClick: function () { createJournalEntry(todo); } })); + } actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'edit', textContent: 'Edit', onClick: function () { showTodoModal(todo); } })); actionButtons.push(el('button', { className: 'todo-btn danger', type: 'button', 'data-todo-action': 'delete', textContent: 'Delete', onClick: function () { deleteTodo(todo); } })); listEl.appendChild(el('div', { className: 'todo-row' + (todo.status === 'done' ? ' done' : ''), 'data-todo-id': todo.id }, [ diff --git a/scripts/smoke-journal-plugin.js b/scripts/smoke-journal-plugin.js index 5fbc845..178f3ee 100644 --- a/scripts/smoke-journal-plugin.js +++ b/scripts/smoke-journal-plugin.js @@ -257,6 +257,56 @@ function byData(container, attr, value) { await flush(); if (api.storedEntries(projectKey).length !== 1) throw new Error('journal entry was not deleted'); + const completedTodo = { + id: 'todo:Project:project-review', + title: 'Prepare project review', + description: 'Collect factual review notes.', + workspaceRootPath: 'Project', + completedAt: '2026-06-27T11:15:00.000Z', + }; + const todoView = await mountWithApi(api, { + workspaceNode: { name: 'Project' }, + workspaceRootPath: 'Project', + toolRequest: { type: 'completed-todo', todo: completedTodo }, + }); + if (!todoView.container.textContent.includes('Create journal entry from completed todo')) { + throw new Error('completed Todo did not open the Journal conversion form'); + } + if (!byData(todoView.container, 'data-journal-todo', completedTodo.id)) { + throw new Error('completed Todo context was not shown in the Journal form'); + } + if (byData(todoView.container, 'data-journal-input', 'title').value !== completedTodo.title) { + throw new Error('completed Todo Journal form did not prefill the exact title'); + } + if (byData(todoView.container, 'data-journal-input', 'summary').value !== completedTodo.description) { + throw new Error('completed Todo Journal form did not prefill the exact description'); + } + if (byData(todoView.container, 'data-journal-input', 'minutes').value !== '0') { + throw new Error('completed Todo Journal form must not invent a duration'); + } + byData(todoView.container, 'data-journal-input', 'title').value = 'Prepare project review for handoff'; + byData(todoView.container, 'data-journal-input', 'summary').value = 'Reviewed factual project notes before handoff.'; + byData(todoView.container, 'data-journal-action', 'save-entry').click(); + await flush(); + + if (api.storedEntries(projectKey).length !== 2) throw new Error('completed Todo Journal entry was not saved'); + const todoEntry = api.storedEntries(projectKey).find((entry) => entry.sourceTodoId === completedTodo.id); + if (!todoEntry) throw new Error('completed Todo reference was not stored on the Journal entry'); + if (todoEntry.title !== 'Prepare project review for handoff' || todoEntry.summary !== 'Reviewed factual project notes before handoff.') { + throw new Error('completed Todo Journal form did not preserve the user-edited fields'); + } + + const duplicateTodoView = await mountWithApi(api, { + workspaceNode: { name: 'Project' }, + workspaceRootPath: 'Project', + toolRequest: { type: 'completed-todo', todo: completedTodo }, + }); + byData(duplicateTodoView.container, 'data-journal-action', 'save-entry').click(); + await flush(); + if (api.storedEntries(projectKey).filter((entry) => entry.sourceTodoId === completedTodo.id).length !== 1) { + throw new Error('completed Todo Journal conversion created a duplicate entry'); + } + const globalView = await mountWithApi(api, {}); if (!globalView.container.textContent.includes('Review research capture') && !globalView.container.textContent.includes('Draft brief updated')) { throw new Error('global journal did not aggregate remaining entries'); @@ -264,6 +314,8 @@ function byData(container, attr, value) { component.unmount && component.unmount(container); component.unmount && component.unmount(candidateView.container); + component.unmount && component.unmount(todoView.container); + component.unmount && component.unmount(duplicateTodoView.container); component.unmount && component.unmount(globalView.container); console.log('journal plugin smoke passed'); diff --git a/scripts/smoke-todo-plugin.js b/scripts/smoke-todo-plugin.js index aae0f85..37d5ad7 100755 --- a/scripts/smoke-todo-plugin.js +++ b/scripts/smoke-todo-plugin.js @@ -116,16 +116,25 @@ function makeDocument() { }; } -function loadComponent(document) { +function FakeCustomEvent(type, options = {}) { + this.type = type; + this.detail = options.detail; +} + +function loadComponent(document, emittedEvents) { const registry = {}; const window = { VerstakPluginRegister(pluginId, bundle) { registry[pluginId] = bundle.components || {}; }, + dispatchEvent(event) { + emittedEvents.push(event); + }, }; window.window = window; window.document = document; - vm.runInNewContext(source, { console, Date, Math, document, window }, { filename: sourcePath }); + window.CustomEvent = FakeCustomEvent; + vm.runInNewContext(source, { console, Date, Math, CustomEvent: FakeCustomEvent, document, window }, { filename: sourcePath }); const component = registry['verstak.todo'] && registry['verstak.todo'].TodoView; if (!component) throw new Error('TodoView was not registered'); return component; @@ -158,12 +167,12 @@ async function flush() { for (let index = 0; index < 10; index += 1) await Promise.resolve(); } -async function mountWithApi(apiState, props, document = makeDocument()) { - const component = loadComponent(document); +async function mountWithApi(apiState, props, emittedEvents = [], document = makeDocument()) { + const component = loadComponent(document, emittedEvents); const container = new FakeNode('div'); component.mount(container, props, apiState.api); await flush(); - return { component, container, document }; + return { component, container, document, emittedEvents }; } (async () => { @@ -208,6 +217,24 @@ async function mountWithApi(apiState, props, document = makeDocument()) { throw new Error('mark done did not persist completed state'); } if (!byData(container, 'data-todo-action', 'reopen')) throw new Error('done Todo did not expose reopen action'); + if (!byData(container, 'data-todo-action', 'create-journal-entry')) throw new Error('done workspace Todo did not expose Journal conversion'); + + byData(container, 'data-todo-action', 'create-journal-entry').click(); + const journalEvent = workspaceView.emittedEvents.find((event) => event.type === 'verstak:workspace-open-tool'); + if (!journalEvent || !journalEvent.detail || journalEvent.detail.kind !== 'journal') { + throw new Error('Todo Journal conversion did not request the Journal workspace tool'); + } + const journalRequest = journalEvent.detail.toolRequest; + if (!journalRequest || journalRequest.type !== 'completed-todo' || !journalRequest.todo) { + throw new Error('Todo Journal conversion did not send a completed-todo request'); + } + if (journalRequest.todo.id !== createdTodo.id + || journalRequest.todo.title !== 'Prepare project review updated' + || journalRequest.todo.description !== 'Collect factual review notes.' + || journalRequest.todo.workspaceRootPath !== 'Project' + || !journalRequest.todo.completedAt) { + throw new Error('Todo Journal conversion did not preserve factual completed Todo fields'); + } apiState.settings['todos:global'].push({ id: 'todo-client',