// Admin / Maintenance page: health, storage stats, backup browser, stats, manual actions. // ---- Range-aware stats engine ----------------------------------------------- // Generates deterministic per-bucket counts based on seed numbers so switching // range feels real (each range animates between fixed shapes, not random noise). const RANGE_OPTIONS = [ { key: "week", label: "This week", buckets: 7, bucketLabel: "d", granularity: "day" }, { key: "month", label: "This month", buckets: 5, bucketLabel: "w", granularity: "week" }, { key: "year", label: "This year", buckets: 12, bucketLabel: "m", granularity: "month" }, { key: "7d", label: "Last 7d", buckets: 7, bucketLabel: "d", granularity: "day" }, { key: "30d", label: "Last 30d", buckets: 30, bucketLabel: "d", granularity: "day" }, { key: "365d", label: "Last 365d", buckets: 12, bucketLabel: "m", granularity: "month" }, ]; // Returns [{start, end}] bucket boundaries matching bucketLabelsFor(rangeKey) function bucketBounds(rangeKey, refDate) { const d = new Date(refDate); if (rangeKey === 'week') { const dow = (d.getDay() + 6) % 7; const mon = new Date(d.getFullYear(), d.getMonth(), d.getDate() - dow); return Array.from({length: 7}, (_, i) => { const s = new Date(mon); s.setDate(mon.getDate() + i); const e = new Date(s); e.setDate(s.getDate() + 1); return { start: s, end: e }; }); } if (rangeKey === '7d') { return Array.from({length: 7}, (_, i) => { const offset = 6 - i; const s = new Date(d.getFullYear(), d.getMonth(), d.getDate() - offset); const e = new Date(s); e.setDate(s.getDate() + 1); return { start: s, end: e }; }); } if (rangeKey === '30d') { return Array.from({length: 30}, (_, i) => { const offset = 29 - i; const s = new Date(d.getFullYear(), d.getMonth(), d.getDate() - offset); const e = new Date(s); e.setDate(s.getDate() + 1); return { start: s, end: e }; }); } if (rangeKey === 'month') { const y = d.getFullYear(), mo = d.getMonth(); return Array.from({length: 5}, (_, i) => ({ start: new Date(y, mo, 1 + i * 7), end: i < 4 ? new Date(y, mo, 1 + (i + 1) * 7) : new Date(y, mo + 1, 1), })); } if (rangeKey === 'year') { const y = d.getFullYear(); return Array.from({length: 12}, (_, i) => ({ start: new Date(y, i, 1), end: new Date(y, i + 1, 1), })); } if (rangeKey === '365d') { return Array.from({length: 12}, (_, i) => { const offset = 11 - i; const y = d.getFullYear(), mo = d.getMonth(); return { start: new Date(y, mo - offset, 1), end: new Date(y, mo - offset + 1, 1), }; }); } return []; } function countInBuckets(history, buckets) { const keys = ['created', 'in-progress', 'blocked', 'postponed', 'done', 'cancelled']; const out = Object.fromEntries(keys.map(k => [k, Array(buckets.length).fill(0)])); for (const h of history) { const ts = new Date(h.timestamp); const idx = buckets.findIndex(b => ts >= b.start && ts < b.end); if (idx < 0) continue; if (h.action === 'item_created') { out.created[idx]++; } else if (h.action === 'status_changed') { const m = h.details.match(/→\s*(\S[\s\S]*?)$/); if (m) { const ns = m[1].trim(); if (ns in out) out[ns][idx]++; } } } return out; } function bucketLabelsFor(rangeKey) { const today = new Date(); const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; const days = ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"]; if (rangeKey === "week") { return days; } if (rangeKey === "7d") { const out = []; for (let i = 6; i >= 0; i--) { const d = new Date(today); d.setDate(today.getDate() - i); out.push(days[(d.getDay() + 6) % 7]); } return out; } if (rangeKey === "30d") { const out = []; for (let i = 29; i >= 0; i--) { const d = new Date(today); d.setDate(today.getDate() - i); out.push(String(d.getDate())); } return out; } if (rangeKey === "month") { return ["W1","W2","W3","W4","W5"]; } if (rangeKey === "year") { return months; } if (rangeKey === "365d") { const out = []; for (let i = 11; i >= 0; i--) { const d = new Date(today); d.setMonth(today.getMonth() - i); out.push(months[d.getMonth()]); } return out; } return []; } // Status keys in the order we want them stacked (bottom → top). // "created" is a synthetic bucket: items that entered the system in that period. const CHART_STATUSES = [ { key: "created", label: "Created", color: "oklch(0.78 0.13 220)" }, // sky cyan-blue { key: "in-progress", label: "In progress", color: "oklch(0.72 0.14 270)" }, // lavender { key: "blocked", label: "Blocked", color: "oklch(0.68 0.20 28)" }, // red — fixed (0° HSL) { key: "postponed", label: "Postponed", color: "oklch(0.83 0.16 90)" }, // bright golden yellow — bisector of red↔green (≈70° HSL) { key: "done", label: "Done", color: "oklch(0.68 0.16 150)" }, // green — fixed (140° HSL) { key: "cancelled", label: "Cancelled", color: "oklch(0.62 0.04 280)" }, // slate ]; function computeRangeStats(rangeKey, dataStats, history) { const opt = RANGE_OPTIONS.find(r => r.key === rangeKey); const labels = bucketLabelsFor(rangeKey); const now = new Date(); // Prior-period reference: shift back by one equivalent period const priorRef = new Date(now); if (rangeKey === 'week' || rangeKey === '7d') priorRef.setDate(priorRef.getDate() - 7); else if (rangeKey === 'month' || rangeKey === '30d') priorRef.setMonth(priorRef.getMonth() - 1); else if (rangeKey === 'year' || rangeKey === '365d') priorRef.setFullYear(priorRef.getFullYear() - 1); const curr = countInBuckets(history, bucketBounds(rangeKey, now)); const prior = countInBuckets(history, bucketBounds(rangeKey, priorRef)); const series = { created: curr.created, 'in-progress': curr['in-progress'], blocked: curr.blocked, postponed: curr.postponed, done: curr.done, cancelled: curr.cancelled, }; const sum = arr => arr.reduce((a, b) => a + b, 0); const totalCreated = sum(curr.created); const totalCompleted = sum(curr.done); const priorCreated = sum(prior.created); const priorCompleted = sum(prior.done); const pct = (c, p) => p === 0 ? (c > 0 ? 100 : 0) : Math.round(((c - p) / p) * 100); const periodWeeks = { week: 1, '7d': 1, month: 4.3, '30d': 4.3, year: 52, '365d': 52 }[rangeKey]; const throughput = +(totalCompleted / periodWeeks).toFixed(1); const priorThroughput = +(priorCompleted / periodWeeks).toFixed(1); const completionRate = totalCreated > 0 ? Math.round((totalCompleted / totalCreated) * 100) : 0; const priorCompletionRate = priorCreated > 0 ? Math.round((priorCompleted / priorCreated) * 100) : 0; const cycle = dataStats.avgInProgressDays ?? 0; return { opt, labels, series, totals: { created: totalCreated, completed: totalCompleted }, kpis: { created: { value: totalCreated, delta: pct(totalCreated, priorCreated) }, completed: { value: totalCompleted, delta: pct(totalCompleted, priorCompleted) }, throughput: { value: throughput, delta: pct(throughput, priorThroughput), unit: '/wk' }, cycle: { value: cycle, delta: 0, unit: 'd', deltaInverse: true }, rate: { value: completionRate, delta: completionRate - priorCompletionRate, unit: '%' }, }, }; } function StatsOverview({ dataStats, mostActiveProject, history }) { const [range, setRange] = useState("week"); const [chartMode, setChartMode] = useState("cumulative"); // cumulative | breakdown const [hiddenStatuses, setHiddenStatuses] = useState(new Set()); const stats = useMemo(() => computeRangeStats(range, dataStats, history), [range, dataStats, history]); const visibleStatuses = CHART_STATUSES.filter(s => !hiddenStatuses.has(s.key)); // Both modes show stacked totals; max is bucket sum across visible series. const maxBar = useMemo(() => { let m = 1; for (let i = 0; i < stats.labels.length; i++) { const s = visibleStatuses.reduce((acc, st) => acc + stats.series[st.key][i], 0); if (s > m) m = s; } return m; }, [stats, visibleStatuses]); const dense = stats.labels.length > 12; const labelStride = dense ? Math.ceil(stats.labels.length / 8) : 1; const toggleStatus = (key) => { setHiddenStatuses(prev => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }; return (

