feat: convert completed todos into journal entries

This commit is contained in:
2026-07-10 20:49:15 +08:00
parent 31c93bdf5b
commit 16e1407440
4 changed files with 153 additions and 13 deletions
+46 -8
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);
});
};
+23
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 }, [