feat: convert completed todos into journal entries

This commit is contained in:
mirivlad 2026-07-10 20:49:15 +08:00
parent 31c93bdf5b
commit 16e1407440
4 changed files with 153 additions and 13 deletions

View File

@ -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);
});
};

View File

@ -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 }, [

View File

@ -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');

View File

@ -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',