Stats overview

{stats.opt.label.toLowerCase()} · most active in {mostActiveProject}
{RANGE_OPTIONS.map(opt => ( ))}
{chartMode === "breakdown" ? ( CHART_STATUSES.map(s => { const off = hiddenStatuses.has(s.key); return ( ); }) ) : ( Total activity )}
{stats.labels.map((lbl, i) => { const showLabel = i % labelStride === 0; const bucketTotal = visibleStatuses.reduce((acc, st) => acc + stats.series[st.key][i], 0); const tip = chartMode === "breakdown" ? visibleStatuses.map(st => `${st.label} ${stats.series[st.key][i]}`).join(" · ") : `total ${bucketTotal}`; // Pre-compute % heights and bottoms for breakdown so segments stack // without flex rounding errors. Bottom = sum of segments below. let acc = 0; const segs = chartMode === "breakdown" ? visibleStatuses.map(st => { const v = stats.series[st.key][i]; if (v === 0) return null; const heightPct = (v / maxBar) * 100; const bottomPct = (acc / maxBar) * 100; acc += v; return { st, v, heightPct, bottomPct }; }).filter(Boolean) : []; return (
{chartMode === "breakdown" ? (
{segs.map((s, idx) => (
))}
) : (
)}
{showLabel ? lbl : ""} {showLabel && bucketTotal > 0 && ( {bucketTotal} )}
); })}
); } const ModeIconCumulative = () => ( ); const ModeIconBreakdown = () => ( ); function Kpi({ label, value, delta, unit, accent, deltaInverse }) { // For metrics where lower is better (cycle time), invert sign const positive = deltaInverse ? delta < 0 : delta > 0; const negative = deltaInverse ? delta > 0 : delta < 0; const arrow = delta === 0 ? "→" : positive ? "▲" : "▼"; const cls = delta === 0 ? "flat" : positive ? "up" : "down"; const formatted = Math.abs(delta) === 0 ? "0" : `${Math.abs(delta)}${unit === "%" ? "pp" : "%"}`; return (
{label}
{value}{unit && {unit}}
{arrow} {formatted} vs prior
); } const ICON_STYLES = [ { value: 'color', label: 'Color', hint: 'Filled circles with semantic colors' }, { value: 'flat', label: 'Flat', hint: 'Minimal monochrome SVG squares' }, { value: 'emoji', label: 'Emoji', hint: 'Native emoji glyphs' }, { value: 'ascii', label: 'ASCII', hint: 'Plain text glyphs: [ ] [/] [!] …' }, ]; const THEMES = [ { value: 'system', label: 'System', hint: 'Follow OS light / dark setting' }, { value: 'light', label: 'Light', hint: 'Always light' }, { value: 'dark', label: 'Dark', hint: 'Always dark' }, ]; function AppearanceCard({ tweaks, setTweak }) { const current = tweaks?.status_style || 'color'; const theme = tweaks?.theme || 'system'; return (

Appearance

Theme
{THEMES.map(t => ( ))}
Status icons
{ICON_STYLES.map(opt => ( ))}
); } // ---- Projects card (api mode only, todo 8) ---- // Registry manager: rows come straight from the server (authoritative size / // missing flags). Rename/remove reuse the SAME app-level handlers the backlog // sections use, so the ConfirmDialog-with-checkbox and PromptDialog flows have // exactly one implementation. function ProjectsCard({ projects, onRegisterProject, onRenameProject, onRemoveProject }) { const propRows = () => (projects || []).map(p => ({ name: p.name, path: p.path, size: undefined, missing: !!p.missing })); const [rows, setRows] = useState(propRows); const [regPath, setRegPath] = useState(''); const [regName, setRegName] = useState(''); const [registering, setRegistering] = useState(false); // Re-fetch the registry whenever the app-level project set changes // (register/rename/remove/poller reloads); on failure fall back to the // app's list so the card never blanks out. useEffect(() => { let cancelled = false; ApiBackend.listProjects() .then(list => { if (!cancelled && Array.isArray(list)) setRows(list); }) .catch(() => { if (!cancelled) setRows(propRows()); }); return () => { cancelled = true; }; }, [projects]); const submitRegister = (e) => { e.preventDefault(); const path = regPath.trim(); if (!path || registering) return; setRegistering(true); // onRegisterProject returns a promise; the app toasts success/error. Promise.resolve(onRegisterProject(path, regName.trim() || null)) .finally(() => { setRegistering(false); setRegPath(''); setRegName(''); }); }; return (

