feat: add deterministic work session candidates
This commit is contained in:
parent
396b77cf26
commit
a76500418e
|
|
@ -8,11 +8,17 @@
|
|||
|
||||
var PLUGIN_ID = 'verstak.activity';
|
||||
var MAX_EVENTS = 250;
|
||||
var MAX_SUGGESTIONS = 12;
|
||||
var MAX_CANDIDATES = 12;
|
||||
var LEGACY_KEY = 'events';
|
||||
var GLOBAL_KEY = 'events:global';
|
||||
var WORKSPACE_PREFIX = 'events:workspace:';
|
||||
var CANDIDATE_PREFIX = 'work-session-candidates:workspace:';
|
||||
var DISMISSAL_PREFIX = 'work-session-dismissals:workspace:';
|
||||
var WORKLOG_COMMAND_ID = 'verstak.activity.suggestWorklog';
|
||||
var MIN_SESSION_DURATION_MINUTES = 10;
|
||||
var MIN_SESSION_ACTIVITY_COUNT = 2;
|
||||
var MAX_IDLE_GAP_MINUTES = 20;
|
||||
var MAX_SESSION_DURATION_MINUTES = 120;
|
||||
var ACTIVITY_EVENTS = [
|
||||
'file.opened',
|
||||
'file.changed',
|
||||
|
|
@ -68,12 +74,14 @@
|
|||
'.activity-btn.danger{border-color:rgba(233,69,96,.42);color:#ff9a9a}',
|
||||
'.activity-status{font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3);white-space:nowrap}',
|
||||
'.activity-status.error{display:inline-flex;border:1px solid rgba(233,69,96,.45);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-danger-muted,rgba(233,69,96,.14));color:#ffc6ce;padding:.18rem .4rem}',
|
||||
'.activity-suggestions{border-bottom:1px solid rgba(32,43,70,.72);background:var(--vt-color-surface-muted,#111629);padding:.65rem .75rem;display:grid;gap:.5rem}',
|
||||
'.activity-suggestions-title{font-size:.76rem;font-weight:600;color:var(--vt-color-text-muted,#7f8aa3);text-transform:uppercase;letter-spacing:.04em}',
|
||||
'.activity-suggestion{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.65rem;align-items:start;padding:.65rem .75rem;border:1px solid var(--vt-color-border,#202b46);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c)}',
|
||||
'.activity-suggestion-title{font-size:.84rem;color:var(--vt-color-text-primary,#f4f7fb);font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
||||
'.activity-suggestion-summary{margin-top:.22rem;font-size:.76rem;color:var(--vt-color-text-secondary,#b7c0d4);line-height:1.4;white-space:pre-wrap;overflow-wrap:anywhere}',
|
||||
'.activity-suggestion-minutes{font-size:.76rem;color:var(--vt-color-accent,#4ecca3);white-space:nowrap}',
|
||||
'.activity-candidates{border-bottom:1px solid rgba(32,43,70,.72);background:var(--vt-color-surface-muted,#111629);padding:.65rem .75rem;display:grid;gap:.5rem}',
|
||||
'.activity-candidates-title{font-size:.76rem;font-weight:600;color:var(--vt-color-text-muted,#7f8aa3);text-transform:uppercase;letter-spacing:.04em}',
|
||||
'.activity-candidate{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.65rem;align-items:start;padding:.65rem .75rem;border:1px solid rgba(78,204,163,.34);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c)}',
|
||||
'.activity-candidate-title{font-size:.84rem;color:var(--vt-color-text-primary,#f4f7fb);font-weight:600}',
|
||||
'.activity-candidate-facts{margin-top:.28rem;display:grid;gap:.16rem;font-size:.76rem;color:var(--vt-color-text-secondary,#b7c0d4)}',
|
||||
'.activity-candidate-activities{margin-top:.38rem;font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}.activity-candidate-activities summary{cursor:pointer}.activity-candidate-activity{margin-top:.2rem;overflow-wrap:anywhere}',
|
||||
'.activity-candidate-actions{display:flex;gap:.35rem;align-items:center;flex-wrap:wrap;justify-content:flex-end}',
|
||||
'.activity-candidate-duration{font-size:.76rem;color:var(--vt-color-accent,#4ecca3);white-space:nowrap}',
|
||||
'.activity-list{flex:1;min-height:0;overflow:auto;background:var(--vt-color-background,#101020)}',
|
||||
'.activity-empty{height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.35rem;color:var(--vt-color-text-muted,#7f8aa3);font-size:.86rem;padding:2rem;text-align:center}',
|
||||
'.activity-empty-title{color:var(--vt-color-text-secondary,#b7c0d4);font-weight:650}',
|
||||
|
|
@ -87,7 +95,7 @@
|
|||
'.activity-summary{margin-top:.25rem;font-size:.78rem;line-height:1.4;color:var(--vt-color-text-secondary,#b7c0d4);white-space:pre-wrap;overflow-wrap:anywhere}',
|
||||
'.activity-source{margin-top:.25rem;font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}',
|
||||
'.activity-details{margin-top:.25rem;font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}.activity-details summary{cursor:pointer}',
|
||||
'@media(max-width:760px){.activity-row,.activity-suggestion{grid-template-columns:1fr;gap:.25rem}.activity-toolbar{align-items:stretch}.activity-status{width:100%}}'
|
||||
'@media(max-width:760px){.activity-row,.activity-candidate{grid-template-columns:1fr;gap:.25rem}.activity-candidate-actions{justify-content:flex-start}.activity-toolbar{align-items:stretch}.activity-status{width:100%}}'
|
||||
].join('\n');
|
||||
|
||||
function el(tag, attrs, children) {
|
||||
|
|
@ -213,32 +221,80 @@
|
|||
return date && !isNaN(date.getTime()) ? date.getTime() : 0;
|
||||
}
|
||||
|
||||
function eventDay(activity) {
|
||||
var value = activity && (activity.occurredAt || activity.receivedAt);
|
||||
var date = value ? new Date(value) : null;
|
||||
if (date && !isNaN(date.getTime())) return date.toISOString().slice(0, 10);
|
||||
return text(value).slice(0, 10) || 'unknown-date';
|
||||
function candidateWorkspace(activity) {
|
||||
return cleanWorkspace(activity && (activity.workspaceRootPath || workspaceFromPayload(activity.payload || {})));
|
||||
}
|
||||
|
||||
function suggestionWorkspace(activity) {
|
||||
return cleanWorkspace(activity && (activity.workspaceRootPath || workspaceFromPayload(activity.payload || {}))) || 'Global';
|
||||
function toISOTime(time) {
|
||||
var date = new Date(time);
|
||||
return isNaN(date.getTime()) ? '' : date.toISOString();
|
||||
}
|
||||
|
||||
function roundUpQuarterHour(minutes) {
|
||||
return Math.ceil(minutes / 15) * 15;
|
||||
function candidateId(workspaceRootPath, firstActivity, lastActivity) {
|
||||
return 'work-session:' + encodeKey(workspaceRootPath) + ':' + encodeKey(firstActivity.activityId) + ':' + encodeKey(lastActivity.activityId);
|
||||
}
|
||||
|
||||
function estimateMinutes(groupEvents) {
|
||||
if (!groupEvents.length) return 0;
|
||||
if (groupEvents.length === 1) return 15;
|
||||
var first = eventTimeMs(groupEvents[0]);
|
||||
var last = eventTimeMs(groupEvents[groupEvents.length - 1]);
|
||||
if (!first || !last || last <= first) return 15;
|
||||
return Math.max(15, Math.min(480, roundUpQuarterHour((last - first) / 60000)));
|
||||
function candidateActivity(activity) {
|
||||
return {
|
||||
activityId: text(activity.activityId),
|
||||
type: text(activity.type),
|
||||
occurredAt: toISOTime(eventTimeMs(activity)),
|
||||
sourcePluginId: text(activity.sourcePluginId),
|
||||
workspaceRootPath: candidateWorkspace(activity)
|
||||
};
|
||||
}
|
||||
|
||||
function eventLabel(activity) {
|
||||
return humanEventTitle(activity);
|
||||
function buildCandidate(session) {
|
||||
var first = session.activities[0];
|
||||
var last = session.activities[session.activities.length - 1];
|
||||
var duration = Math.round((eventTimeMs(last) - eventTimeMs(first)) / 60000);
|
||||
if (session.activities.length < MIN_SESSION_ACTIVITY_COUNT || duration < MIN_SESSION_DURATION_MINUTES) return null;
|
||||
return {
|
||||
candidateId: candidateId(session.workspaceRootPath, first, last),
|
||||
workspaceRootPath: session.workspaceRootPath,
|
||||
startedAt: toISOTime(eventTimeMs(first)),
|
||||
endedAt: toISOTime(eventTimeMs(last)),
|
||||
estimatedMinutes: duration,
|
||||
activityCount: session.activities.length,
|
||||
activityIds: session.activities.map(function (activity) { return activity.activityId; }).filter(Boolean),
|
||||
activities: session.activities.map(candidateActivity)
|
||||
};
|
||||
}
|
||||
|
||||
function buildWorkSessionCandidates(activityList, workspaceFilter) {
|
||||
var filter = cleanWorkspace(workspaceFilter);
|
||||
var ordered = sortEvents(activityList || []).filter(function (activity) {
|
||||
return isMeaningfulActivity(activity) && candidateWorkspace(activity) && eventTimeMs(activity);
|
||||
}).slice().sort(function (a, b) {
|
||||
return eventTimeMs(a) - eventTimeMs(b);
|
||||
});
|
||||
var sessions = [];
|
||||
var current = null;
|
||||
ordered.forEach(function (activity) {
|
||||
var workspace = candidateWorkspace(activity);
|
||||
var time = eventTimeMs(activity);
|
||||
if (!current) {
|
||||
current = { workspaceRootPath: workspace, activities: [activity] };
|
||||
return;
|
||||
}
|
||||
var firstTime = eventTimeMs(current.activities[0]);
|
||||
var lastTime = eventTimeMs(current.activities[current.activities.length - 1]);
|
||||
var switchedWorkspace = current.workspaceRootPath !== workspace;
|
||||
var idleGap = time - lastTime > MAX_IDLE_GAP_MINUTES * 60 * 1000;
|
||||
var exceededMaximum = time - firstTime > MAX_SESSION_DURATION_MINUTES * 60 * 1000;
|
||||
if (switchedWorkspace || idleGap || exceededMaximum) {
|
||||
sessions.push(current);
|
||||
current = { workspaceRootPath: workspace, activities: [activity] };
|
||||
return;
|
||||
}
|
||||
current.activities.push(activity);
|
||||
});
|
||||
if (current) sessions.push(current);
|
||||
return sessions.map(buildCandidate).filter(function (candidate) {
|
||||
return candidate && (!filter || candidate.workspaceRootPath === filter);
|
||||
}).sort(function (a, b) {
|
||||
return b.endedAt.localeCompare(a.endedAt) || a.workspaceRootPath.localeCompare(b.workspaceRootPath);
|
||||
}).slice(0, MAX_CANDIDATES);
|
||||
}
|
||||
|
||||
function humanEventType(type) {
|
||||
|
|
@ -262,49 +318,6 @@
|
|||
return 'Activity';
|
||||
}
|
||||
|
||||
function summarizeEvents(groupEvents) {
|
||||
var labels = [];
|
||||
groupEvents.forEach(function (activity) {
|
||||
var label = eventLabel(activity);
|
||||
if (label && labels.indexOf(label) === -1) labels.push(label);
|
||||
});
|
||||
var visible = labels.slice(0, 3);
|
||||
var suffix = labels.length > 3 ? ' +' + (labels.length - 3) + ' more' : '';
|
||||
return (visible.join('; ') || groupEvents.length + ' activity events') + suffix;
|
||||
}
|
||||
|
||||
function buildWorklogSuggestions(activityList, workspaceFilter) {
|
||||
var filter = cleanWorkspace(workspaceFilter);
|
||||
var groups = {};
|
||||
sortEvents(activityList || []).filter(isMeaningfulActivity).forEach(function (activity) {
|
||||
var workspace = suggestionWorkspace(activity);
|
||||
if (filter && workspace !== filter) return;
|
||||
var day = eventDay(activity);
|
||||
var key = workspace + '|' + day;
|
||||
groups[key] = groups[key] || { workspaceRootPath: workspace, date: day, events: [] };
|
||||
groups[key].events.push(activity);
|
||||
});
|
||||
return Object.keys(groups).map(function (key) {
|
||||
var group = groups[key];
|
||||
var ordered = group.events.slice().sort(function (a, b) {
|
||||
return eventTimeMs(a) - eventTimeMs(b);
|
||||
});
|
||||
var eventIds = ordered.map(function (activity) { return activity.activityId; }).filter(Boolean);
|
||||
var suggestionId = 'worklog:' + group.workspaceRootPath + ':' + group.date;
|
||||
return {
|
||||
suggestionId: suggestionId,
|
||||
workspaceRootPath: group.workspaceRootPath,
|
||||
date: group.date,
|
||||
title: group.workspaceRootPath + ' work on ' + group.date,
|
||||
summary: summarizeEvents(ordered),
|
||||
minutes: estimateMinutes(ordered),
|
||||
eventIds: eventIds
|
||||
};
|
||||
}).sort(function (a, b) {
|
||||
return b.date.localeCompare(a.date) || a.workspaceRootPath.localeCompare(b.workspaceRootPath);
|
||||
}).slice(0, MAX_SUGGESTIONS);
|
||||
}
|
||||
|
||||
function globalEventKeys(settings) {
|
||||
var keys = [LEGACY_KEY, GLOBAL_KEY];
|
||||
Object.keys(settings || {}).forEach(function (key) {
|
||||
|
|
@ -321,7 +334,7 @@
|
|||
globalEventKeys(settings).forEach(function (key) {
|
||||
all = all.concat(normalizeStoredEvents(settings[key], key));
|
||||
});
|
||||
return sortEvents(all).filter(isMeaningfulActivity);
|
||||
return sortEvents(all);
|
||||
}
|
||||
var workspaceKey = WORKSPACE_PREFIX + encodeKey(workspace);
|
||||
var scopedEvents = normalizeStoredEvents(settings[workspaceKey], workspaceKey);
|
||||
|
|
@ -331,7 +344,49 @@
|
|||
var legacyEvents = normalizeStoredEvents(settings[LEGACY_KEY], LEGACY_KEY).filter(function (item) {
|
||||
return item.workspaceRootPath === workspace;
|
||||
});
|
||||
return sortEvents(scopedEvents.concat(globalEvents, legacyEvents)).filter(isMeaningfulActivity);
|
||||
return sortEvents(scopedEvents.concat(globalEvents, legacyEvents));
|
||||
}
|
||||
|
||||
function candidateStorageKey(workspaceRoot) {
|
||||
return CANDIDATE_PREFIX + encodeKey(workspaceRoot);
|
||||
}
|
||||
|
||||
function dismissalStorageKey(workspaceRoot) {
|
||||
return DISMISSAL_PREFIX + encodeKey(workspaceRoot);
|
||||
}
|
||||
|
||||
function decodeStoredWorkspace(key, prefix) {
|
||||
if (key.indexOf(prefix) !== 0) return '';
|
||||
try {
|
||||
return cleanWorkspace(decodeURIComponent(key.slice(prefix.length)));
|
||||
} catch (err) {
|
||||
return cleanWorkspace(key.slice(prefix.length));
|
||||
}
|
||||
}
|
||||
|
||||
function dismissedCandidatesFromSettings(settings) {
|
||||
var dismissed = {};
|
||||
Object.keys(settings || {}).forEach(function (key) {
|
||||
if (key.indexOf(DISMISSAL_PREFIX) !== 0 || !Array.isArray(settings[key])) return;
|
||||
var workspace = decodeStoredWorkspace(key, DISMISSAL_PREFIX);
|
||||
if (!workspace) return;
|
||||
dismissed[workspace] = {};
|
||||
settings[key].forEach(function (candidateId) {
|
||||
candidateId = text(candidateId).trim();
|
||||
if (candidateId) dismissed[workspace][candidateId] = true;
|
||||
});
|
||||
});
|
||||
return dismissed;
|
||||
}
|
||||
|
||||
function isCandidateDismissed(candidate, dismissedByWorkspace) {
|
||||
return !!(candidate && dismissedByWorkspace && dismissedByWorkspace[candidate.workspaceRootPath] && dismissedByWorkspace[candidate.workspaceRootPath][candidate.candidateId]);
|
||||
}
|
||||
|
||||
function visibleCandidates(activityList, workspaceFilter, dismissedByWorkspace) {
|
||||
return buildWorkSessionCandidates(activityList, workspaceFilter).filter(function (candidate) {
|
||||
return !isCandidateDismissed(candidate, dismissedByWorkspace);
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
|
|
@ -351,7 +406,9 @@
|
|||
|
||||
var scope = scopeFromProps(props || {});
|
||||
var events = [];
|
||||
var suggestions = [];
|
||||
var candidateSourceEvents = [];
|
||||
var candidates = [];
|
||||
var dismissedByWorkspace = {};
|
||||
var statusText = 'Loading activity...';
|
||||
var statusClass = '';
|
||||
var disposed = false;
|
||||
|
|
@ -371,7 +428,10 @@
|
|||
return;
|
||||
}
|
||||
events = [];
|
||||
updateSuggestions();
|
||||
candidateSourceEvents = candidateSourceEvents.filter(function (activity) {
|
||||
return candidateWorkspace(activity) !== scope.workspaceRoot;
|
||||
});
|
||||
updateCandidates();
|
||||
persist().then(render);
|
||||
}
|
||||
});
|
||||
|
|
@ -381,17 +441,46 @@
|
|||
toolbar.appendChild(statusEl);
|
||||
toolbar.appendChild(clearBtn);
|
||||
|
||||
var suggestionsEl = el('div', {
|
||||
className: 'activity-suggestions',
|
||||
'data-activity-section': 'worklog-suggestions'
|
||||
var candidatesEl = el('div', {
|
||||
className: 'activity-candidates',
|
||||
'data-activity-section': 'work-session-candidates'
|
||||
});
|
||||
var listEl = el('div', { className: 'activity-list' });
|
||||
containerEl.appendChild(toolbar);
|
||||
containerEl.appendChild(suggestionsEl);
|
||||
containerEl.appendChild(candidatesEl);
|
||||
containerEl.appendChild(listEl);
|
||||
|
||||
function updateSuggestions() {
|
||||
suggestions = buildWorklogSuggestions(events, scope.mode === 'workspace' ? scope.workspaceRoot : '');
|
||||
function candidatesForWorkspace(workspaceRoot) {
|
||||
return visibleCandidates(candidateSourceEvents, workspaceRoot, dismissedByWorkspace);
|
||||
}
|
||||
|
||||
function updateCandidates() {
|
||||
candidates = candidatesForWorkspace(scope.mode === 'workspace' ? scope.workspaceRoot : '');
|
||||
}
|
||||
|
||||
function candidateWorkspaces() {
|
||||
var workspaces = {};
|
||||
candidateSourceEvents.forEach(function (activity) {
|
||||
var workspace = candidateWorkspace(activity);
|
||||
if (workspace) workspaces[workspace] = true;
|
||||
});
|
||||
if (scope.mode === 'workspace' && scope.workspaceRoot) workspaces[scope.workspaceRoot] = true;
|
||||
return Object.keys(workspaces);
|
||||
}
|
||||
|
||||
function persistCandidateCache(workspaceRoot) {
|
||||
if (!workspaceRoot || !api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
|
||||
return api.settings.write(candidateStorageKey(workspaceRoot), candidatesForWorkspace(workspaceRoot));
|
||||
}
|
||||
|
||||
function persistCandidateCaches() {
|
||||
return Promise.all(candidateWorkspaces().map(persistCandidateCache));
|
||||
}
|
||||
|
||||
function persistDismissals(workspaceRoot) {
|
||||
if (!workspaceRoot || !api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
|
||||
var dismissed = dismissedByWorkspace[workspaceRoot] || {};
|
||||
return api.settings.write(dismissalStorageKey(workspaceRoot), Object.keys(dismissed));
|
||||
}
|
||||
|
||||
function persist() {
|
||||
|
|
@ -399,7 +488,7 @@
|
|||
var toStore = scope.mode === 'global'
|
||||
? events.filter(function (item) { return !item._storageKey || item._storageKey === GLOBAL_KEY; })
|
||||
: events;
|
||||
return api.settings.write(scope.key, storageEvents(toStore)).catch(function (err) {
|
||||
return api.settings.write(scope.key, storageEvents(toStore)).then(persistCandidateCaches).catch(function (err) {
|
||||
statusText = 'Could not save activity: ' + (err && err.message ? err.message : String(err));
|
||||
statusClass = 'error';
|
||||
});
|
||||
|
|
@ -411,9 +500,16 @@
|
|||
return Promise.resolve();
|
||||
}
|
||||
return api.settings.read().then(function (settings) {
|
||||
var keys = globalEventKeys(settings || {});
|
||||
var keys = globalEventKeys(settings || {}).concat(Object.keys(settings || {}).filter(function (key) {
|
||||
return key.indexOf(CANDIDATE_PREFIX) === 0 || key.indexOf(DISMISSAL_PREFIX) === 0;
|
||||
}));
|
||||
events = [];
|
||||
return Promise.all(keys.map(function (key) {
|
||||
candidateSourceEvents = [];
|
||||
candidates = [];
|
||||
dismissedByWorkspace = {};
|
||||
return Promise.all(keys.filter(function (key, index, all) {
|
||||
return all.indexOf(key) === index;
|
||||
}).map(function (key) {
|
||||
return api.settings.write(key, []);
|
||||
}));
|
||||
}).then(function () {
|
||||
|
|
@ -455,25 +551,70 @@
|
|||
});
|
||||
}
|
||||
|
||||
function renderSuggestions() {
|
||||
suggestionsEl.innerHTML = '';
|
||||
if (!suggestions.length) {
|
||||
suggestionsEl.setAttribute('hidden', 'hidden');
|
||||
function candidateTimeRange(candidate) {
|
||||
return formatDate(candidate.startedAt) + ' - ' + formatDate(candidate.endedAt);
|
||||
}
|
||||
|
||||
function reviewCandidate(candidate) {
|
||||
if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function' || typeof CustomEvent !== 'function') return;
|
||||
window.dispatchEvent(new CustomEvent('verstak:workspace-open-tool', {
|
||||
detail: {
|
||||
kind: 'journal',
|
||||
toolRequest: { type: 'work-session-candidate', candidate: candidate }
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function dismissCandidate(candidate) {
|
||||
if (!candidate || !candidate.workspaceRootPath || !candidate.candidateId) return;
|
||||
dismissedByWorkspace[candidate.workspaceRootPath] = dismissedByWorkspace[candidate.workspaceRootPath] || {};
|
||||
dismissedByWorkspace[candidate.workspaceRootPath][candidate.candidateId] = true;
|
||||
updateCandidates();
|
||||
Promise.all([
|
||||
persistDismissals(candidate.workspaceRootPath),
|
||||
persistCandidateCache(candidate.workspaceRootPath)
|
||||
]).then(function () {
|
||||
statusText = 'Candidate dismissed';
|
||||
statusClass = '';
|
||||
}).catch(function (err) {
|
||||
statusText = 'Could not dismiss candidate: ' + (err && err.message ? err.message : String(err));
|
||||
statusClass = 'error';
|
||||
}).then(render);
|
||||
}
|
||||
|
||||
function renderCandidates() {
|
||||
candidatesEl.innerHTML = '';
|
||||
if (!candidates.length) {
|
||||
candidatesEl.setAttribute('hidden', 'hidden');
|
||||
return;
|
||||
}
|
||||
if (typeof suggestionsEl.removeAttribute === 'function') suggestionsEl.removeAttribute('hidden');
|
||||
else delete suggestionsEl.attributes.hidden;
|
||||
suggestionsEl.appendChild(el('div', { className: 'activity-suggestions-title', textContent: 'Worklog suggestions' }));
|
||||
suggestions.forEach(function (suggestion) {
|
||||
suggestionsEl.appendChild(el('div', {
|
||||
className: 'activity-suggestion',
|
||||
'data-worklog-suggestion': suggestion.suggestionId
|
||||
if (typeof candidatesEl.removeAttribute === 'function') candidatesEl.removeAttribute('hidden');
|
||||
else delete candidatesEl.attributes.hidden;
|
||||
candidatesEl.appendChild(el('div', { className: 'activity-candidates-title', textContent: 'Possible journal entries' }));
|
||||
candidates.forEach(function (candidate) {
|
||||
candidatesEl.appendChild(el('div', {
|
||||
className: 'activity-candidate',
|
||||
'data-work-session-candidate': candidate.candidateId
|
||||
}, [
|
||||
el('div', {}, [
|
||||
el('div', { className: 'activity-suggestion-title', textContent: suggestion.title }),
|
||||
el('div', { className: 'activity-suggestion-summary', textContent: suggestion.summary })
|
||||
el('div', { className: 'activity-candidate-title', textContent: 'Possible journal entry' }),
|
||||
el('div', { className: 'activity-candidate-facts' }, [
|
||||
el('div', { textContent: 'Workspace: ' + candidate.workspaceRootPath }),
|
||||
el('div', { textContent: 'Time: ' + candidateTimeRange(candidate) }),
|
||||
el('div', { textContent: 'Estimated duration: ' + candidate.estimatedMinutes + ' min' }),
|
||||
el('div', { textContent: 'Activities: ' + candidate.activityCount })
|
||||
]),
|
||||
el('div', { className: 'activity-suggestion-minutes', textContent: suggestion.minutes + ' min' })
|
||||
el('details', { className: 'activity-candidate-activities' }, [
|
||||
el('summary', { textContent: 'Included activities (' + candidate.activityCount + ')' })
|
||||
].concat(candidate.activities.map(function (activity) {
|
||||
return el('div', { className: 'activity-candidate-activity', textContent: activity.occurredAt + ' · ' + activity.type + ' · ' + activity.activityId });
|
||||
})))
|
||||
]),
|
||||
el('div', { className: 'activity-candidate-actions' }, [
|
||||
el('div', { className: 'activity-candidate-duration', textContent: candidate.estimatedMinutes + ' min' }),
|
||||
el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'review', textContent: 'Review', onClick: function () { reviewCandidate(candidate); } }),
|
||||
el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'dismiss', textContent: 'Dismiss', onClick: function () { dismissCandidate(candidate); } })
|
||||
])
|
||||
]));
|
||||
});
|
||||
}
|
||||
|
|
@ -483,45 +624,41 @@
|
|||
clearBtn.disabled = events.length === 0;
|
||||
statusEl.textContent = statusText;
|
||||
statusEl.className = 'activity-status' + (statusClass ? ' ' + statusClass : '');
|
||||
updateSuggestions();
|
||||
renderSuggestions();
|
||||
renderCandidates();
|
||||
renderList();
|
||||
}
|
||||
|
||||
function loadStored() {
|
||||
if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve();
|
||||
if (scope.mode === 'global') {
|
||||
return api.settings.read().then(function (settings) {
|
||||
events = eventsFromSettings(settings || {}, '');
|
||||
}).catch(function (err) {
|
||||
statusText = 'Could not load activity: ' + (err && err.message ? err.message : String(err));
|
||||
statusClass = 'error';
|
||||
});
|
||||
}
|
||||
return api.settings.read().then(function (settings) {
|
||||
events = eventsFromSettings(settings || {}, scope.workspaceRoot);
|
||||
settings = settings || {};
|
||||
candidateSourceEvents = eventsFromSettings(settings, '');
|
||||
events = eventsFromSettings(settings, scope.mode === 'workspace' ? scope.workspaceRoot : '');
|
||||
dismissedByWorkspace = dismissedCandidatesFromSettings(settings);
|
||||
updateCandidates();
|
||||
return persistCandidateCaches();
|
||||
}).catch(function (err) {
|
||||
statusText = 'Could not load activity: ' + (err && err.message ? err.message : String(err));
|
||||
statusClass = 'error';
|
||||
});
|
||||
}
|
||||
|
||||
function suggestWorklog(args) {
|
||||
function listWorkSessionCandidates(args) {
|
||||
var workspace = cleanWorkspace(args && args.workspaceRootPath);
|
||||
if (!api || !api.settings || typeof api.settings.read !== 'function') {
|
||||
return Promise.resolve({ suggestions: buildWorklogSuggestions(events, workspace || (scope.mode === 'workspace' ? scope.workspaceRoot : '')) });
|
||||
return Promise.resolve({ candidates: visibleCandidates(candidateSourceEvents, workspace || (scope.mode === 'workspace' ? scope.workspaceRoot : ''), dismissedByWorkspace) });
|
||||
}
|
||||
return api.settings.read().then(function (settings) {
|
||||
var sourceEvents = eventsFromSettings(settings || {}, workspace);
|
||||
return { suggestions: buildWorklogSuggestions(sourceEvents, workspace) };
|
||||
settings = settings || {};
|
||||
return { candidates: visibleCandidates(eventsFromSettings(settings, ''), workspace, dismissedCandidatesFromSettings(settings)) };
|
||||
}).catch(function () {
|
||||
return { suggestions: [] };
|
||||
return { candidates: [] };
|
||||
});
|
||||
}
|
||||
|
||||
function registerCommands() {
|
||||
if (!api || !api.commands || typeof api.commands.register !== 'function') return Promise.resolve();
|
||||
return api.commands.register(WORKLOG_COMMAND_ID, suggestWorklog).then(function (unregister) {
|
||||
return api.commands.register(WORKLOG_COMMAND_ID, listWorkSessionCandidates).then(function (unregister) {
|
||||
if (typeof unregister === 'function') unsubscribers.push(unregister);
|
||||
}).catch(function (err) {
|
||||
statusText = 'Activity commands unavailable: ' + (err && err.message ? err.message : String(err));
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@
|
|||
"commands": [
|
||||
{
|
||||
"id": "verstak.activity.suggestWorklog",
|
||||
"title": "Suggest Worklog From Activity",
|
||||
"title": "List Possible Journal Entries",
|
||||
"handler": "verstak.activity.suggestWorklog"
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"name": "Journal",
|
||||
"version": "0.1.0",
|
||||
"apiVersion": "0.1.0",
|
||||
"description": "Workspace-scoped worklog journal with manual entries and Activity suggestion import.",
|
||||
"description": "Workspace-scoped journal with user-authored entries and optional Activity links.",
|
||||
"source": "official",
|
||||
"icon": "book-open",
|
||||
"provides": [
|
||||
|
|
@ -12,9 +12,6 @@
|
|||
"journal",
|
||||
"report.worklog"
|
||||
],
|
||||
"optionalRequires": [
|
||||
"activity.reconstruction"
|
||||
],
|
||||
"permissions": [
|
||||
"storage.namespace",
|
||||
"ui.register"
|
||||
|
|
|
|||
|
|
@ -270,19 +270,32 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
|
|||
if (!container.textContent.includes('Example Article')) throw new Error('browser capture title was not rendered');
|
||||
if (!container.textContent.includes('browser.capture.selection')) throw new Error('event type was not rendered');
|
||||
if (!container.textContent.includes('browser.capture.converted')) throw new Error('conversion event type was not rendered');
|
||||
if (!container.textContent.includes('Worklog suggestions')) throw new Error('worklog suggestions section was not rendered');
|
||||
if (!container.textContent.includes('Project work on 2026-06-27')) throw new Error('workspace worklog suggestion title was not rendered');
|
||||
if (!container.textContent.includes('30 min')) throw new Error('workspace worklog suggestion duration was not rendered');
|
||||
const suggestionNode = walk(container, (node) => node.getAttribute && node.getAttribute('data-worklog-suggestion') === 'worklog:Project:2026-06-27');
|
||||
if (!suggestionNode) throw new Error('worklog suggestion data attribute was not rendered');
|
||||
if (!container.textContent.includes('Possible journal entries')) throw new Error('work session candidate section was not rendered');
|
||||
if (container.textContent.includes('Project work on 2026-06-27')) throw new Error('candidate must not invent a worklog title');
|
||||
const candidateNode = walk(container, (node) => node.getAttribute && node.getAttribute('data-work-session-candidate'));
|
||||
if (!candidateNode) throw new Error('work session candidate data attribute was not rendered');
|
||||
if (!candidateNode.textContent.includes('Workspace: Project')) throw new Error('candidate workspace was not rendered');
|
||||
if (!candidateNode.textContent.includes('Estimated duration: 30 min')) throw new Error('candidate duration was not rendered');
|
||||
if (!candidateNode.textContent.includes('Activities: 3')) throw new Error('candidate activity count was not rendered');
|
||||
if (!walk(candidateNode, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'review')) throw new Error('candidate review action was not rendered');
|
||||
if (!walk(candidateNode, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'dismiss')) throw new Error('candidate dismiss action was not rendered');
|
||||
|
||||
const commandResult = await api.commandHandlers.get(WORKLOG_COMMAND_ID)({ workspaceRootPath: 'Project' });
|
||||
const suggestions = commandResult && commandResult.suggestions;
|
||||
if (!Array.isArray(suggestions) || suggestions.length !== 1) throw new Error('worklog suggestion command returned unexpected suggestions');
|
||||
if (suggestions[0].suggestionId !== 'worklog:Project:2026-06-27') throw new Error('worklog suggestion id mismatch');
|
||||
if (suggestions[0].minutes !== 30) throw new Error(`expected 30 suggested minutes, got ${suggestions[0].minutes}`);
|
||||
if (!suggestions[0].summary.includes('Example Article') || !suggestions[0].summary.includes('Saved note')) throw new Error('worklog suggestion summary did not include event titles');
|
||||
if (suggestions[0].eventIds.join(',') !== 'capture-1,note-1,capture-1:browser.capture.converted') throw new Error('worklog suggestion event ids mismatch');
|
||||
const candidates = commandResult && commandResult.candidates;
|
||||
if (!Array.isArray(candidates) || candidates.length !== 1) throw new Error('work session command returned unexpected candidates');
|
||||
const candidate = candidates[0];
|
||||
if (!candidate.candidateId) throw new Error('candidate id is missing');
|
||||
if (candidate.workspaceRootPath !== 'Project') throw new Error('candidate workspace mismatch');
|
||||
if (candidate.startedAt !== '2026-06-27T00:00:00.000Z' || candidate.endedAt !== '2026-06-27T00:30:00.000Z') throw new Error('candidate range mismatch');
|
||||
if (candidate.estimatedMinutes !== 30) throw new Error(`expected 30 candidate minutes, got ${candidate.estimatedMinutes}`);
|
||||
if (candidate.activityCount !== 3) throw new Error(`expected three candidate activities, got ${candidate.activityCount}`);
|
||||
if (candidate.activityIds.join(',') !== 'capture-1,note-1,capture-1:browser.capture.converted') throw new Error('candidate activity ids mismatch');
|
||||
if (!Array.isArray(candidate.activities) || candidate.activities.length !== 3) throw new Error('candidate activity list is missing');
|
||||
if ('title' in candidate || 'summary' in candidate || 'description' in candidate) throw new Error('candidate contains non-factual journal fields');
|
||||
const cachedCandidates = api.storedEvents('work-session-candidates:workspace:Project');
|
||||
if (!Array.isArray(cachedCandidates) || cachedCandidates.length !== 1 || cachedCandidates[0].candidateId !== candidate.candidateId) {
|
||||
throw new Error('candidate cache was not persisted for Overview');
|
||||
}
|
||||
|
||||
const clientView = await mountWithApi(api, { workspaceNode: { name: 'ClientA' }, workspaceRootPath: 'ClientA' });
|
||||
if (clientView.container.textContent.includes('Example Article')) throw new Error('Project activity leaked into ClientA workspace view');
|
||||
|
|
@ -313,16 +326,22 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
|
|||
await flush();
|
||||
if (api.storedEvents(clientKey).length !== 1) throw new Error('ClientA activity was not stored under ClientA workspace key');
|
||||
if (!clientView.container.textContent.includes('Client note')) throw new Error('ClientA activity was not rendered');
|
||||
if (!clientView.container.textContent.includes('ClientA work on 2026-06-27')) throw new Error('ClientA worklog suggestion was not rendered');
|
||||
if (clientView.container.textContent.includes('Possible journal entries')) throw new Error('a single activity must not create a work session candidate');
|
||||
component.unmount && component.unmount(clientView.container);
|
||||
|
||||
const globalView = await mountWithApi(api, {});
|
||||
if (!globalView.container.textContent.includes('Example Article')) throw new Error('global activity did not aggregate Project activity');
|
||||
if (!globalView.container.textContent.includes('Client note')) throw new Error('global activity did not aggregate ClientA activity');
|
||||
if (!globalView.container.textContent.includes('Project work on 2026-06-27')) throw new Error('global activity did not render Project worklog suggestion');
|
||||
if (!globalView.container.textContent.includes('ClientA work on 2026-06-27')) throw new Error('global activity did not render ClientA worklog suggestion');
|
||||
if (!globalView.container.textContent.includes('Possible journal entries')) throw new Error('global activity did not render work session candidates');
|
||||
component.unmount && component.unmount(globalView.container);
|
||||
|
||||
const dismissButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'dismiss');
|
||||
if (!dismissButton) throw new Error('candidate dismiss action was not available');
|
||||
dismissButton.click();
|
||||
await flush();
|
||||
if (container.textContent.includes('Possible journal entries')) throw new Error('dismiss action did not remove the candidate from Activity');
|
||||
if (api.storedEvents('work-session-candidates:workspace:Project').length !== 0) throw new Error('dismiss action did not update the candidate cache');
|
||||
|
||||
const manualButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-activity-action') === 'manual');
|
||||
if (manualButton) throw new Error('manual activity button should not be rendered');
|
||||
|
||||
|
|
@ -331,7 +350,7 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
|
|||
clearButton.click();
|
||||
await flush();
|
||||
if (api.storedEvents(projectKey).length !== 0) throw new Error('clear action did not remove activity events');
|
||||
if (container.textContent.includes('Project work on 2026-06-27')) throw new Error('clear action did not remove worklog suggestions');
|
||||
if (api.storedEvents('work-session-candidates:workspace:Project').length !== 0) throw new Error('clear action did not remove cached candidates');
|
||||
|
||||
component.unmount && component.unmount(container);
|
||||
if (api.unsubscribed.length !== 33) throw new Error(`expected 33 unsubscribers, got ${api.unsubscribed.length}`);
|
||||
|
|
@ -355,7 +374,7 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
|
|||
});
|
||||
const persisted = await mountWithApi(persistedApi);
|
||||
if (!persisted.container.textContent.includes('Saved note')) throw new Error('persisted activity was not rendered');
|
||||
if (persisted.container.textContent.includes('Selected file')) throw new Error('low-value file activity should not be rendered');
|
||||
if (!persisted.container.textContent.includes('Selected file')) throw new Error('raw Activity log must retain low-value technical events');
|
||||
|
||||
const legacyApi = makeApi({
|
||||
events: [
|
||||
|
|
@ -402,6 +421,34 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
|
|||
const taggedGlobalClient = await mountWithApi(taggedGlobalApi, { workspaceNode: { name: 'ClientA' }, workspaceRootPath: 'ClientA' });
|
||||
if (taggedGlobalClient.container.textContent.includes('Global project capture')) throw new Error('workspace-tagged global activity leaked into another workspace');
|
||||
|
||||
const sessionsApi = makeApi({
|
||||
'events:workspace:Project': [
|
||||
{ activityId: 'project-first', type: 'note.saved', occurredAt: '2026-06-27T00:00:00Z', workspaceRootPath: 'Project' },
|
||||
{ activityId: 'project-second', type: 'file.changed', occurredAt: '2026-06-27T00:10:00Z', workspaceRootPath: 'Project' },
|
||||
{ activityId: 'project-after-switch-first', type: 'note.saved', occurredAt: '2026-06-27T00:24:00Z', workspaceRootPath: 'Project' },
|
||||
{ activityId: 'project-after-switch-second', type: 'file.changed', occurredAt: '2026-06-27T00:34:00Z', workspaceRootPath: 'Project' },
|
||||
{ activityId: 'project-after-idle-first', type: 'note.saved', occurredAt: '2026-06-27T01:10:00Z', workspaceRootPath: 'Project' },
|
||||
{ activityId: 'project-after-idle-second', type: 'file.changed', occurredAt: '2026-06-27T01:22:00Z', workspaceRootPath: 'Project' },
|
||||
],
|
||||
'events:workspace:ClientA': [
|
||||
{ activityId: 'client-first', type: 'note.saved', occurredAt: '2026-06-27T00:12:00Z', workspaceRootPath: 'ClientA' },
|
||||
{ activityId: 'client-second', type: 'file.changed', occurredAt: '2026-06-27T00:22:00Z', workspaceRootPath: 'ClientA' },
|
||||
],
|
||||
});
|
||||
const sessionView = await mountWithApi(sessionsApi, {});
|
||||
const sessionResult = await sessionsApi.commandHandlers.get(WORKLOG_COMMAND_ID)({});
|
||||
const sessionCandidates = sessionResult && sessionResult.candidates;
|
||||
if (!Array.isArray(sessionCandidates) || sessionCandidates.length !== 4) throw new Error('workspace switches and idle gaps must split candidates');
|
||||
const projectCandidates = sessionCandidates.filter((item) => item.workspaceRootPath === 'Project');
|
||||
if (projectCandidates.length !== 3) throw new Error('workspace switch must close the prior Project candidate');
|
||||
if (!projectCandidates.some((item) => item.activityIds.join(',') === 'project-after-idle-first,project-after-idle-second')) {
|
||||
throw new Error('idle gap must start a separate Project candidate');
|
||||
}
|
||||
if (sessionCandidates.some((item) => 'title' in item || 'summary' in item || 'description' in item)) {
|
||||
throw new Error('session candidates must contain only factual fields');
|
||||
}
|
||||
component.unmount && component.unmount(sessionView.container);
|
||||
|
||||
console.log('activity plugin smoke passed');
|
||||
})().catch((err) => {
|
||||
console.error(err);
|
||||
|
|
|
|||
|
|
@ -90,6 +90,12 @@ function walk(node, fn) {
|
|||
return null;
|
||||
}
|
||||
|
||||
function walkAll(node, fn, matches = []) {
|
||||
if (fn(node)) matches.push(node);
|
||||
for (const child of node.children) walkAll(child, fn, matches);
|
||||
return matches;
|
||||
}
|
||||
|
||||
function makeDocument() {
|
||||
return {
|
||||
body: new FakeNode('body'),
|
||||
|
|
@ -127,9 +133,7 @@ function loadComponent(document) {
|
|||
|
||||
function makeApi(initialSettings = {}) {
|
||||
const settings = { ...initialSettings };
|
||||
const commandCalls = [];
|
||||
return {
|
||||
commandCalls,
|
||||
settings: {
|
||||
read: async (key) => (key ? settings[key] : { ...settings }),
|
||||
write: async (key, value) => {
|
||||
|
|
@ -137,30 +141,6 @@ function makeApi(initialSettings = {}) {
|
|||
return { ...settings };
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
executeFor: async (pluginId, commandId, args) => {
|
||||
commandCalls.push({ pluginId, commandId, args });
|
||||
if (pluginId !== 'verstak.activity' || commandId !== 'verstak.activity.suggestWorklog') {
|
||||
throw new Error(`unexpected command ${pluginId}:${commandId}`);
|
||||
}
|
||||
return {
|
||||
status: 'handled',
|
||||
pluginId,
|
||||
commandId,
|
||||
result: {
|
||||
suggestions: [{
|
||||
suggestionId: 'worklog:Project:2026-06-27',
|
||||
workspaceRootPath: 'Project',
|
||||
date: '2026-06-27',
|
||||
title: 'Project work on 2026-06-27',
|
||||
summary: 'Example Article; Saved note',
|
||||
minutes: 30,
|
||||
eventIds: ['capture-1', 'note-1'],
|
||||
}],
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
storedEntries(key) {
|
||||
return settings[key] || [];
|
||||
},
|
||||
|
|
@ -190,7 +170,7 @@ function byData(container, attr, value) {
|
|||
for (const capability of ['worklog', 'journal', 'report.worklog']) {
|
||||
if (!manifest.provides.includes(capability)) throw new Error(`journal manifest missing capability ${capability}`);
|
||||
}
|
||||
if (!manifest.optionalRequires.includes('activity.reconstruction')) throw new Error('journal manifest must optionally require activity.reconstruction');
|
||||
if ((manifest.optionalRequires || []).includes('activity.reconstruction')) throw new Error('Journal must remain available without Activity');
|
||||
if (!manifest.permissions.includes('storage.namespace')) throw new Error('journal manifest must request storage.namespace');
|
||||
if (!manifest.permissions.includes('ui.register')) throw new Error('journal manifest must request ui.register');
|
||||
if (!(manifest.contributes.workspaceItems || []).some((item) => item.component === 'JournalView')) throw new Error('journal workspace item missing');
|
||||
|
|
@ -200,6 +180,10 @@ function byData(container, attr, value) {
|
|||
const { component, container } = await mountWithApi(api);
|
||||
const projectKey = 'worklog:workspace:Project';
|
||||
|
||||
if (walk(container, (node) => node.getAttribute && node.getAttribute('data-journal-action') === 'import-activity')) {
|
||||
throw new Error('Journal must not provide direct Activity import');
|
||||
}
|
||||
|
||||
byData(container, 'data-journal-action', 'add').click();
|
||||
await flush();
|
||||
byData(container, 'data-journal-input', 'date').value = '2026-06-27';
|
||||
|
|
@ -210,6 +194,7 @@ function byData(container, attr, value) {
|
|||
await flush();
|
||||
|
||||
if (api.storedEntries(projectKey).length !== 1) throw new Error('manual journal entry was not stored');
|
||||
if (api.storedEntries(projectKey)[0].activityIds.length !== 0) throw new Error('manual journal entry must not require activity links');
|
||||
if (!container.textContent.includes('Draft brief')) throw new Error('manual journal entry was not rendered');
|
||||
if (!container.textContent.includes('45 min')) throw new Error('manual journal entry minutes were not rendered');
|
||||
|
||||
|
|
@ -225,28 +210,60 @@ function byData(container, attr, value) {
|
|||
if (api.storedEntries(projectKey)[0].title !== 'Draft brief updated') throw new Error('journal entry title was not updated');
|
||||
if (!container.textContent.includes('60 min')) throw new Error('edited journal entry minutes were not rendered');
|
||||
|
||||
byData(container, 'data-journal-action', 'import-activity').click();
|
||||
const candidate = {
|
||||
candidateId: 'work-session:Project:capture-1:note-1',
|
||||
workspaceRootPath: 'Project',
|
||||
startedAt: '2026-06-27T10:12:00.000Z',
|
||||
endedAt: '2026-06-27T11:03:00.000Z',
|
||||
estimatedMinutes: 51,
|
||||
activityCount: 2,
|
||||
activityIds: ['capture-1', 'note-1'],
|
||||
activities: [
|
||||
{ activityId: 'capture-1', type: 'browser.capture.selection', occurredAt: '2026-06-27T10:12:00.000Z', sourcePluginId: 'verstak.browser-inbox' },
|
||||
{ activityId: 'note-1', type: 'note.saved', occurredAt: '2026-06-27T11:03:00.000Z', sourcePluginId: 'verstak.notes' },
|
||||
],
|
||||
};
|
||||
const candidateView = await mountWithApi(api, {
|
||||
workspaceNode: { name: 'Project' },
|
||||
workspaceRootPath: 'Project',
|
||||
toolRequest: { type: 'work-session-candidate', candidate },
|
||||
});
|
||||
if (!candidateView.container.textContent.includes('Review possible journal entry')) throw new Error('candidate review modal was not opened');
|
||||
if (!candidateView.container.textContent.includes('Workspace: Project')) throw new Error('candidate workspace was not shown for review');
|
||||
if (!candidateView.container.textContent.includes('Estimated duration: 51 min')) throw new Error('candidate duration was not shown for review');
|
||||
if (byData(candidateView.container, 'data-journal-input', 'title').value !== '') throw new Error('candidate review must start with an empty title');
|
||||
if (byData(candidateView.container, 'data-journal-input', 'summary').value !== '') throw new Error('candidate review must start with an empty body');
|
||||
if (byData(candidateView.container, 'data-journal-input', 'minutes').value !== '51') throw new Error('candidate review must prefill the factual duration');
|
||||
const linkedActivityInputs = walkAll(candidateView.container, (node) => node.getAttribute && node.getAttribute('data-journal-candidate-activity'));
|
||||
if (linkedActivityInputs.length !== 2 || linkedActivityInputs.some((node) => node.checked !== true)) throw new Error('candidate activities were not available for review');
|
||||
byData(candidateView.container, 'data-journal-input', 'title').value = 'Review research capture';
|
||||
byData(candidateView.container, 'data-journal-input', 'summary').value = 'Read the capture and updated the project note.';
|
||||
linkedActivityInputs[1].checked = false;
|
||||
byData(candidateView.container, 'data-journal-action', 'save-entry').click();
|
||||
await flush();
|
||||
|
||||
if (api.commandCalls.length !== 1) throw new Error('activity suggestion command was not called');
|
||||
if (api.commandCalls[0].args.workspaceRootPath !== 'Project') throw new Error('activity suggestion command used wrong workspace');
|
||||
if (api.storedEntries(projectKey).length !== 2) throw new Error('activity suggestion was not imported as a journal entry');
|
||||
if (!container.textContent.includes('Project work on 2026-06-27')) throw new Error('imported activity suggestion was not rendered');
|
||||
if (api.storedEntries(projectKey).length !== 2) throw new Error('reviewed candidate was not saved as a journal entry');
|
||||
const linkedEntry = api.storedEntries(projectKey).find((entry) => entry.sourceCandidateId === candidate.candidateId);
|
||||
if (!linkedEntry) throw new Error('candidate reference was not stored on the journal entry');
|
||||
if (linkedEntry.title !== 'Review research capture' || linkedEntry.summary !== 'Read the capture and updated the project note.') {
|
||||
throw new Error('candidate review did not keep the user-authored entry fields');
|
||||
}
|
||||
if (linkedEntry.activityIds.join(',') !== 'capture-1') throw new Error('candidate review did not persist selected activity ids');
|
||||
if (walk(candidateView.container, (node) => node.getAttribute && node.getAttribute('data-journal-action') === 'view-activity')) {
|
||||
throw new Error('journal rows must not navigate to Activity by default');
|
||||
}
|
||||
|
||||
byData(container, 'data-journal-action', 'import-activity').click();
|
||||
await flush();
|
||||
if (api.storedEntries(projectKey).length !== 2) throw new Error('duplicate activity suggestion was imported');
|
||||
|
||||
byData(container, 'data-journal-action', 'delete').click();
|
||||
byData(candidateView.container, 'data-journal-action', 'delete').click();
|
||||
await flush();
|
||||
if (api.storedEntries(projectKey).length !== 1) throw new Error('journal entry was not deleted');
|
||||
|
||||
const globalView = await mountWithApi(api, {});
|
||||
if (!globalView.container.textContent.includes('Project work on 2026-06-27') && !globalView.container.textContent.includes('Draft brief updated')) {
|
||||
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');
|
||||
}
|
||||
|
||||
component.unmount && component.unmount(container);
|
||||
component.unmount && component.unmount(candidateView.container);
|
||||
component.unmount && component.unmount(globalView.container);
|
||||
|
||||
console.log('journal plugin smoke passed');
|
||||
|
|
|
|||
Loading…
Reference in New Issue