// Main App — top shell, state management, real storage load/save flow. const { useState: useStateMain, useEffect: useEffectMain, useMemo: useMemoMain, useRef: useRefMain } = React; const LS_KEY = "personal-backlog-state-v1"; function loadLocalState() { try { const raw = localStorage.getItem(LS_KEY); if (!raw) return null; return JSON.parse(raw); } catch { return null; } } // Only UI state (which rows are expanded) is persisted locally. // Backlog data always lives in backlog.md — never in localStorage. function saveLocalState(expandedMap) { try { localStorage.setItem(LS_KEY, JSON.stringify({ expandedMap })); } catch { /* quota / private mode */ } } // Empty starting state — used when there is no saved data and no seed data loaded. function buildEmptyData() { const days = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'].map(day => ({ day, count: 0 })); return { entries: [], history: [], meta: { saved: null, checksum: '—', entryCount: 0, historyCount: 0 }, health: { integrityOk: true, lastSave: null, lastBackup: null, masterSize: 0, backupDirSize: 0, backupCount: 0, statsSize: 0, historySize: 0, historyOldest: null, mode: 'localStorage only', }, stats: { createdThisWeek: 0, completedThisWeek: 0, avgInProgressDays: null, mostActiveProject: '—', completionByDay: days.map(d => ({ ...d })), createdByDay: days.map(d => ({ ...d })), statusMix: { open: 0, 'in-progress': 0, blocked: 0, postponed: 0, done: 0, cancelled: 0 }, }, backups: [], }; } function App() { const [data, setData] = useStateMain(buildEmptyData); const [storageMode, setStorageMode] = useStateMain('local'); // 'api' | 'direct' | 'local' const [isLoading, setIsLoading] = useStateMain(true); const [view, setView] = useStateMain('backlog'); const [filters, setFilters] = useStateMain({ statuses: [], priorities: [], tags: [], dueRange: null, scope: 'all', text: '' }); const [expandedMap, setExpandedMap] = useStateMain(() => loadLocalState()?.expandedMap ?? {}); const [saveState, setSaveState] = useStateMain({ status: 'idle', lastSaved: data.health?.lastSave || null }); const [toast, setToast] = useStateMain(null); const [showWarning, setShowWarning] = useStateMain(false); const [importExportOpen, setImportExportOpen] = useStateMain(false); const [itemDialog, setItemDialog] = useStateMain(null); const [confirm, setConfirm] = useStateMain(null); const [needsConnect, setNeedsConnect] = useStateMain(false); const [tweaks, setTweak] = useTweaks({ accent_hue: 35, density: 'comfortable', show_ids: false, paper_texture: true, status_style: 'color', sort_mode: 'priority', }); // Refs for async callbacks that need latest state without stale closures. const latestData = useRefMain(data); const latestExpandedMap = useRefMain(expandedMap); const isDirtyRef = useRefMain(false); const saveTimerRef = useRefMain(null); useEffectMain(() => { latestData.current = data; }, [data]); useEffectMain(() => { latestExpandedMap.current = expandedMap; }, [expandedMap]); useEffectMain(() => { document.documentElement.style.setProperty('--accent-hue', tweaks.accent_hue); document.documentElement.dataset.density = tweaks.density; document.documentElement.dataset.showIds = tweaks.show_ids ? 'true' : 'false'; document.documentElement.dataset.paper = tweaks.paper_texture ? 'true' : 'false'; }, [tweaks]); // ---- Storage initialisation (runs once on mount) ---- useEffectMain(() => { let cancelled = false; async function initStorage() { try { const mode = await Storage.detect(); setStorageMode(mode); if (mode === 'local') { setIsLoading(false); return; } if (mode === 'direct') { // Try silent reconnect — works if permission is still active from a previous session. const ok = await Storage.tryAutoConnect(); if (!ok) { // Can't connect without a user gesture — show the connect button instead of blocking. setIsLoading(false); setNeedsConnect(true); return; } } // API mode needs no init — server is already running. if (cancelled) return; await applyStorageData(mode, () => cancelled); } catch (e) { if (cancelled) return; setStorageMode('local'); showToast('Storage init failed: ' + e.message, 'err'); } finally { if (!cancelled) setIsLoading(false); } } async function applyStorageData(mode, isCancelled) { const [raw, backups, sizeInfo] = await Promise.all([ Storage.load(), Storage.listBackups(), Storage.getHealthInfo(), ]); if (isCancelled?.()) return; const parsed = await Parser.parse(raw?.content || ''); const newData = await buildDataFromStorage(parsed, backups, mode || Storage.mode, sizeInfo); if (isCancelled?.()) return; setData(newData); if (!loadLocalState()?.expandedMap) { const em = {}; walkTree(newData.entries, it => { em[it.id] = !it.collapsed; }); setExpandedMap(em); } if (!parsed.checksumOk && parsed.meta) setShowWarning(true); SyncPoller.lastChecksum = parsed.meta?.checksum || ''; SyncPoller.start({ isDirty: () => isDirtyRef.current, onExternalChange: async (kind, freshParsed) => { if (kind === 'warn') { showToast('File changed externally — you have unsaved edits', 'warn'); } else if (kind === 'reload' && freshParsed) { const [nb, ns] = await Promise.all([Storage.listBackups(), Storage.getHealthInfo()]); setData(await buildDataFromStorage(freshParsed, nb, Storage.mode, ns)); showToast('Reloaded from disk'); } }, }); } initStorage(); return () => { cancelled = true; SyncPoller.stop(); }; }, []); // Persist expanded/collapsed row state locally. Data itself lives in backlog.md. useEffectMain(() => { saveLocalState(latestExpandedMap.current); }, [expandedMap]); const showToast = (msg, kind = 'ok') => { setToast({ msg, kind, t: Date.now() }); setTimeout(() => setToast(t => (t && Date.now() - t.t >= 2400) ? null : t), 2500); }; // ---- Connect to backlog.md (user-triggered, called from the connect banner) ---- const handleConnect = async () => { try { await Storage.connect(); const [raw, backups, sizeInfo] = await Promise.all([ Storage.load(), Storage.listBackups(), Storage.getHealthInfo(), ]); const parsed = await Parser.parse(raw?.content || ''); const newData = await buildDataFromStorage(parsed, backups, Storage.mode, sizeInfo); setData(newData); if (!loadLocalState()?.expandedMap) { const em = {}; walkTree(newData.entries, it => { em[it.id] = !it.collapsed; }); setExpandedMap(em); } if (!parsed.checksumOk && parsed.meta) setShowWarning(true); SyncPoller.lastChecksum = parsed.meta?.checksum || ''; SyncPoller.start({ isDirty: () => isDirtyRef.current, onExternalChange: async (kind, freshParsed) => { if (kind === 'warn') { showToast('File changed externally — you have unsaved edits', 'warn'); } else if (kind === 'reload' && freshParsed) { const [nb, ns] = await Promise.all([Storage.listBackups(), Storage.getHealthInfo()]); setData(await buildDataFromStorage(freshParsed, nb, Storage.mode, ns)); showToast('Reloaded from disk'); } }, }); setNeedsConnect(false); } catch (e) { showToast('Connect failed: ' + e.message, 'err'); } }; // ---- Real async save ---- const triggerSave = (label) => { isDirtyRef.current = true; setSaveState(prev => ({ ...prev, status: 'saving' })); if (saveTimerRef.current) clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(async () => { const d = latestData.current; const em = latestExpandedMap.current; try { if (Storage.isConnected()) { const content = await Parser.serialize({ entries: d.entries, history: d.history }); await Storage.save(content); // Keep SyncPoller in sync with our own save to avoid false external-change triggers. const cm = content.match(/checksum:\s*(sha256:[a-f0-9]+)/); if (cm) SyncPoller.lastChecksum = cm[1]; } saveLocalState(em); isDirtyRef.current = false; const now = new Date().toISOString(); setSaveState({ status: 'saved', lastSaved: now }); setData(prev => ({ ...prev, health: { ...prev.health, lastSave: now }, meta: { ...prev.meta, saved: now }, })); if (label) showToast(label); } catch (e) { setSaveState(prev => ({ ...prev, status: 'error' })); showToast('Save failed: ' + e.message, 'err'); } }, 600); }; const mutate = (fn) => { isDirtyRef.current = true; setData(d => { const c = structuredClone(d); fn(c); return c; }); }; // ---- Helpers ---- function findParentList(items, id, parent = null) { for (const it of items) { if (it.id === id) return { list: items, parent }; if (it.children?.length) { const r = findParentList(it.children, id, it); if (r) return r; } } return null; } const recentTags = useMemoMain(() => { const counts = {}; walkTree(data.entries, it => (it.tags || []).forEach(t => counts[t] = (counts[t] || 0) + 1)); return Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([t]) => t); }, [data]); // ---- Mutations ---- const onMutate = { setStatus: (id, status) => { mutate(d => { const it = findItem(d.entries, id); if (!it || it.status === status) return; d.history.unshift({ timestamp: new Date().toISOString(), itemId: id, action: 'status_changed', details: `${it.status} → ${status}` }); const wasDone = it.status === 'done'; it.status = status; if (status !== 'blocked') it.reason = null; if (status === 'done') it.progress = 100; else if (wasDone && (it.progress ?? 0) >= 100) it.progress = 75; }); triggerSave(); }, setPriority: (id, priority) => { mutate(d => { const it = findItem(d.entries, id); if (!it || it.priority === priority) return; d.history.unshift({ timestamp: new Date().toISOString(), itemId: id, action: 'priority_changed', details: `${it.priority} → ${priority}` }); it.priority = priority; }); triggerSave(); }, setProgress: (id, progress) => { mutate(d => { const it = findItem(d.entries, id); if (!it) return; const v = snapProgress(progress); if ((it.progress ?? 0) === v) return; const before = it.progress ?? 0; it.progress = v; d.history.unshift({ timestamp: new Date().toISOString(), itemId: id, action: 'progress_changed', details: `${before}% → ${v}%` }); }); triggerSave(); }, moveWithinPriority: (id, dir) => { mutate(d => { const r = findParentList(d.entries, id); if (!r) return; const i = r.list.findIndex(x => x.id === id); if (i < 0) return; const me = r.list[i]; let j = i + dir; while (j >= 0 && j < r.list.length && r.list[j].priority !== me.priority) j += dir; if (j < 0 || j >= r.list.length) return; [r.list[i], r.list[j]] = [r.list[j], r.list[i]]; d.history.unshift({ timestamp: new Date().toISOString(), itemId: id, action: 'item_reordered', details: `moved ${dir < 0 ? 'up' : 'down'}` }); }); triggerSave(); }, reorder: (draggedId, targetId) => { mutate(d => { const src = findParentList(d.entries, draggedId); const tgt = findParentList(d.entries, targetId); if (!src || !tgt || src.list !== tgt.list) return; const draggedIdx = src.list.findIndex(x => x.id === draggedId); const dragged = src.list[draggedIdx]; src.list.splice(draggedIdx, 1); const newTargetIdx = tgt.list.findIndex(x => x.id === targetId); tgt.list.splice(newTargetIdx, 0, dragged); d.history.unshift({ timestamp: new Date().toISOString(), itemId: draggedId, action: 'item_reordered', details: `dropped before ${targetId}` }); }); triggerSave('Reordered'); }, addChild: (parentId) => setItemDialog({ mode: 'add-child', parentId, initial: null }), addRoot: () => setItemDialog({ mode: 'add', parentId: null, initial: null }), editItem: (id) => { const it = findItem(data.entries, id); if (!it) return; setItemDialog({ mode: 'edit', itemId: id, initial: it }); }, deleteItem: (id) => { const it = findItem(data.entries, id); if (!it) return; const childCount = (() => { let n = 0; walkTree([it], () => n++); return n - 1; })(); setConfirm({ title: 'Delete this item?', message: <>Delete {it.title}{childCount > 0 ? ` and ${childCount} sub-item${childCount > 1 ? 's' : ''}` : ''}?, detail: This will be recorded in the history log., confirmLabel: 'Delete', danger: true, onConfirm: () => { mutate(d => { function remove(list) { const i = list.findIndex(x => x.id === id); if (i >= 0) { list.splice(i, 1); return true; } for (const x of list) if (x.children?.length && remove(x.children)) return true; return false; } remove(d.entries); d.history.unshift({ timestamp: new Date().toISOString(), itemId: id, action: 'item_deleted', details: `final: ${it.status}` }); }); setConfirm(null); triggerSave('Deleted'); }, }); }, }; const submitItemDialog = (vals) => { if (itemDialog.mode === 'edit') { mutate(d => { const x = findItem(d.entries, itemDialog.itemId); if (!x) return; const before = { priority: x.priority, status: x.status }; Object.assign(x, vals); if (before.status !== vals.status) d.history.unshift({ timestamp: new Date().toISOString(), itemId: x.id, action: 'status_changed', details: `${before.status} → ${vals.status}` }); if (before.priority !== vals.priority) d.history.unshift({ timestamp: new Date().toISOString(), itemId: x.id, action: 'priority_changed', details: `${before.priority} → ${vals.priority}` }); }); triggerSave('Saved'); } else { const newId = 'n-' + Math.random().toString(36).slice(2, 8); mutate(d => { const node = { id: newId, level: 1, ...vals, children: [], collapsed: false }; if (itemDialog.mode === 'add-child' && itemDialog.parentId) { const parent = findItem(d.entries, itemDialog.parentId); if (parent) { parent.children = parent.children || []; node.level = (parent.level || 1) + 1; parent.children.push(node); setExpandedMap(m => ({ ...m, [parent.id]: true })); } } else { d.entries.push(node); } d.history.unshift({ timestamp: new Date().toISOString(), itemId: newId, action: 'item_created', details: vals.title }); }); triggerSave('Added'); } setItemDialog(null); }; const setExpanded = (id, val) => setExpandedMap(m => ({ ...m, [id]: val })); const tagsList = useMemoMain(() => allTags(data.entries), [data]); const counts = useMemoMain(() => countByStatus(data.entries), [data]); const filtered = useMemoMain(() => { const f = { ...filters, text: filters.text?.trim() || '' }; const noFilters = !f.statuses?.length && !f.priorities?.length && !f.tags?.length && !f.dueRange && !f.text; let tree = noFilters ? data.entries : filterTree(data.entries, f); tree = structuredClone(tree); if (tweaks.sort_mode === 'priority') { function sortRecur(list) { list.sort((a, b) => PRIORITIES.indexOf(a.priority) - PRIORITIES.indexOf(b.priority)); list.forEach(it => it.children?.length && sortRecur(it.children)); } sortRecur(tree); } if (f.scope === 'top') tree = tree.map(it => ({ ...it, children: [] })); return tree; }, [data, filters, tweaks.sort_mode]); const filteredCount = useMemoMain(() => countAll(filtered), [filtered]); const totalCount = useMemoMain(() => countAll(data.entries), [data]); // ---- Keyboard shortcuts ---- useEffectMain(() => { const onKey = (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; if (e.key === '/' && !e.metaKey) { e.preventDefault(); document.querySelector('.search-input')?.focus(); } if (e.key === 'g') setView('backlog'); if (e.key === 'a') setView('admin'); if (e.key === 'n') onMutate.addRoot(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, []); // ---- Restore from backup ---- const handleRestore = (b) => { setConfirm({ title: 'Restore this backup?', danger: false, message: <>Overwrite backlog.md with the contents of this backup., detail: ( <>
{b.name}
{!b.valid && (
Checksum mismatch on this backup
The file may be partially written or edited externally.
)} ), confirmLabel: 'Restore', onConfirm: async () => { setConfirm(null); try { if (Storage.isConnected()) { const result = await Storage.restoreBackup(b.name); if (!result.ok) throw new Error(result.error || 'Restore failed'); const [raw, backups, sizeInfo] = await Promise.all([Storage.load(), Storage.listBackups(), Storage.getHealthInfo()]); const parsed = await Parser.parse(raw?.content || ''); const newData = await buildDataFromStorage(parsed, backups, storageMode, sizeInfo); setData(newData); SyncPoller.lastChecksum = parsed.meta?.checksum || ''; isDirtyRef.current = false; } showToast(`Restored from ${b.name.slice(0, 28)}…`); } catch (e) { showToast('Restore failed: ' + e.message, 'err'); } }, }); }; // ---- Import entries from parsed content ---- const handleImport = ({ entries, history }) => { mutate(d => { d.entries = entries; if (history?.length) d.history = [...history, ...d.history]; d.history.unshift({ timestamp: new Date().toISOString(), itemId: 'system', action: 'imported', details: `${entries.length} top-level entries` }); }); setImportExportOpen(false); triggerSave('Imported'); }; const saveLabel = saveState.status === 'saving' ? 'Saving…' : saveState.status === 'error' ? 'Save failed' : `Saved · ${fmtTimestamp(saveState.lastSaved)}`; const hasFilters = filters.text || filters.statuses?.length || filters.priorities?.length || filters.tags?.length || filters.dueRange; if (isLoading) { return (
Loading…
Connecting to storage
); } return (
setFilters({ ...filters, text: v })} onForceSave={() => triggerSave('Saved manually')} onOpenImportExport={() => setImportExportOpen(true)} /> {storageMode === 'local' && (
This browser can't access local files directly — changes won't be saved.{' '} Open in Chrome or Edge to save without a server, or run{' '} python3 server/server.py for any browser.
)} {needsConnect && (
No backlog.md connected — grant one-time folder access, then reloads are silent.
)} {showWarning && (
File was edited outside the app — checksum mismatch. The next save will rewrite a correct marker.
)} {view === 'backlog' ? (
Backlog

{hasFilters ? <>{filteredCount} of {totalCount} items : <>{totalCount} items}

Status: {STATUSES.map(s => ( {s.label} ))} · {tweaks.sort_mode === 'manual' ? <>Drag rows to reorder · : <>Auto-sorted by priority · drag/arrows reorder within priority · } / search · n new
{filtered.length === 0 ? (
∅
{hasFilters ? 'No items match these filters.' : 'No items yet. Press n or click "+ New item" to start.'}
) : ( )}
) : ( setView('backlog')} onForceSave={() => triggerSave('Force-saved')} onForceBackup={() => triggerSave('Force-backed up')} onCompact={() => { mutate(d => { if (d.history.length > 200) d.history = d.history.slice(0, 200); }); triggerSave('History compacted'); }} onRestore={handleRestore} onDownloadBackup={(name) => { if (Storage.mode === 'api') { const a = document.createElement('a'); a.href = `/api/backups/${encodeURIComponent(name)}`; a.download = name; a.click(); } else { showToast(`${name.slice(0, 28)}… — download not available in direct mode`); } }} /> )} setItemDialog(null)} onSubmit={submitItemDialog} /> setConfirm(null)} onConfirm={confirm?.onConfirm} /> setImportExportOpen(false)} onImport={handleImport} /> {toast && (
{toast.msg}
)} setTweak('status_style', v)}/> setTweak('sort_mode', v)}/> setTweak('accent_hue', v)} formatValue={v => `${v}°`}/> setTweak('density', v)}/> setTweak('paper_texture', v)}/> setShowWarning(true)}/> showToast('File reloaded from disk')}/> {window.SEED_DATA && ( { const seed = structuredClone(window.SEED_DATA); const defaults = { done: 100, cancelled: 0, 'in-progress': 50, blocked: 25, postponed: 25, open: 0 }; walkTree(seed.entries, it => { if (typeof it.progress !== 'number') it.progress = defaults[it.status] ?? 0; else if (it.status === 'done') it.progress = 100; }); setData(seed); const em = {}; walkTree(seed.entries, it => { em[it.id] = !it.collapsed; }); setExpandedMap(em); showToast('Sample data loaded'); }}/> )}
); } // ----- View chips (saved searches) ----- const VIEW_CHIPS = [ { key: 'all', label: 'All', statuses: [] }, { key: 'open', label: 'Open', statuses: ['open'] }, { key: 'wip', label: 'In progress', statuses: ['in-progress'] }, { key: 'active', label: 'Active', statuses: ['open', 'in-progress', 'blocked', 'postponed'], hint: 'everything not done or cancelled' }, { key: 'blocked', label: 'Blocked', statuses: ['blocked'] }, { key: 'closed', label: 'Closed', statuses: ['done', 'cancelled'], hint: 'done + cancelled' }, ]; function ViewChips({ filters, setFilters }) { const cur = filters.statuses || []; const same = (a, b) => a.length === b.length && a.every(x => b.includes(x)); const activeKey = VIEW_CHIPS.find(v => same(v.statuses, cur))?.key; return (
{VIEW_CHIPS.map(v => ( ))}
); } // ----- Header ----- function Header({ view, setView, saveState, saveLabel, storageMode, searchValue, onSearch, onOpenImportExport }) { const modeIcon = storageMode === 'api' ? '⚡' : storageMode === 'direct' ? '📁' : '💾'; return (
backlog backlog.md {modeIcon}
onSearch(e.target.value)}/> {searchValue && }
{saveLabel}
); } // ----- Import / Export dialog ----- function ImportExportDialog({ open, data, storageMode, onClose, onImport }) { const { useState: useStateD, useEffect: useEffectD, useRef: useRefD } = React; const [tab, setTab] = useStateD('md'); const [copied, setCopied] = useStateD(false); const [mdContent, setMdContent] = useStateD(''); const fileInputRef = useRefD(null); // Async-generate markdown when dialog opens or data changes. useEffectD(() => { if (!open || !data) return; let cancelled = false; Parser.serialize({ entries: data.entries, history: data.history }) .then(md => { if (!cancelled) setMdContent(md); }) .catch(() => {}); return () => { cancelled = true; }; }, [open, data]); const jsonContent = React.useMemo(() => { if (!data) return ''; return JSON.stringify({ entries: data.entries, history: data.history, meta: data.meta }, null, 2); }, [data]); const currentContent = tab === 'md' ? mdContent : jsonContent; const filename = tab === 'md' ? 'backlog.md' : 'backlog_export.json'; const mimeType = tab === 'md' ? 'text/markdown' : 'application/json'; const copyContent = async () => { try { await navigator.clipboard.writeText(currentContent); } catch { const ta = Object.assign(document.createElement('textarea'), { value: currentContent, style: 'position:fixed;opacity:0', }); document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch {} document.body.removeChild(ta); } setCopied(true); setTimeout(() => setCopied(false), 1400); }; const downloadFile = () => { const blob = new Blob([currentContent], { type: mimeType }); const url = URL.createObjectURL(blob); const a = Object.assign(document.createElement('a'), { href: url, download: filename }); a.click(); URL.revokeObjectURL(url); }; const handleFileInput = async (e) => { const file = e.target.files?.[0]; if (!file) return; e.target.value = ''; try { const text = await file.text(); let entries, history; if (file.name.endsWith('.json')) { const obj = JSON.parse(text); entries = obj.entries || []; history = obj.history || []; } else { const parsed = await Parser.parse(text); entries = parsed.entries; history = parsed.history; } onImport({ entries, history }); } catch (err) { alert('Import failed: ' + err.message); } }; return (
{currentContent || '…generating…'}
); } window.App = App;