Projects

{rows.length} registered · rename and remove act on the registry, not the file
{rows.map(r => ( ))}
Name Path Size Status
{r.name} {r.path} {typeof r.size === 'number' ? fmtBytes(r.size) : —} {r.missing ? missing : ✓ ok}
setRegPath(e.target.value)} /> setRegName(e.target.value)} />
); } function AdminPage({ data, onClose, history, tweaks, setTweak, onForceSave, onForceBackup, onCompact, onRestore, onDownloadBackup, archiveData, onArchiveItems, onRestoreItems, onViewItem, storageMode, projects, onRegisterProject, onRenameProject, onRemoveProject }) { const [previewBackup, setPreviewBackup] = useState(null); const counts = countByStatus(data.entries); const total = countAll(data.entries); const maxStat = Math.max(1, ...Object.values(data.stats.statusMix)); return (
Admin · Maintenance

System overview

{/* Health */}

System health

{data.health.integrityOk ? "✓ integrity ok" : "checksum mismatch"}
Last save
{fmtTimestamp(data.health.lastSave)}
Last backup
{fmtTimestamp(data.health.lastBackup)}
Storage mode
{data.health.mode}
{data.health.masterPath && (
File path
{data.health.masterPath}
)}
Checksum
{data.meta.checksum.slice(0, 32)}…
Saved at
{data.meta.saved}
Entries / history
{data.meta.entryCount} · {data.meta.historyCount}
{/* Storage */}

Storage

{fmtBytes(data.health.masterSize + data.health.backupDirSize + data.health.statsSize + data.health.historySize)} total
backlog.md
{fmtBytes(data.health.masterSize)} · {data.meta.entryCount} entries
{data.health.masterPath && (
Path
{data.health.masterPath}
)}
history.jsonl
{fmtBytes(data.health.historySize)} · {data.meta.historyCount.toLocaleString()} events
backups/
{fmtBytes(data.health.backupDirSize)} · {data.health.backupCount} files
{data.health.backupsPath && (
Backups
{data.health.backupsPath}
)}
stats.jsonl
{fmtBytes(data.health.statsSize)} · 90-day rollup
Oldest event
{fmtTimestamp(data.health.historyOldest)}
{/* Projects (api mode only) */} {storageMode === 'api' && ( )} {/* Manual actions */}

Manual actions

{/* Stats overview */} {/* Appearance */} {/* Status mix */}

Status mix

{STATUSES.map(s => { const v = data.stats.statusMix[s.key] || 0; return (
{s.glyph} {s.label} {v}
); })}
{/* Backup browser */}

Backup browser

{data.backups.length} files · keeping ≥7 days, ≥30 days of dailies
{data.backups.map(b => ( ))}
Filename Timestamp Size Entries Integrity
{b.name} {fmtTimestamp(b.timestamp)} {fmtBytes(b.size)} {b.entries ?? —} {b.valid ? ✓ valid : mismatch}
{previewBackup && (
{previewBackup} read-only preview
{`# Backlog



## [P0] 🔥 Personal backlog app
- [x] Markdown parser + integrity marker *(done: 2026-04-29)*
  - [x] Tokenize three sections
  - [x] SHA-256 over entries+history
- [/] Tree renderer with parent-visibility filter *(due: 2026-05-04)*
  - [ ] Expand / collapse animations
  - [ ] Drag handle within priority group *(due: 2026-05-06)*
- [!] File System Access API direct mode *(reason: Need to test on Edge — no machine yet)*
…


| Timestamp | Item ID | Action | Details |
…


`}
)}
{/* Archive manager */} {/* Recent history */}

Audit log

{data.meta.historyCount.toLocaleString()} events · {" "}{fmtBytes(data.health.historySize)} · {" "}since {fmtShortDate(data.health.historyOldest)} · {" "}showing latest 10
{history.slice(0, 10).map((h, i) => ( ))}
Timestamp Item ID Action Details
{h.timestamp} {h.itemId} {h.action.replace(/_/g, " ")} {h.details}
); } // ---- Archive Manager Card ---- // In multi-project api mode the card scopes to ONE project (select at the // top): both lists and every count reflect the selection. Writes already // route per-project in app.jsx (archive/restore group by _projectId), so the // select is a read-side filter — restore of an item from project X rewrites // only X's backlog.md + archive.md. Single-project mode renders exactly as // before (no select). function ArchiveCard({ entries, archiveData, onArchiveItems, onRestoreItems, onViewItem, projects = [] }) { const [selectedIds, setSelectedIds] = useState([]); const [filter, setFilter] = useState('done'); // 'done', 'cancelled', 'all' const [searchTerm, setSearchTerm] = useState(''); const [archiveSearchTerm, setArchiveSearchTerm] = useState(''); const [expandedArchive, setExpandedArchive] = useState(false); const isMulti = projects.length > 1; const [archivePid, setArchivePid] = useState(projects[0]?.id ?? null); // Keep the selection valid across register/rename/remove reloads. useEffect(() => { if (isMulti && !projects.some(p => p.id === archivePid)) { setArchivePid(projects[0]?.id ?? null); } }, [projects]); const pidOf = x => x._projectId ?? projects[0]?.id; const scopedBacklog = isMulti ? entries.filter(e => pidOf(e) === archivePid) : entries; const scopedArchive = isMulti ? (archiveData?.entries || []).filter(e => pidOf(e) === archivePid) : (archiveData?.entries || []); // Find archivable items (done or cancelled) const archivableItems = []; walkTree(scopedBacklog, (item) => { if (filter === 'all' || item.status === filter) { if (item.status === 'done' || item.status === 'cancelled') { archivableItems.push(item); } } }); // Filter by search const filteredItems = archivableItems.filter(it => !searchTerm || it.title.toLowerCase().includes(searchTerm.toLowerCase()) ); // Archive entries const archiveEntries = scopedArchive; const filteredArchiveItems = []; walkTree(archiveEntries, (item) => { if (!archiveSearchTerm || item.title.toLowerCase().includes(archiveSearchTerm.toLowerCase())) { filteredArchiveItems.push(item); } }); const toggleSelect = (id) => { setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]); }; const selectAll = () => { setSelectedIds(filteredItems.map(it => it.id)); }; const handleArchive = () => { if (selectedIds.length > 0) { onArchiveItems(selectedIds); setSelectedIds([]); } }; return (

Archive manager

{isMulti && ( )} {archivableItems.length} archivable · {archiveEntries.length} archived {isMulti ? ` · ${archivePid}` : ''}
{/* Archivable items */}
Archivable items setSearchTerm(e.target.value)} className="archive-search" />
{filteredItems.length === 0 ? (
No archivable items found
) : (
{filteredItems.slice(0, 20).map(it => ( ))} {filteredItems.length > 20 && (
...and {filteredItems.length - 20} more
)}
)}
{/* Archive browser */}
Archived items ({archiveEntries.length}) {expandedArchive && ( setArchiveSearchTerm(e.target.value)} className="archive-search" /> )}
{expandedArchive && ( filteredArchiveItems.length === 0 ? (
{archiveEntries.length === 0 ? 'Archive is empty' : 'No matches found'}
) : (
{filteredArchiveItems.slice(0, 30).map(it => (
onViewItem?.(it)}>{it.title} {it.archived && ( archived {it.archived} )}
))} {filteredArchiveItems.length > 30 && (
...and {filteredArchiveItems.length - 30} more
)}
) )}
); } window.AdminPage = AdminPage; window.ArchiveCard = ArchiveCard; window.ProjectsCard = ProjectsCard;