feat: convert completed todos into journal entries
This commit is contained in:
parent
31c93bdf5b
commit
16e1407440
|
|
@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }, [
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue