verstak-desktop/frontend/src/lib/shell/TodaySurface.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>