1120 lines
36 KiB
Svelte
1120 lines
36 KiB
Svelte
<script>
|
|
import { createEventDispatcher, onMount } from 'svelte';
|
|
import * as App from '../../../wailsjs/go/api/App';
|
|
|
|
export let workspaceRootPath = '';
|
|
export let availableTools = [];
|
|
|
|
const dispatch = createEventDispatcher();
|
|
const FILTERS = [
|
|
{ key: 'all', label: 'All' },
|
|
{ key: 'notes', label: 'Notes' },
|
|
{ key: 'files', label: 'Files' },
|
|
{ key: 'captures', label: 'Captures' },
|
|
{ key: 'journal', label: 'Journal' },
|
|
];
|
|
const LOW_VALUE_RECENT_TYPES = new Set([
|
|
'workspace.selected',
|
|
'case.selected',
|
|
'file.selected',
|
|
'file.opened',
|
|
'note.opened',
|
|
]);
|
|
const LOW_VALUE_RESUME_TYPES = new Set([
|
|
'workspace.selected',
|
|
'case.selected',
|
|
'file.selected',
|
|
]);
|
|
|
|
let loading = true;
|
|
let activeFilter = 'all';
|
|
let captures = [];
|
|
let activityEvents = [];
|
|
let journalEntries = [];
|
|
let todos = [];
|
|
let workSessionCandidates = [];
|
|
let keyResources = [];
|
|
let totalNotes = 0;
|
|
let loadedWorkspaceRoot = '';
|
|
let toolProbe = 0;
|
|
|
|
$: hasNotes = hasTool('notes', availableTools);
|
|
$: hasTodos = hasTool('todo', availableTools);
|
|
$: recentChanges = buildRecentChanges(activityEvents, captures, journalEntries);
|
|
$: filteredRecentChanges = activeFilter === 'all'
|
|
? recentChanges
|
|
: recentChanges.filter(item => item.category === activeFilter);
|
|
$: noteRecentChanges = countCategory(recentChanges, 'notes');
|
|
$: fileRecentChanges = countCategory(recentChanges, 'files');
|
|
$: unprocessedCaptures = captures.filter(item => item?.processed !== true);
|
|
$: linkedCandidateIds = new Set(journalEntries.map(item => String(item?.sourceCandidateId || '')).filter(Boolean));
|
|
$: pendingWorkSessionCandidates = workSessionCandidates.filter(item => !linkedCandidateIds.has(String(item?.candidateId || '')));
|
|
$: urgentTodos = hasTodos ? todos.filter(item => todoAttentionState(item)) : [];
|
|
$: needsAttention = buildNeedsAttention(unprocessedCaptures, pendingWorkSessionCandidates, urgentTodos);
|
|
$: continueItems = buildContinueItems(activityEvents, unprocessedCaptures, journalEntries, urgentTodos);
|
|
$: attentionActionKind = needsAttention[0]?.actionKind || 'browser-inbox';
|
|
$: lastActive = lastActiveDate([...recentChanges, ...continueItems], captures, journalEntries, todos);
|
|
$: summaryItems = [
|
|
{ key: 'notes', label: 'Notes', count: totalNotes, detail: totalAndRecentLabel(totalNotes, noteRecentChanges), actionKind: 'notes', actionLabel: 'Open Notes' },
|
|
{ key: 'files', label: 'Files', count: fileRecentChanges, detail: countLabel(fileRecentChanges, 'recent change'), actionKind: 'files', actionLabel: 'Open Files' },
|
|
{ key: 'captures', label: 'Captures', count: unprocessedCaptures.length, detail: captureReviewLabel(unprocessedCaptures.length), actionKind: 'browser-inbox', actionLabel: 'Review Inbox' },
|
|
{ key: 'activity', label: 'Activity', count: activityEvents.length, detail: countLabel(activityEvents.length, 'recorded event'), actionKind: 'activity', actionLabel: 'View Activity' },
|
|
{ key: 'journal', label: 'Journal', count: journalEntries.length, detail: journalEntryLabel(journalEntries.length), actionKind: 'journal', actionLabel: 'Open Journal' },
|
|
{ key: 'attention', label: 'Needs attention', count: needsAttention.length, detail: countLabel(needsAttention.length, 'pending item'), actionKind: attentionActionKind, actionLabel: 'Review pending items' },
|
|
];
|
|
|
|
onMount(() => {
|
|
toolProbe += 1;
|
|
});
|
|
|
|
$: if (workspaceRootPath && workspaceRootPath !== loadedWorkspaceRoot) {
|
|
loadOverview();
|
|
}
|
|
|
|
function hasTool(name, tools = availableTools) {
|
|
toolProbe;
|
|
name = String(name || '').toLowerCase();
|
|
const fromProps = (tools || []).some(tool => {
|
|
const label = `${tool?.title || ''} ${tool?.id || ''} ${tool?.pluginId || ''}`.toLowerCase();
|
|
return label.includes(name);
|
|
});
|
|
if (fromProps) return true;
|
|
if (typeof document === 'undefined') return false;
|
|
return Array.from(document.querySelectorAll('.workspace-tabs [role="tab"]')).some(tab => {
|
|
return String(tab.textContent || '').trim().toLowerCase().includes(name);
|
|
});
|
|
}
|
|
|
|
function decodeTuple(response, fallback) {
|
|
if (Array.isArray(response) && response.length === 2) return response[1] ? fallback : (response[0] || fallback);
|
|
return response || fallback;
|
|
}
|
|
|
|
async function readPluginSettings(pluginId) {
|
|
try {
|
|
return decodeTuple(await App.ReadPluginSettings(pluginId), {});
|
|
} catch (_) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function workspaceKey(prefix) {
|
|
return prefix + encodeURIComponent(String(workspaceRootPath || '').trim());
|
|
}
|
|
|
|
function normalizeRows(value) {
|
|
return Array.isArray(value) ? value.filter(item => item && typeof item === 'object') : [];
|
|
}
|
|
|
|
function rowsFor(settings, keys) {
|
|
const workspace = String(workspaceRootPath || '').trim();
|
|
return keys.flatMap(key => normalizeRows(settings?.[key])).filter(item => {
|
|
const tagged = String(item.workspaceRootPath || item.workspaceName || item.workspaceNodeId || '').trim();
|
|
return !tagged || !workspace || tagged === workspace;
|
|
});
|
|
}
|
|
|
|
function browserCaptureRowsForWorkspace(settings) {
|
|
const workspace = String(workspaceRootPath || '').trim();
|
|
if (!workspace) return [];
|
|
const seen = new Set();
|
|
return [
|
|
'captures:global',
|
|
workspaceKey('captures:workspace:'),
|
|
'captures',
|
|
].flatMap(key => normalizeRows(settings?.[key])).filter(item => {
|
|
const tagged = String(item.workspaceRootPath || item.workspaceName || item.workspaceNodeId || '').trim();
|
|
if (tagged !== workspace) return false;
|
|
const captureId = String(item.captureId || '');
|
|
if (!captureId || seen.has(captureId)) return !captureId;
|
|
seen.add(captureId);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function todoRowsForWorkspace(settings) {
|
|
const workspace = String(workspaceRootPath || '').trim();
|
|
if (!workspace) return [];
|
|
const seen = new Set();
|
|
return normalizeRows(settings?.['todos:global']).filter(item => {
|
|
const tagged = String(item.workspaceRootPath || item.workspaceName || item.workspaceNodeId || '').trim();
|
|
if (tagged !== workspace) return false;
|
|
const todoId = String(item.id || '');
|
|
if (!todoId || seen.has(todoId)) return !todoId;
|
|
seen.add(todoId);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function todoDateMs(value) {
|
|
const raw = String(value || '').trim();
|
|
if (!raw) return 0;
|
|
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}T00:00:00` : raw;
|
|
const date = new Date(normalized);
|
|
return Number.isNaN(date.getTime()) ? 0 : date.getTime();
|
|
}
|
|
|
|
function todoTitle(item) {
|
|
return String(item?.title || item?.name || 'Untitled todo').trim() || 'Untitled todo';
|
|
}
|
|
|
|
function todoAttentionState(item) {
|
|
if (String(item?.status || 'open').toLowerCase() !== 'open') return '';
|
|
const now = Date.now();
|
|
const dueAt = todoDateMs(item?.dueAt);
|
|
const reminderAt = todoDateMs(item?.reminderAt);
|
|
if (reminderAt && reminderAt <= now) return 'Reminder due';
|
|
if (dueAt && dueAt < now) return 'Overdue';
|
|
if (dueAt && dueAt <= now + 3 * 24 * 60 * 60 * 1000) return 'Due soon';
|
|
return '';
|
|
}
|
|
|
|
function todoTimeValue(item) {
|
|
return item?.dueAt || item?.reminderAt || item?.updatedAt || item?.createdAt || '';
|
|
}
|
|
|
|
function timeValue(item) {
|
|
return item?.capturedAt || item?.endedAt || item?.startedAt || item?.occurredAt || item?.receivedAt || item?.updatedAt || item?.modifiedAt || item?.date || item?.time || '';
|
|
}
|
|
|
|
function timeMs(item) {
|
|
const value = timeValue(item);
|
|
if (!value) return 0;
|
|
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(String(value)) ? `${value}T00:00:00` : value;
|
|
const date = new Date(normalized);
|
|
return Number.isNaN(date.getTime()) ? 0 : date.getTime();
|
|
}
|
|
|
|
function sortByTime(rows) {
|
|
return [...rows].sort((a, b) => timeMs(b) - timeMs(a));
|
|
}
|
|
|
|
function fileName(path) {
|
|
const value = String(path || '').split('/').filter(Boolean).pop() || '';
|
|
return value || String(path || '').trim();
|
|
}
|
|
|
|
function titleFromPath(path) {
|
|
return fileName(path).replace(/\.(md|markdown|txt)$/i, '').replace(/_/g, ' ') || 'Untitled';
|
|
}
|
|
|
|
function quoted(value) {
|
|
const clean = String(value || '').trim();
|
|
return clean ? `"${clean}"` : '';
|
|
}
|
|
|
|
function entityName(item) {
|
|
const payload = item?.payload && typeof item.payload === 'object' ? item.payload : {};
|
|
const path = payload.path || payload.notePath || item?.path || item?.relativePath || looksLikePath(item?.summary);
|
|
const title = String(item?.title || payload.title || '').trim();
|
|
if (path && (!title || /^(saved note|file opened|file changed|activity event)$/i.test(title))) return titleFromPath(path);
|
|
return title || titleFromPath(path) || String(item?.summary || item?.activityId || 'item');
|
|
}
|
|
|
|
function looksLikePath(value) {
|
|
const text = String(value || '').trim();
|
|
if (!text) return '';
|
|
return text.includes('/') || /\.[a-z0-9]{1,8}$/i.test(text) ? text : '';
|
|
}
|
|
|
|
function captureTitle(capture) {
|
|
return capture?.title || capture?.fileName || capture?.url || capture?.captureId || 'Untitled capture';
|
|
}
|
|
|
|
function journalTitle(entry) {
|
|
return entry?.title || entry?.summary || entry?.date || entry?.entryId || 'Journal entry';
|
|
}
|
|
|
|
function itemTimeLabel(item) {
|
|
const value = timeValue(item);
|
|
if (!value) return 'No timestamp';
|
|
return relativeTime(value);
|
|
}
|
|
|
|
function absoluteTime(value) {
|
|
const ms = timeMs({ occurredAt: value });
|
|
if (!ms) return '';
|
|
return new Date(ms).toLocaleString(undefined, {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
function relativeTime(value) {
|
|
const ms = timeMs({ occurredAt: value });
|
|
if (!ms) return 'No timestamp';
|
|
const diff = Date.now() - ms;
|
|
if (diff < 0) return absoluteTime(value);
|
|
const minute = 60 * 1000;
|
|
const hour = 60 * minute;
|
|
const day = 24 * hour;
|
|
if (diff < minute) return 'Just now';
|
|
if (diff < hour) return `${Math.floor(diff / minute)} min ago`;
|
|
if (diff < day) return `${Math.floor(diff / hour)}h ago`;
|
|
if (diff < 2 * day) return 'Yesterday';
|
|
if (diff < 7 * day) return `${Math.floor(diff / day)} days ago`;
|
|
const date = new Date(ms);
|
|
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: date.getFullYear() === new Date().getFullYear() ? undefined : 'numeric' });
|
|
}
|
|
|
|
function activityCategory(item) {
|
|
const type = String(item?.type || '').toLowerCase();
|
|
if (type.startsWith('note.')) return 'notes';
|
|
if (type.startsWith('file.')) return 'files';
|
|
if (type.startsWith('browser.capture')) return 'captures';
|
|
if (type.startsWith('journal.') || type.startsWith('worklog.')) return 'journal';
|
|
return 'activity';
|
|
}
|
|
|
|
function activityTitle(item) {
|
|
const type = String(item?.type || '').toLowerCase();
|
|
const name = entityName(item);
|
|
if (type === 'note.saved' || type === 'note.edited') return `Edited note ${quoted(name)}`;
|
|
if (type === 'note.opened') return `Opened note ${quoted(name)}`;
|
|
if (type === 'note.created') return `Created note ${quoted(name)}`;
|
|
if (type === 'file.opened') return `Opened file ${quoted(name)}`;
|
|
if (type === 'file.changed') return `Changed file ${quoted(name)}`;
|
|
if (type === 'file.created') return `Created file ${quoted(name)}`;
|
|
if (type === 'file.deleted' || type === 'file.trashed') return `Removed file ${quoted(name)}`;
|
|
if (type === 'browser.capture.page') return `Captured page ${quoted(name)}`;
|
|
if (type === 'browser.capture.selection') return `Captured selection ${quoted(name)}`;
|
|
if (type === 'browser.capture.link') return `Captured link ${quoted(name)}`;
|
|
if (type === 'browser.capture.file') return `Captured file ${quoted(name)}`;
|
|
if (type === 'browser.capture.converted') return `Converted capture ${quoted(name)}`;
|
|
if (type === 'journal.entry.added' || type === 'worklog.entry.added') return `Added journal entry ${quoted(name)}`;
|
|
if (type === 'action.started') return 'Work session detected';
|
|
if (type === 'workspace.opened') return 'Workspace opened';
|
|
return item?.title || item?.summary || 'Workspace activity';
|
|
}
|
|
|
|
function actionForCategory(category) {
|
|
if (category === 'notes') return { kind: 'notes', label: 'Open Notes' };
|
|
if (category === 'files') return { kind: 'files', label: 'Open Files' };
|
|
if (category === 'captures') return { kind: 'browser-inbox', label: 'Review Inbox' };
|
|
if (category === 'journal') return { kind: 'journal', label: 'Open Journal' };
|
|
return { kind: 'activity', label: 'View Activity' };
|
|
}
|
|
|
|
function buildRecentChanges(events, captureRows, journalRows) {
|
|
const activityItems = sortByTime(events)
|
|
.filter(item => !LOW_VALUE_RECENT_TYPES.has(String(item?.type || '').toLowerCase()))
|
|
.map(item => {
|
|
const category = activityCategory(item);
|
|
const action = actionForCategory(category);
|
|
return {
|
|
id: item.activityId || `${item.type}:${timeValue(item)}`,
|
|
category,
|
|
title: activityTitle(item),
|
|
meta: `${itemTimeLabel(item)}${item.sourcePluginId ? ' · ' + item.sourcePluginId.replace('verstak.', '') : ''}`,
|
|
time: timeValue(item),
|
|
absolute: absoluteTime(timeValue(item)),
|
|
actionKind: action.kind,
|
|
actionLabel: action.label,
|
|
};
|
|
});
|
|
const captureItems = captureRows.map(item => ({
|
|
id: item.captureId || `capture:${timeValue(item)}`,
|
|
category: 'captures',
|
|
title: `Captured ${item.kind || 'item'} ${quoted(captureTitle(item))}`,
|
|
meta: `${itemTimeLabel(item)} · ${item.domain || item.url || 'Browser capture'}`,
|
|
time: timeValue(item),
|
|
absolute: absoluteTime(timeValue(item)),
|
|
actionKind: 'browser-inbox',
|
|
actionLabel: 'Review Inbox',
|
|
}));
|
|
const journalItems = journalRows.map(item => ({
|
|
id: item.entryId || `journal:${timeValue(item)}`,
|
|
category: 'journal',
|
|
title: `Added journal entry ${quoted(journalTitle(item))}`,
|
|
meta: `${itemTimeLabel(item)}${item.minutes ? ' · ' + item.minutes + ' min' : ''}`,
|
|
time: timeValue(item),
|
|
absolute: absoluteTime(timeValue(item)),
|
|
actionKind: 'journal',
|
|
actionLabel: 'Open Journal',
|
|
}));
|
|
return sortByTime([...activityItems, ...captureItems, ...journalItems]).slice(0, 12);
|
|
}
|
|
|
|
function isResumeEvent(item) {
|
|
const type = String(item?.type || '').toLowerCase();
|
|
if (LOW_VALUE_RESUME_TYPES.has(type)) return false;
|
|
return type === 'file.opened' ||
|
|
type === 'note.opened' ||
|
|
type === 'note.saved' ||
|
|
type === 'note.edited' ||
|
|
type === 'file.changed' ||
|
|
type === 'file.created' ||
|
|
type === 'browser.capture.converted';
|
|
}
|
|
|
|
function continueItemFromActivity(item) {
|
|
const category = activityCategory(item);
|
|
const action = actionForCategory(category);
|
|
return {
|
|
id: item.activityId || `${item.type}:${timeValue(item)}`,
|
|
category,
|
|
title: activityTitle(item),
|
|
meta: itemTimeLabel(item),
|
|
time: timeValue(item),
|
|
absolute: absoluteTime(timeValue(item)),
|
|
actionKind: action.kind,
|
|
actionLabel: action.label,
|
|
};
|
|
}
|
|
|
|
function buildContinueItems(events, captureRows, journalRows, todoRows) {
|
|
const todoCandidates = [...todoRows].sort((a, b) => todoDateMs(todoTimeValue(a)) - todoDateMs(todoTimeValue(b))).map(item => ({
|
|
id: item.id || `todo:${todoTitle(item)}`,
|
|
category: 'todo',
|
|
title: `Todo ${quoted(todoTitle(item))}`,
|
|
meta: `${todoAttentionState(item)}${item.dueAt ? ` · Due ${item.dueAt}` : ''}`,
|
|
time: todoTimeValue(item),
|
|
absolute: absoluteTime(todoTimeValue(item)),
|
|
actionKind: 'todo',
|
|
actionLabel: 'Open Todos',
|
|
}));
|
|
const captureCandidates = sortByTime(captureRows).map(item => ({
|
|
id: item.captureId || `capture:${timeValue(item)}`,
|
|
category: 'captures',
|
|
title: `Review capture ${quoted(captureTitle(item))}`,
|
|
meta: `${itemTimeLabel(item)} · ${item.domain || item.kind || 'Browser capture'}`,
|
|
time: timeValue(item),
|
|
absolute: absoluteTime(timeValue(item)),
|
|
actionKind: 'browser-inbox',
|
|
actionLabel: 'Review Inbox',
|
|
}));
|
|
const noteCandidates = sortByTime(events)
|
|
.filter(item => isResumeEvent(item) && activityCategory(item) === 'notes')
|
|
.map(continueItemFromActivity);
|
|
const fileCandidates = sortByTime(events)
|
|
.filter(item => ['file.changed', 'file.created'].includes(String(item?.type || '').toLowerCase()))
|
|
.map(continueItemFromActivity);
|
|
const journalCandidates = sortByTime(journalRows).map(item => ({
|
|
id: item.entryId || `journal:${timeValue(item)}`,
|
|
category: 'journal',
|
|
title: `Continue journal entry ${quoted(journalTitle(item))}`,
|
|
meta: itemTimeLabel(item),
|
|
time: timeValue(item),
|
|
absolute: absoluteTime(timeValue(item)),
|
|
actionKind: 'journal',
|
|
actionLabel: 'Open Journal',
|
|
}));
|
|
return [...todoCandidates, ...captureCandidates, ...noteCandidates, ...fileCandidates, ...journalCandidates].slice(0, 4);
|
|
}
|
|
|
|
function buildNeedsAttention(captureRows, candidates, todoRows) {
|
|
const captureItems = sortByTime(captureRows).slice(0, 4).map(item => ({
|
|
id: item.captureId || `capture:${timeValue(item)}`,
|
|
title: captureTitle(item),
|
|
meta: `${item.kind || 'capture'} · ${itemTimeLabel(item)}`,
|
|
actionKind: 'browser-inbox',
|
|
actionLabel: 'Review Inbox',
|
|
}));
|
|
const candidateItems = sortByTime(candidates).slice(0, 4).map(item => ({
|
|
id: item.candidateId || `work-session:${timeValue(item)}`,
|
|
title: 'Possible journal entry',
|
|
meta: `Workspace: ${item.workspaceRootPath || workspaceRootPath || 'Unknown'} · ${item.estimatedMinutes || 0} min · ${item.activityCount || (item.activityIds || []).length || 0} activities`,
|
|
actionKind: 'journal',
|
|
actionLabel: 'Review candidate',
|
|
toolRequest: { type: 'work-session-candidate', candidate: item },
|
|
}));
|
|
const todoItems = [...todoRows].sort((a, b) => todoDateMs(todoTimeValue(a)) - todoDateMs(todoTimeValue(b))).slice(0, 4).map(item => ({
|
|
id: item.id || `todo:${todoTitle(item)}`,
|
|
title: todoTitle(item),
|
|
meta: `${todoAttentionState(item)}${item.dueAt ? ` · Due ${item.dueAt}` : ''}`,
|
|
actionKind: 'todo',
|
|
actionLabel: 'Open Todos',
|
|
}));
|
|
return [...todoItems, ...captureItems, ...candidateItems].slice(0, 6);
|
|
}
|
|
|
|
function countCategory(items, category) {
|
|
return items.filter(item => item.category === category).length;
|
|
}
|
|
|
|
function countLabel(count, singular) {
|
|
return `${count} ${singular}${count === 1 ? '' : 's'}`;
|
|
}
|
|
|
|
function totalAndRecentLabel(total, recent) {
|
|
return `${total} total · ${countLabel(recent, 'recent change')}`;
|
|
}
|
|
|
|
function captureReviewLabel(count) {
|
|
return `${count} capture${count === 1 ? '' : 's'} to review`;
|
|
}
|
|
|
|
function journalEntryLabel(count) {
|
|
return `${count} journal entr${count === 1 ? 'y' : 'ies'}`;
|
|
}
|
|
|
|
function lastActiveDate(items, captureRows, journalRows, todoRows) {
|
|
const source = sortByTime([...items, ...captureRows, ...journalRows, ...todoRows])[0];
|
|
return timeValue(source);
|
|
}
|
|
|
|
async function listFiles(pluginId, relativeDir) {
|
|
if (!App.ListVaultFiles) return [];
|
|
try {
|
|
return decodeTuple(await App.ListVaultFiles(pluginId, relativeDir), []);
|
|
} catch (_) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function loadWorkspaceResources() {
|
|
const workspace = String(workspaceRootPath || '').trim();
|
|
if (!workspace) return { keyResources: [], totalNotes: 0 };
|
|
const [rootEntries, notesEntries] = await Promise.all([
|
|
listFiles('verstak.files', workspace),
|
|
listFiles('verstak.notes', `${workspace}/Notes`),
|
|
]);
|
|
const noteFiles = notesEntries.filter(item => item?.type === 'file');
|
|
const overview = [...notesEntries, ...rootEntries].find(item => /(^|\/)overview\.md$/i.test(String(item.relativePath || item.name || '')));
|
|
return {
|
|
totalNotes: noteFiles.length,
|
|
keyResources: overview ? [{
|
|
id: overview.relativePath || overview.name,
|
|
title: overview.name || fileName(overview.relativePath) || 'Overview.md',
|
|
meta: overview.relativePath || 'Workspace overview note',
|
|
actionKind: hasNotes ? 'notes' : 'files',
|
|
actionLabel: hasNotes ? 'Open Notes' : 'Open Files',
|
|
}] : [],
|
|
};
|
|
}
|
|
|
|
async function loadOverview() {
|
|
const workspaceAtStart = String(workspaceRootPath || '').trim();
|
|
loadedWorkspaceRoot = workspaceAtStart;
|
|
loading = true;
|
|
const [browserSettings, activitySettings, journalSettings, todoSettings, resources] = await Promise.all([
|
|
readPluginSettings('verstak.browser-inbox'),
|
|
readPluginSettings('verstak.activity'),
|
|
readPluginSettings('verstak.journal'),
|
|
readPluginSettings('verstak.todo'),
|
|
loadWorkspaceResources(),
|
|
]);
|
|
if (workspaceAtStart !== String(workspaceRootPath || '').trim()) return;
|
|
|
|
captures = browserCaptureRowsForWorkspace(browserSettings);
|
|
activityEvents = rowsFor(activitySettings, [
|
|
workspaceKey('events:workspace:'),
|
|
'events:global',
|
|
'events',
|
|
]);
|
|
journalEntries = rowsFor(journalSettings, [
|
|
workspaceKey('worklog:workspace:'),
|
|
'worklog',
|
|
]);
|
|
todos = todoRowsForWorkspace(todoSettings);
|
|
workSessionCandidates = rowsFor(activitySettings, [
|
|
workspaceKey('work-session-candidates:workspace:'),
|
|
]);
|
|
keyResources = resources.keyResources;
|
|
totalNotes = resources.totalNotes;
|
|
loading = false;
|
|
}
|
|
|
|
function openTool(kind, toolRequest = null) {
|
|
dispatch('openTool', { kind, toolRequest });
|
|
}
|
|
</script>
|
|
|
|
<div class="today-root overview-root" aria-label="Overview" data-overview-root>
|
|
<div class="today-header overview-header">
|
|
<div>
|
|
<h2>Overview</h2>
|
|
<p title={lastActive ? absoluteTime(lastActive) : ''}>
|
|
{#if loading}
|
|
Loading workspace context...
|
|
{:else if lastActive}
|
|
Last active {relativeTime(lastActive)}
|
|
{:else}
|
|
No recent workspace activity
|
|
{/if}
|
|
</p>
|
|
</div>
|
|
<button type="button" data-overview-action="refresh" on:click={loadOverview}>Refresh</button>
|
|
</div>
|
|
|
|
<div class="today-summary overview-summary" aria-label="Workspace overview summary">
|
|
{#each summaryItems as item}
|
|
<button
|
|
type="button"
|
|
class="today-summary-item overview-summary-item"
|
|
class:summary-attention={item.key === 'attention'}
|
|
data-overview-summary={item.key}
|
|
data-overview-action={item.actionKind}
|
|
aria-label={`${item.label}: ${item.actionLabel}`}
|
|
on:click={() => openTool(item.actionKind)}
|
|
>
|
|
<strong>{loading ? '...' : item.count}</strong>
|
|
<span>{item.label}</span>
|
|
<small>{loading ? 'Loading...' : item.detail}</small>
|
|
<em>{item.actionLabel}</em>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
|
|
<div class="overview-layout">
|
|
<main class="overview-main">
|
|
<section class="today-resume overview-continue" data-overview-section="continue">
|
|
<div class="today-resume-copy overview-continue-copy">
|
|
<span>Continue working</span>
|
|
<h3>Pick up the next useful item in this workspace.</h3>
|
|
</div>
|
|
{#if loading}
|
|
<p class="today-empty compact">Loading workspace signals...</p>
|
|
{:else if continueItems.length}
|
|
<div class="overview-continue-list">
|
|
{#each continueItems as item}
|
|
<button
|
|
type="button"
|
|
class="overview-continue-item"
|
|
data-overview-continue-item={item.category}
|
|
data-overview-action={item.actionKind}
|
|
on:click={() => openTool(item.actionKind)}
|
|
>
|
|
<span class="overview-continue-item-copy">
|
|
<strong title={item.title}>{item.title}</strong>
|
|
<span title={item.absolute}>{item.meta}</span>
|
|
</span>
|
|
<span class="overview-continue-item-action">{item.actionLabel}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<div class="overview-continue-empty">
|
|
<strong>No clear resume point yet</strong>
|
|
<p>Recent notes, files, captures, and journal entries will appear here.</p>
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
<section class="today-panel overview-panel overview-recent" data-overview-section="recent">
|
|
<div class="today-panel-head overview-panel-head">
|
|
<div>
|
|
<h3>Recent changes</h3>
|
|
<p>Latest meaningful activity in this workspace.</p>
|
|
</div>
|
|
<div class="overview-filters" aria-label="Recent changes filter">
|
|
{#each FILTERS as filter}
|
|
<button
|
|
type="button"
|
|
class:is-active={activeFilter === filter.key}
|
|
aria-pressed={activeFilter === filter.key}
|
|
data-overview-filter={filter.key}
|
|
on:click={() => activeFilter = filter.key}
|
|
>
|
|
{filter.label}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{#if loading}
|
|
<p class="today-empty">Loading recent changes...</p>
|
|
{:else if filteredRecentChanges.length}
|
|
<div class="today-list overview-list">
|
|
{#each filteredRecentChanges as item}
|
|
<button
|
|
type="button"
|
|
class="today-row overview-change-row"
|
|
data-overview-recent-item={item.category}
|
|
data-overview-action={item.actionKind}
|
|
on:click={() => openTool(item.actionKind)}
|
|
>
|
|
<span class="overview-change-copy">
|
|
<strong title={item.title}>{item.title}</strong>
|
|
<span title={item.absolute}>{item.meta}</span>
|
|
</span>
|
|
<span class="overview-row-action">{item.actionLabel}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<p class="today-empty">No meaningful changes for this filter yet.</p>
|
|
{/if}
|
|
</section>
|
|
</main>
|
|
|
|
<aside class="overview-side">
|
|
{#if needsAttention.length || !loading}
|
|
<section class="today-panel overview-panel" data-overview-section="attention">
|
|
<div class="today-panel-head overview-panel-head">
|
|
<div>
|
|
<h3>Needs attention</h3>
|
|
<p>Pending captures, urgent todos, and possible journal entries.</p>
|
|
</div>
|
|
</div>
|
|
{#if loading}
|
|
<p class="today-empty">Loading pending items...</p>
|
|
{:else if needsAttention.length}
|
|
<div class="today-list overview-list compact">
|
|
{#each needsAttention as item}
|
|
<div class="today-row overview-attention-row">
|
|
<strong title={item.title}>{item.title}</strong>
|
|
<span>{item.meta}</span>
|
|
<button type="button" on:click={() => openTool(item.actionKind, item.toolRequest)}>{item.actionLabel}</button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<p class="today-empty compact">No pending captures, urgent todos, or possible journal entries.</p>
|
|
{/if}
|
|
</section>
|
|
{/if}
|
|
|
|
{#if keyResources.length}
|
|
<section class="today-panel overview-panel secondary" data-overview-section="key-resources">
|
|
<div class="today-panel-head overview-panel-head">
|
|
<h3>Key resources</h3>
|
|
</div>
|
|
<div class="today-list overview-list compact">
|
|
{#each keyResources as item}
|
|
<div class="today-row overview-resource-row">
|
|
<strong title={item.title}>{item.title}</strong>
|
|
<span title={item.meta}>{item.meta}</span>
|
|
<button type="button" on:click={() => openTool(item.actionKind)}>{item.actionLabel}</button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.today-root {
|
|
height: 100%;
|
|
min-height: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: var(--vt-color-background);
|
|
color: var(--vt-color-text-primary);
|
|
overflow: auto;
|
|
}
|
|
|
|
.today-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
padding: 1rem;
|
|
border-bottom: 1px solid var(--vt-color-border);
|
|
background: var(--vt-color-surface-muted);
|
|
}
|
|
|
|
.today-header h2 {
|
|
margin: 0;
|
|
font-size: 1.05rem;
|
|
}
|
|
|
|
.today-header p {
|
|
margin: 0.25rem 0 0;
|
|
color: var(--vt-color-text-muted);
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.today-summary {
|
|
display: grid;
|
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
|
gap: 0.5rem;
|
|
padding: 0.75rem 0.75rem 0;
|
|
}
|
|
|
|
.today-summary-item {
|
|
min-width: 0;
|
|
display: grid;
|
|
gap: 0.16rem;
|
|
padding: 0.65rem 0.75rem;
|
|
border: 1px solid var(--vt-color-border);
|
|
border-radius: var(--vt-radius-lg);
|
|
background: var(--vt-color-surface);
|
|
color: inherit;
|
|
cursor: pointer;
|
|
font: inherit;
|
|
text-align: left;
|
|
}
|
|
|
|
.today-summary-item:hover,
|
|
.today-summary-item:focus-visible {
|
|
border-color: var(--vt-color-accent);
|
|
background: var(--vt-color-surface-hover);
|
|
outline: none;
|
|
}
|
|
|
|
.today-summary-item strong {
|
|
color: var(--vt-color-text-primary);
|
|
font-size: 1rem;
|
|
line-height: 1;
|
|
}
|
|
|
|
.today-summary-item span,
|
|
.today-summary-item small,
|
|
.today-summary-item em {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
color: var(--vt-color-text-muted);
|
|
font-size: 0.74rem;
|
|
}
|
|
|
|
.today-summary-item span {
|
|
color: var(--vt-color-text-secondary);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.today-summary-item em {
|
|
color: var(--vt-color-accent);
|
|
font-size: 0.7rem;
|
|
font-style: normal;
|
|
}
|
|
|
|
.today-summary-item.summary-attention {
|
|
border-color: rgba(255, 200, 87, 0.5);
|
|
background: var(--vt-color-warning-muted);
|
|
}
|
|
|
|
.today-summary-item.summary-attention em {
|
|
color: var(--vt-color-warning);
|
|
}
|
|
|
|
.overview-layout {
|
|
min-height: 0;
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) minmax(17rem, 23rem);
|
|
gap: 0.75rem;
|
|
padding: 0.75rem;
|
|
}
|
|
|
|
.overview-main,
|
|
.overview-side {
|
|
min-width: 0;
|
|
display: grid;
|
|
align-content: start;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.today-resume {
|
|
display: grid;
|
|
gap: 0.75rem;
|
|
padding: 0.9rem 1rem;
|
|
border: 1px solid rgba(78, 204, 163, 0.24);
|
|
border-radius: var(--vt-radius-lg);
|
|
background: var(--vt-color-accent-muted);
|
|
}
|
|
|
|
.today-resume-copy {
|
|
min-width: 0;
|
|
display: grid;
|
|
gap: 0.22rem;
|
|
}
|
|
|
|
.today-resume-copy span {
|
|
color: var(--vt-color-accent);
|
|
font-size: 0.72rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.today-resume-copy h3 {
|
|
margin: 0;
|
|
color: var(--vt-color-text-primary);
|
|
font-size: 0.98rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.today-panel {
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
border: 1px solid var(--vt-color-border);
|
|
border-radius: var(--vt-radius-lg);
|
|
background: var(--vt-color-surface);
|
|
}
|
|
|
|
.overview-recent {
|
|
min-height: 24rem;
|
|
}
|
|
|
|
.overview-panel.secondary {
|
|
background: var(--vt-color-surface-muted);
|
|
}
|
|
|
|
[data-overview-section='attention'] {
|
|
border-color: rgba(255, 200, 87, 0.45);
|
|
background: var(--vt-color-warning-muted);
|
|
}
|
|
|
|
.today-panel-head {
|
|
min-height: 2.8rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.75rem;
|
|
padding: 0.65rem 0.75rem;
|
|
border-bottom: 1px solid var(--vt-color-border);
|
|
}
|
|
|
|
.today-panel h3 {
|
|
margin: 0;
|
|
color: var(--vt-color-text-primary);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.today-panel-head p {
|
|
margin: 0.2rem 0 0;
|
|
color: var(--vt-color-text-muted);
|
|
font-size: 0.74rem;
|
|
}
|
|
|
|
.overview-filters {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
padding: 0.2rem;
|
|
border: 1px solid var(--vt-color-border);
|
|
border-radius: var(--vt-radius-md);
|
|
background: var(--vt-color-background);
|
|
}
|
|
|
|
.overview-filters button,
|
|
.today-panel-head button,
|
|
.today-header button,
|
|
.overview-continue-item,
|
|
.overview-list button {
|
|
min-height: 1.85rem;
|
|
padding: 0.3rem 0.65rem;
|
|
border: 1px solid var(--vt-color-border-strong);
|
|
border-radius: var(--vt-radius-md);
|
|
background: var(--vt-color-surface-hover);
|
|
color: var(--vt-color-text-secondary);
|
|
font-size: 0.76rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.overview-filters button {
|
|
min-height: 1.55rem;
|
|
padding: 0.16rem 0.5rem;
|
|
border-color: transparent;
|
|
background: transparent;
|
|
}
|
|
|
|
.overview-filters button.is-active,
|
|
.overview-filters button:hover,
|
|
.today-panel-head button:hover,
|
|
.today-header button:hover,
|
|
.overview-continue-item:hover,
|
|
.overview-list button:hover {
|
|
border-color: var(--vt-color-accent);
|
|
color: var(--vt-color-text-primary);
|
|
}
|
|
|
|
.overview-filters button.is-active {
|
|
background: var(--vt-color-accent-muted);
|
|
color: var(--vt-color-accent);
|
|
}
|
|
|
|
.today-empty {
|
|
flex: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin: 0;
|
|
padding: 1rem;
|
|
color: var(--vt-color-text-muted);
|
|
font-size: 0.82rem;
|
|
line-height: 1.45;
|
|
text-align: center;
|
|
}
|
|
|
|
.today-empty.compact {
|
|
min-height: 5rem;
|
|
}
|
|
|
|
.today-list {
|
|
display: grid;
|
|
gap: 0.45rem;
|
|
padding: 0.65rem;
|
|
}
|
|
|
|
.today-list.compact {
|
|
gap: 0.4rem;
|
|
}
|
|
|
|
.today-row {
|
|
min-width: 0;
|
|
display: grid;
|
|
gap: 0.2rem;
|
|
padding: 0.55rem;
|
|
border: 1px solid var(--vt-color-border);
|
|
border-radius: var(--vt-radius-md);
|
|
background: var(--vt-color-surface-muted);
|
|
color: inherit;
|
|
font: inherit;
|
|
text-align: left;
|
|
}
|
|
|
|
.overview-change-row {
|
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
width: 100%;
|
|
padding: 0.55rem 0.75rem;
|
|
border: 0;
|
|
border-bottom: 1px solid var(--vt-color-border);
|
|
border-radius: 0;
|
|
background: transparent;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.overview-change-row:hover,
|
|
.overview-change-row:focus-visible {
|
|
background: var(--vt-color-surface-hover);
|
|
outline: none;
|
|
}
|
|
|
|
.overview-change-copy,
|
|
.overview-continue-item-copy {
|
|
min-width: 0;
|
|
display: grid;
|
|
gap: 0.2rem;
|
|
}
|
|
|
|
.overview-row-action,
|
|
.overview-continue-item-action {
|
|
color: var(--vt-color-text-muted);
|
|
font-size: 0.72rem;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.overview-recent .overview-list {
|
|
gap: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
.overview-recent .overview-change-row:last-child {
|
|
border-bottom: 0;
|
|
}
|
|
|
|
.overview-attention-row,
|
|
.overview-resource-row {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
|
|
.today-row strong {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
color: var(--vt-color-text-primary);
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.today-row span {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
color: var(--vt-color-text-muted);
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.overview-continue-list {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.overview-continue-item {
|
|
min-width: 0;
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
text-align: left;
|
|
}
|
|
|
|
.overview-continue-item strong,
|
|
.overview-continue-item-copy span {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.overview-continue-item strong {
|
|
color: var(--vt-color-text-primary);
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.overview-continue-item-copy span {
|
|
color: var(--vt-color-text-muted);
|
|
font-size: 0.72rem;
|
|
}
|
|
|
|
.overview-continue-empty {
|
|
display: grid;
|
|
gap: 0.22rem;
|
|
color: var(--vt-color-text-secondary);
|
|
}
|
|
|
|
.overview-continue-empty p {
|
|
margin: 0;
|
|
color: var(--vt-color-text-muted);
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
@media (max-width: 1120px) {
|
|
.today-summary {
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
}
|
|
}
|
|
|
|
@media (max-width: 980px) {
|
|
.overview-layout {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.today-panel-head {
|
|
align-items: stretch;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.overview-filters {
|
|
overflow-x: auto;
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
.overview-continue-list {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.overview-continue-item {
|
|
width: 100%;
|
|
}
|
|
|
|
.overview-change-row {
|
|
grid-template-columns: 1fr;
|
|
align-items: stretch;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 620px) {
|
|
.today-summary {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
|
|
.overview-change-row {
|
|
grid-template-columns: 1fr;
|
|
gap: 0.3rem;
|
|
}
|
|
}
|
|
</style>
|