feat: add deterministic work session candidates

This commit is contained in:
2026-07-10 08:03:46 +08:00
parent 396b77cf26
commit a76500418e
6 changed files with 474 additions and 253 deletions
+104 -81
View File
@@ -8,8 +8,6 @@
var PLUGIN_ID = 'verstak.journal';
var WORKLOG_PREFIX = 'worklog:workspace:';
var ACTIVITY_PLUGIN_ID = 'verstak.activity';
var ACTIVITY_WORKLOG_COMMAND = 'verstak.activity.suggestWorklog';
function injectStyles() {
if (document.getElementById('journal-style-injected')) return;
@@ -57,6 +55,11 @@
'.journal-modal-grid{display:grid;grid-template-columns:1fr 8rem;gap:.6rem}',
'.journal-field{display:grid;gap:.3rem;font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}',
'.journal-field.wide{grid-column:1/-1}',
'.journal-candidate-context{display:grid;gap:.22rem;padding:.65rem .7rem;border:1px solid rgba(78,204,163,.34);border-radius:var(--vt-radius-md,6px);background:var(--vt-color-surface-muted,#111629);font-size:.76rem;color:var(--vt-color-text-secondary,#b7c0d4)}',
'.journal-candidate-context strong{color:var(--vt-color-text-primary,#f4f7fb)}',
'.journal-candidate-activities{display:grid;gap:.35rem;margin:0;padding:.65rem .7rem;border:1px solid var(--vt-color-border,#202b46);border-radius:var(--vt-radius-md,6px);font-size:.74rem;color:var(--vt-color-text-secondary,#b7c0d4)}',
'.journal-candidate-activities legend{padding:0 .2rem;color:var(--vt-color-text-muted,#7f8aa3)}',
'.journal-candidate-activity{display:flex;align-items:flex-start;gap:.45rem;line-height:1.35;overflow-wrap:anywhere}',
'.journal-modal-actions{display:flex;justify-content:flex-end;gap:.5rem}',
'.journal-btn.primary{background:var(--vt-color-accent,#4ecca3);border-color:var(--vt-color-accent,#4ecca3);color:#101827}',
'.journal-btn.ghost{background:transparent}',
@@ -127,8 +130,10 @@
summary: text(value.summary),
minutes: Math.max(0, Number(value.minutes || 0)),
billable: value.billable === true,
sourceSuggestionId: text(value.sourceSuggestionId),
eventIds: Array.isArray(value.eventIds) ? value.eventIds.map(text) : []
sourceCandidateId: text(value.sourceCandidateId || value.sourceSuggestionId),
activityIds: Array.isArray(value.activityIds)
? value.activityIds.map(text)
: (Array.isArray(value.eventIds) ? value.eventIds.map(text) : [])
};
}
@@ -156,8 +161,8 @@
summary: entry.summary,
minutes: entry.minutes,
billable: entry.billable,
sourceSuggestionId: entry.sourceSuggestionId,
eventIds: entry.eventIds || []
sourceCandidateId: entry.sourceCandidateId,
activityIds: entry.activityIds || []
};
});
}
@@ -165,7 +170,7 @@
function sortEntries(entryList) {
var seen = {};
return entryList.filter(function (entry) {
var key = entry.sourceSuggestionId || entry.entryId;
var key = entry.sourceCandidateId || entry.entryId;
if (!key || seen[key]) return false;
seen[key] = true;
return true;
@@ -184,6 +189,50 @@
return 'journal:' + cleanWorkspace(workspaceRoot || 'Global') + ':' + text(date) + ':' + encodeKey(title).slice(0, 48) + ':' + Date.now();
}
function candidateDate(value) {
var date = new Date(value || '');
return isNaN(date.getTime()) ? today() : date.toISOString().slice(0, 10);
}
function candidateTime(value) {
var date = new Date(value || '');
if (isNaN(date.getTime())) return text(value);
return date.toLocaleString(undefined, { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
function candidateFromRequest(request, workspaceRoot) {
var value = request && request.type === 'work-session-candidate' ? request.candidate : null;
if (!value || typeof value !== 'object') return null;
var workspace = cleanWorkspace(value.workspaceRootPath);
if (!workspace || workspace !== cleanWorkspace(workspaceRoot) || !text(value.candidateId).trim()) return null;
var activities = Array.isArray(value.activities) ? value.activities.filter(function (activity) {
return activity && text(activity.activityId).trim();
}).map(function (activity) {
return {
activityId: text(activity.activityId),
type: text(activity.type || 'activity.event'),
occurredAt: text(activity.occurredAt),
sourcePluginId: text(activity.sourcePluginId)
};
}) : [];
var activityIds = Array.isArray(value.activityIds) ? value.activityIds.map(text).filter(Boolean) : activities.map(function (activity) { return activity.activityId; });
if (!activities.length) {
activities = activityIds.map(function (activityId) {
return { activityId: activityId, type: 'activity.event', occurredAt: '', sourcePluginId: '' };
});
}
return {
candidateId: text(value.candidateId),
workspaceRootPath: workspace,
startedAt: text(value.startedAt),
endedAt: text(value.endedAt),
estimatedMinutes: Math.max(0, Number(value.estimatedMinutes || 0)),
activityCount: Math.max(0, Number(value.activityCount || activities.length)),
activityIds: activityIds,
activities: activities
};
}
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',
@@ -212,12 +261,6 @@
var titleEl = el('span', { className: 'journal-title', textContent: scope.mode === 'global' ? 'Journal' : 'Journal · ' + scope.label });
var countEl = el('span', { className: 'journal-count' });
var statusEl = el('span', { className: 'journal-status' });
var importBtn = el('button', {
className: 'journal-btn',
'data-journal-action': 'import-activity',
textContent: 'Import Activity',
onClick: importActivity
});
var addBtn = el('button', {
className: 'journal-btn primary',
'data-journal-action': 'add',
@@ -229,7 +272,6 @@
toolbar.appendChild(el('span', { className: 'journal-spacer' }));
toolbar.appendChild(statusEl);
toolbar.appendChild(addBtn);
toolbar.appendChild(importBtn);
var listEl = el('div', { className: 'journal-list' });
containerEl.appendChild(toolbar);
@@ -276,16 +318,22 @@
modalHost.setAttribute('hidden', 'hidden');
}
function showEntryModal(existingEntry) {
function showEntryModal(existingEntry, candidate) {
if (scope.mode !== 'workspace') return;
var editing = !!existingEntry;
var dateInput = el('input', { className: 'journal-input', type: 'date', value: editing ? existingEntry.date : today(), 'data-journal-input': 'date' });
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 summaryInput = el('textarea', { className: 'journal-input textarea', placeholder: 'Summary', 'data-journal-input': 'summary' });
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: '15', value: editing ? existingEntry.minutes : '30', 'data-journal-input': 'minutes' });
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' });
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) {
var input = el('input', { type: 'checkbox', value: activity.activityId, checked: 'checked', 'data-journal-candidate-activity': activity.activityId });
input.checked = true;
return { input: input, activity: activity };
}) : [];
function saveEntry() {
addOrUpdateEntry(existingEntry, {
@@ -293,10 +341,28 @@
title: titleInput.value,
summary: summaryInput.value,
minutes: minutesInput.value,
billable: billableInput.checked === true
billable: billableInput.checked === true,
sourceCandidateId: reviewingCandidate ? candidate.candidateId : (existingEntry ? existingEntry.sourceCandidateId : ''),
activityIds: reviewingCandidate
? activityInputs.filter(function (item) { return item.input.checked === true; }).map(function (item) { return item.activity.activityId; })
: (existingEntry ? existingEntry.activityIds : [])
});
}
var candidateContext = reviewingCandidate ? 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 candidateActivities = reviewingCandidate ? el('fieldset', { className: 'journal-candidate-activities' }, [
el('legend', { textContent: 'Linked activities' })
].concat(activityInputs.map(function (item) {
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;
modalHost.innerHTML = '';
if (typeof modalHost.removeAttribute === 'function') modalHost.removeAttribute('hidden');
else delete modalHost.attributes.hidden;
@@ -304,14 +370,16 @@
if (event.target === event.currentTarget) closeEntryModal();
} }, [
el('div', { className: 'journal-modal' }, [
el('div', { className: 'journal-modal-title', textContent: editing ? 'Edit journal entry' : 'Add journal entry' }),
el('div', { className: 'journal-modal-title', textContent: editing ? 'Edit journal entry' : (reviewingCandidate ? 'Review possible journal entry' : 'Add journal entry') }),
candidateContext,
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' }, ['Summary', summaryInput]),
el('label', { className: 'journal-field wide' }, ['Body', summaryInput]),
el('label', { className: 'journal-billable' }, [billableInput, 'Billable'])
]),
candidateActivities,
el('div', { className: 'journal-modal-actions' }, [
el('button', { className: 'journal-btn ghost', type: 'button', textContent: 'Cancel', onClick: closeEntryModal }),
el('button', { className: 'journal-btn primary', type: 'button', 'data-journal-action': 'save-entry', textContent: editing ? 'Save changes' : 'Add entry', onClick: saveEntry })
@@ -330,6 +398,13 @@
render();
return;
}
var sourceCandidateId = text(formValue && formValue.sourceCandidateId || (existingEntry && existingEntry.sourceCandidateId)).trim();
if (!existingEntry && sourceCandidateId && entries.some(function (entry) { return entry.sourceCandidateId === sourceCandidateId; })) {
statusText = 'A journal entry already references this candidate';
statusClass = 'error';
render();
return;
}
var entry = normalizeEntry({
entryId: existingEntry ? existingEntry.entryId : entryId(scope.workspaceRoot, formValue.date || today(), title),
workspaceRootPath: scope.workspaceRoot,
@@ -338,8 +413,8 @@
summary: formValue.summary,
minutes: Number(formValue.minutes || 0),
billable: formValue.billable === true,
sourceSuggestionId: existingEntry ? existingEntry.sourceSuggestionId : '',
eventIds: existingEntry ? existingEntry.eventIds : []
sourceCandidateId: sourceCandidateId,
activityIds: Array.isArray(formValue.activityIds) ? formValue.activityIds : (existingEntry ? existingEntry.activityIds : [])
}, scope.key);
if (existingEntry) {
entries = entries.map(function (item) {
@@ -363,61 +438,6 @@
persist().then(render);
}
function suggestionToEntry(suggestion) {
return normalizeEntry({
entryId: 'journal:' + suggestion.suggestionId,
workspaceRootPath: suggestion.workspaceRootPath || scope.workspaceRoot,
date: suggestion.date || today(),
title: suggestion.title || 'Activity work',
summary: suggestion.summary || '',
minutes: Number(suggestion.minutes || 0),
billable: false,
sourceSuggestionId: suggestion.suggestionId,
eventIds: suggestion.eventIds || []
}, scope.key);
}
function importActivity() {
if (scope.mode !== 'workspace') return;
if (!api || !api.commands || typeof api.commands.executeFor !== 'function') {
statusText = 'Activity suggestions unavailable';
statusClass = 'error';
render();
return;
}
importBtn.disabled = true;
statusText = 'Importing activity...';
statusClass = '';
render();
api.commands.executeFor(ACTIVITY_PLUGIN_ID, ACTIVITY_WORKLOG_COMMAND, {
workspaceRootPath: scope.workspaceRoot
}).then(function (response) {
var suggestions = response && response.result && Array.isArray(response.result.suggestions)
? response.result.suggestions
: [];
var imported = 0;
suggestions.forEach(function (suggestion) {
if (!suggestion || !suggestion.suggestionId) return;
var exists = entries.some(function (entry) {
return entry.sourceSuggestionId === suggestion.suggestionId;
});
if (exists) return;
entries.push(suggestionToEntry(suggestion));
imported += 1;
});
entries = sortEntries(entries);
statusText = imported ? 'Imported ' + imported + ' activity suggestion' + (imported === 1 ? '' : 's') : 'No new activity suggestions';
statusClass = '';
return persist();
}).catch(function (err) {
statusText = 'Activity suggestions unavailable: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
}).then(function () {
importBtn.disabled = false;
render();
});
}
function renderList() {
listEl.innerHTML = '';
if (!entries.length) {
@@ -433,7 +453,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') })
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-minutes', textContent: entry.minutes + ' min' }),
scope.mode === 'workspace' ? el('div', { className: 'journal-row-actions' }, [
@@ -449,12 +469,15 @@
statusEl.textContent = statusText;
statusEl.className = 'journal-status' + (statusClass ? ' ' + statusClass : '');
addBtn.disabled = scope.mode !== 'workspace';
importBtn.disabled = scope.mode !== 'workspace' || importBtn.disabled;
renderList();
}
render();
loadStored().then(render);
loadStored().then(function () {
render();
var candidate = candidateFromRequest(props && props.toolRequest, scope.workspaceRoot);
if (candidate) showEntryModal(null, candidate);
});
};
JournalView.unmount = function (containerEl) {