Initial version

This commit is contained in:
Ilia Sharin
2026-05-05 18:42:06 -04:00
commit a2a3b7b72b
21 changed files with 14258 additions and 0 deletions
+626
View File
@@ -0,0 +1,626 @@
// 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 (
<section className="card span-2 stats-card">
<div className="card-head stats-head">
<div>
<h3>Stats overview</h3>
<span className="card-sub">{stats.opt.label.toLowerCase()} · most active in <strong>{mostActiveProject}</strong></span>
</div>
<div className="range-switch" role="tablist" aria-label="Stats range">
{RANGE_OPTIONS.map(opt => (
<button
key={opt.key}
role="tab"
aria-selected={range === opt.key}
className={`range-btn ${range === opt.key ? "active" : ""}`}
onClick={() => setRange(opt.key)}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="kpi-grid">
<Kpi label="Created" {...stats.kpis.created}/>
<Kpi label="Completed" {...stats.kpis.completed}/>
<Kpi label="Throughput" {...stats.kpis.throughput}/>
<Kpi label="Avg cycle" {...stats.kpis.cycle}/>
<Kpi label="Completion rate" {...stats.kpis.rate}/>
</div>
<div className="chart-head">
<div className="chart-legend" role="group" aria-label="Toggle status series">
{chartMode === "breakdown" ? (
CHART_STATUSES.map(s => {
const off = hiddenStatuses.has(s.key);
return (
<button
key={s.key}
className={`legend-chip ${off ? "off" : ""}`}
onClick={() => toggleStatus(s.key)}
title={off ? `Show ${s.label}` : `Hide ${s.label}`}
>
<span className="lg-swatch" style={{ background: s.color }}/>
{s.label}
</button>
);
})
) : (
<span className="legend-chip static">
<span className="lg-swatch" style={{ background: "var(--accent)" }}/>
Total activity
</span>
)}
</div>
<div className="mode-switch" role="tablist" aria-label="Chart mode">
<button
className={`mode-btn ${chartMode === "cumulative" ? "active" : ""}`}
onClick={() => setChartMode("cumulative")}
aria-selected={chartMode === "cumulative"}
role="tab"
title="Cumulative — single bar per bucket"
>
<ModeIconCumulative/> Cumulative
</button>
<button
className={`mode-btn ${chartMode === "breakdown" ? "active" : ""}`}
onClick={() => setChartMode("breakdown")}
aria-selected={chartMode === "breakdown"}
role="tab"
title="Breakdown — stacked segments per status"
>
<ModeIconBreakdown/> Breakdown
</button>
</div>
</div>
<div className={`stack-bars buckets-${stats.labels.length} mode-${chartMode}`}>
{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 (
<div key={i} className="stack-col" title={`${lbl} — ${tip}`}>
{chartMode === "breakdown" ? (
<div className="stack-stack">
{segs.map((s, idx) => (
<div
key={s.st.key}
className={`stack-seg ${idx === segs.length - 1 ? "top" : ""}`}
style={{
background: s.st.color,
height: `${s.heightPct}%`,
bottom: `${s.bottomPct}%`,
}}
aria-label={`${s.st.label} ${s.v}`}
/>
))}
</div>
) : (
<div className="stack-stack cumulative-stack">
<div
className="stack-seg cumulative top"
style={{ height: `${(bucketTotal / maxBar) * 100}%`, bottom: 0 }}
aria-label={`total ${bucketTotal}`}
/>
</div>
)}
<div className="stack-cap">
{showLabel ? lbl : ""}
{showLabel && bucketTotal > 0 && (
<span className="stack-total">{bucketTotal}</span>
)}
</div>
</div>
);
})}
</div>
</section>
);
}
const ModeIconCumulative = () => (
<svg width="14" height="12" viewBox="0 0 14 12" fill="none" aria-hidden>
<rect x="0.5" y="6" width="3" height="5" fill="currentColor"/>
<rect x="5.5" y="3" width="3" height="8" fill="currentColor"/>
<rect x="10.5" y="5" width="3" height="6" fill="currentColor"/>
</svg>
);
const ModeIconBreakdown = () => (
<svg width="14" height="12" viewBox="0 0 14 12" fill="none" aria-hidden>
<rect x="1" y="6" width="3" height="5" fill="currentColor" opacity="0.4"/>
<rect x="1" y="2" width="3" height="4" fill="currentColor"/>
<rect x="5.5" y="4" width="3" height="7" fill="currentColor" opacity="0.4"/>
<rect x="5.5" y="1" width="3" height="3" fill="currentColor"/>
<rect x="10" y="7" width="3" height="4" fill="currentColor" opacity="0.4"/>
<rect x="10" y="3" width="3" height="4" fill="currentColor"/>
</svg>
);
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 (
<div className={`kpi ${accent ? "kpi-accent" : ""}`}>
<div className="kpi-label">{label}</div>
<div className="kpi-value">
{value}{unit && <span className="kpi-unit">{unit}</span>}
</div>
<div className={`kpi-delta ${cls}`}>
<span className="kpi-arrow">{arrow}</span> {formatted} <span className="kpi-vs">vs prior</span>
</div>
</div>
);
}
function AdminPage({ data, onClose, history, onForceSave, onForceBackup, onCompact, onRestore, onDownloadBackup }) {
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 (
<div className="admin">
<div className="admin-head">
<div>
<div className="eyebrow">Admin · Maintenance</div>
<h2>System overview</h2>
</div>
<button className="btn-secondary" onClick={onClose}>← Back to backlog</button>
</div>
<div className="admin-grid">
{/* Health */}
<section className="card span-2">
<div className="card-head">
<h3>System health</h3>
<span className={`pill ${data.health.integrityOk ? "ok" : "warn"}`}>
{data.health.integrityOk ? "✓ integrity ok" : "checksum mismatch"}
</span>
</div>
<dl className="kv">
<div><dt>Last save</dt><dd>{fmtTimestamp(data.health.lastSave)}</dd></div>
<div><dt>Last backup</dt><dd>{fmtTimestamp(data.health.lastBackup)}</dd></div>
<div><dt>Storage mode</dt><dd>{data.health.mode}</dd></div>
{data.health.masterPath && (
<div><dt>File path</dt><dd className="mono small">{data.health.masterPath}</dd></div>
)}
<div><dt>Checksum</dt><dd className="mono small">{data.meta.checksum.slice(0, 32)}…</dd></div>
<div><dt>Saved at</dt><dd className="mono small">{data.meta.saved}</dd></div>
<div><dt>Entries / history</dt><dd>{data.meta.entryCount} · {data.meta.historyCount}</dd></div>
</dl>
</section>
{/* Storage */}
<section className="card">
<div className="card-head">
<h3>Storage</h3>
<span className="card-sub mono">{fmtBytes(data.health.masterSize + data.health.backupDirSize + data.health.statsSize + data.health.historySize)} total</span>
</div>
<dl className="kv">
<div><dt>backlog.md</dt><dd>{fmtBytes(data.health.masterSize)} · {data.meta.entryCount} entries</dd></div>
{data.health.masterPath && (
<div><dt>Path</dt><dd className="mono small">{data.health.masterPath}</dd></div>
)}
<div><dt>history.jsonl</dt><dd>{fmtBytes(data.health.historySize)} · {data.meta.historyCount.toLocaleString()} events</dd></div>
<div><dt>backups/</dt><dd>{fmtBytes(data.health.backupDirSize)} · {data.health.backupCount} files</dd></div>
{data.health.backupsPath && (
<div><dt>Backups</dt><dd className="mono small">{data.health.backupsPath}</dd></div>
)}
<div><dt>stats.jsonl</dt><dd>{fmtBytes(data.health.statsSize)} · 90-day rollup</dd></div>
<div><dt>Oldest event</dt><dd className="mono small">{fmtTimestamp(data.health.historyOldest)}</dd></div>
</dl>
</section>
{/* Manual actions */}
<section className="card">
<div className="card-head"><h3>Manual actions</h3></div>
<div className="action-list">
<button className="action-btn" onClick={onForceSave}>
<Icon name="save" size={14}/>
<span className="action-text">
<span className="action-label">Force save</span>
<span className="action-meta">last save {fmtRelative(data.health.lastSave)}</span>
</span>
</button>
<button className="action-btn" onClick={onForceBackup}>
<Icon name="archive" size={14}/>
<span className="action-text">
<span className="action-label">Force backup</span>
<span className="action-meta">{data.health.backupCount} files · {fmtBytes(data.health.backupDirSize)}</span>
</span>
</button>
<button className="action-btn" onClick={onCompact}>
<Icon name="refresh" size={14}/>
<span className="action-text">
<span className="action-label">Compact history</span>
<span className="action-meta">{data.meta.historyCount.toLocaleString()} events · {fmtBytes(data.health.historySize)} · since {fmtShortDate(data.health.historyOldest)}</span>
</span>
</button>
</div>
</section>
{/* Stats overview */}
<StatsOverview dataStats={data.stats} mostActiveProject={data.stats.mostActiveProject} history={history}/>
{/* Status mix */}
<section className="card">
<div className="card-head"><h3>Status mix</h3></div>
<div className="status-mix">
{STATUSES.map(s => {
const v = data.stats.statusMix[s.key] || 0;
return (
<div key={s.key} className="mix-row">
<span className={`glyph status-${s.key}`}>{s.glyph}</span>
<span className="mix-label">{s.label}</span>
<span className="mix-bar">
<span className="mix-fill" style={{ width: `${(v / maxStat) * 100}%` }} />
</span>
<span className="mix-num">{v}</span>
</div>
);
})}
</div>
</section>
{/* Backup browser */}
<section className="card span-3">
<div className="card-head">
<h3>Backup browser</h3>
<span className="card-sub">{data.backups.length} files · keeping ≥7 days, ≥30 days of dailies</span>
</div>
<table className="backup-table">
<thead>
<tr>
<th>Filename</th>
<th>Timestamp</th>
<th>Size</th>
<th>Entries</th>
<th>Integrity</th>
<th></th>
</tr>
</thead>
<tbody>
{data.backups.map(b => (
<tr key={b.name} className={previewBackup === b.name ? "active" : ""}>
<td className="mono">{b.name}</td>
<td>{fmtTimestamp(b.timestamp)}</td>
<td>{fmtBytes(b.size)}</td>
<td>{b.entries ?? <span className="muted">—</span>}</td>
<td>
{b.valid
? <span className="pill mini ok">✓ valid</span>
: <span className="pill mini warn"><Icon name="warn" size={10}/> mismatch</span>}
</td>
<td className="row-actions-cell">
<button className="row-btn" onClick={() => setPreviewBackup(previewBackup === b.name ? null : b.name)}>
<Icon name="eye" size={12}/> Preview
</button>
<button className="row-btn" onClick={() => onDownloadBackup(b.name)}>
<Icon name="download" size={12}/> Download
</button>
<button className="row-btn primary" onClick={() => onRestore(b)}>
Restore
</button>
</td>
</tr>
))}
</tbody>
</table>
{previewBackup && (
<div className="backup-preview">
<div className="preview-head">
<span className="mono">{previewBackup}</span>
<span className="card-sub">read-only preview</span>
</div>
<pre className="preview-body">{`# Backlog
<!-- SECTION: ENTRIES -->
## [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)*
…
<!-- SECTION: HISTORY -->
| Timestamp | Item ID | Action | Details |
…
<!-- SECTION: INTEGRITY -->
<!-- saved: 2026-04-30T19:44:02Z | checksum: sha256:8a1c3e… | entries: 27 | history: 10 -->`}</pre>
</div>
)}
</section>
{/* Recent history */}
<section className="card span-3">
<div className="card-head">
<h3>Audit log</h3>
<span className="card-sub">
<strong>{data.meta.historyCount.toLocaleString()}</strong> events ·
{" "}{fmtBytes(data.health.historySize)} ·
{" "}since {fmtShortDate(data.health.historyOldest)} ·
{" "}showing latest 10
</span>
</div>
<table className="history-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Item ID</th>
<th>Action</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{history.slice(0, 10).map((h, i) => (
<tr key={i}>
<td className="mono small">{h.timestamp}</td>
<td className="mono small">{h.itemId}</td>
<td><span className="pill mini neutral">{h.action.replace(/_/g, " ")}</span></td>
<td>{h.details}</td>
</tr>
))}
</tbody>
</table>
</section>
</div>
</div>
);
}
window.AdminPage = AdminPage;
+918
View File
@@ -0,0 +1,918 @@
// 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 <strong>{it.title}</strong>{childCount > 0 ? ` and ${childCount} sub-item${childCount > 1 ? 's' : ''}` : ''}?</>,
detail: <span className="muted">This will be recorded in the history log.</span>,
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 <code className="mono">backlog.md</code> with the contents of this backup.</>,
detail: (
<>
<div className="mono small">{b.name}</div>
{!b.valid && (
<div className="restore-warn">
<Icon name="warn" size={14}/>
<div>
<div className="restore-warn-title">Checksum mismatch on this backup</div>
<div className="restore-warn-body">The file may be partially written or edited externally.</div>
</div>
</div>
)}
</>
),
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 (
<div className="app" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
<div style={{ textAlign: 'center', color: 'var(--ink-3)' }}>
<div style={{ fontSize: 22, marginBottom: 8 }}>Loading…</div>
<div style={{ fontSize: 13 }}>Connecting to storage</div>
</div>
</div>
);
}
return (
<div className="app">
<StatusStyleContext.Provider value={tweaks.status_style}>
<Header
view={view} setView={setView}
saveState={saveState} saveLabel={saveLabel}
storageMode={storageMode}
searchValue={filters.text}
onSearch={(v) => setFilters({ ...filters, text: v })}
onForceSave={() => triggerSave('Saved manually')}
onOpenImportExport={() => setImportExportOpen(true)}
/>
{storageMode === 'local' && (
<div className="banner warn" style={{gap:8}}>
<Icon name="warn" size={14}/>
<span>
This browser can't access local files directly — <strong>changes won't be saved.</strong>{' '}
Open in Chrome or Edge to save without a server, or run{' '}
<code className="mono">python3 server/server.py</code> for any browser.
</span>
</div>
)}
{needsConnect && (
<div className="banner info">
<Icon name="folder" size={14}/>
No <code className="mono">backlog.md</code> connected — grant one-time folder access, then reloads are silent.
<button className="btn-primary" style={{marginLeft:'auto',flexShrink:0,fontSize:12,padding:'3px 10px'}}
onClick={handleConnect}>Open folder…</button>
</div>
)}
{showWarning && (
<div className="banner warn">
<Icon name="warn" size={14}/>
File was edited outside the app — checksum mismatch. The next save will rewrite a correct marker.
<button className="banner-close" onClick={() => setShowWarning(false)}>dismiss</button>
</div>
)}
{view === 'backlog' ? (
<div className="main">
<FilterPanel filters={filters} setFilters={setFilters} tagsList={tagsList} counts={counts}/>
<section className="content">
<div className="content-sticky">
<div className="content-head">
<div>
<div className="eyebrow">Backlog</div>
<h1 className="content-title">
{hasFilters ? <>{filteredCount} of {totalCount} items</> : <>{totalCount} items</>}
</h1>
</div>
<div className="content-head-actions">
<div className="seg seg-mini" title="Order">
<button className={`seg-btn ${tweaks.sort_mode === 'priority' ? 'active' : ''}`}
onClick={() => setTweak('sort_mode', 'priority')}>By priority</button>
<button className={`seg-btn ${tweaks.sort_mode === 'manual' ? 'active' : ''}`}
onClick={() => setTweak('sort_mode', 'manual')}>Manual</button>
</div>
<button className="btn-primary" onClick={onMutate.addRoot}>
<Icon name="plus" size={12}/> New item
</button>
</div>
</div>
<ViewChips filters={filters} setFilters={setFilters}/>
<div className="legend">
<span className="legend-cap">Status:</span>
{STATUSES.map(s => (
<span key={s.key} className="legend-item">
<StatusIcon status={s.key} size={13}/>
<span>{s.label}</span>
</span>
))}
<span className="legend-sep">·</span>
<span className="legend-hint">
{tweaks.sort_mode === 'manual' ? <>Drag rows to reorder · </> : <>Auto-sorted by priority · drag/arrows reorder within priority · </>}
<kbd>/</kbd> search · <kbd>n</kbd> new
</span>
</div>
</div>
<div className="content-scroll">
{filtered.length === 0 ? (
<div className="empty-state">
<div className="empty-glyph">∅</div>
<div>{hasFilters ? 'No items match these filters.' : 'No items yet. Press n or click "+ New item" to start.'}</div>
</div>
) : (
<BacklogTree
items={filtered}
expandedMap={expandedMap}
setExpanded={setExpanded}
onMutate={onMutate}
query={filters.text?.trim() || ''}
statusStyle={tweaks.status_style}
manualOrder={tweaks.sort_mode === 'manual'}
/>
)}
</div>
</section>
</div>
) : (
<AdminPage
data={data}
history={data.history}
onClose={() => 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`);
}
}}
/>
)}
<ItemDialog
open={!!itemDialog}
mode={itemDialog?.mode}
initial={itemDialog?.initial}
recentTags={recentTags}
onClose={() => setItemDialog(null)}
onSubmit={submitItemDialog}
/>
<ConfirmDialog
open={!!confirm}
title={confirm?.title}
message={confirm?.message}
detail={confirm?.detail}
confirmLabel={confirm?.confirmLabel}
danger={confirm?.danger}
onCancel={() => setConfirm(null)}
onConfirm={confirm?.onConfirm}
/>
<ImportExportDialog
open={importExportOpen}
data={data}
storageMode={storageMode}
onClose={() => setImportExportOpen(false)}
onImport={handleImport}
/>
{toast && (
<div className={`toast ${toast.kind}`}><Icon name="check" size={12}/> {toast.msg}</div>
)}
<TweaksPanel title="Tweaks">
<TweakSection title="Status icons">
<TweakRadio label="Style"
value={tweaks.status_style}
options={[
{ value: 'flat', label: 'Flat' },
{ value: 'ascii', label: 'ASCII' },
{ value: 'color', label: 'Color' },
{ value: 'emoji', label: 'Emoji' },
]}
onChange={v => setTweak('status_style', v)}/>
</TweakSection>
<TweakSection title="Order">
<TweakRadio label="Sort"
value={tweaks.sort_mode}
options={[
{ value: 'priority', label: 'By priority' },
{ value: 'manual', label: 'Manual' },
]}
onChange={v => setTweak('sort_mode', v)}/>
</TweakSection>
<TweakSection title="Accent">
<TweakSlider label="Hue" value={tweaks.accent_hue} min={0} max={360} step={1}
onChange={v => setTweak('accent_hue', v)} formatValue={v => `${v}°`}/>
</TweakSection>
<TweakSection title="Layout">
<TweakRadio label="Density"
value={tweaks.density}
options={[{ value: 'comfortable', label: 'Comfortable' }, { value: 'compact', label: 'Compact' }]}
onChange={v => setTweak('density', v)}/>
<TweakToggle label="Paper texture" checked={tweaks.paper_texture} onChange={v => setTweak('paper_texture', v)}/>
</TweakSection>
<TweakSection title="Demo">
<TweakButton label="Show checksum-mismatch banner" onClick={() => setShowWarning(true)}/>
<TweakButton label="Simulate external file change" onClick={() => showToast('File reloaded from disk')}/>
{window.SEED_DATA && (
<TweakButton label="Load sample / test data" onClick={() => {
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');
}}/>
)}
</TweakSection>
</TweaksPanel>
</StatusStyleContext.Provider>
</div>
);
}
// ----- 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 (
<div className="view-chips" role="tablist" aria-label="Saved views">
{VIEW_CHIPS.map(v => (
<button key={v.key}
role="tab"
aria-selected={activeKey === v.key}
className={`view-chip ${activeKey === v.key ? 'active' : ''}`}
title={v.hint || v.label}
onClick={() => setFilters({ ...filters, statuses: v.statuses })}>
{v.label}
</button>
))}
</div>
);
}
// ----- Header -----
function Header({ view, setView, saveState, saveLabel, storageMode, searchValue, onSearch, onOpenImportExport }) {
const modeIcon = storageMode === 'api' ? '⚡' : storageMode === 'direct' ? '📁' : '💾';
return (
<header className="header">
<div className="brand">
<span className="brand-glyph"><StatusIcon status="in-progress" size={16}/></span>
<span className="brand-name">backlog</span>
<span className="brand-sub mono" title={`Storage: ${storageMode}`}>backlog.md {modeIcon}</span>
</div>
<nav className="tabs">
<button className={`tab ${view === 'backlog' ? 'active' : ''}`} onClick={() => setView('backlog')}>
<Icon name="list" size={13}/> Backlog
</button>
<button className={`tab ${view === 'admin' ? 'active' : ''}`} onClick={() => setView('admin')}>
<Icon name="cog" size={13}/> Admin
</button>
</nav>
<div className="search-wrap">
<Icon name="search" size={14}/>
<input className="search-input"
placeholder="Search titles, tags, reasons… ( / )"
value={searchValue}
onChange={(e) => onSearch(e.target.value)}/>
{searchValue && <button className="search-clear" onClick={() => onSearch('')}>×</button>}
</div>
<div className="header-right">
<button className="header-btn" onClick={onOpenImportExport} title="Import / Export">
<Icon name="archive" size={13}/> Import/Export
</button>
<div className={`save-indicator ${saveState.status}`}>
<span className={`save-dot ${saveState.status}`}/>
{saveLabel}
</div>
</div>
</header>
);
}
// ----- 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 (
<Dialog open={open} onClose={onClose} width={760} labelledBy="dlg-ie-title">
<DialogHeader id="dlg-ie-title" eyebrow="Backup & sync" title="Import / Export" onClose={onClose}/>
<div className="dlg-body">
<div className="ie-tabs">
<button className={`ie-tab ${tab === 'md' ? 'active' : ''}`} onClick={() => setTab('md')}>
Markdown <span className="ie-tab-sub">.md</span>
</button>
<button className={`ie-tab ${tab === 'json' ? 'active' : ''}`} onClick={() => setTab('json')}>
JSON <span className="ie-tab-sub">structured</span>
</button>
<button
className={`ie-copy-btn ${copied ? 'copied' : ''}`}
onClick={copyContent}
title={`Copy ${tab === 'md' ? 'Markdown' : 'JSON'} to clipboard`}
aria-live="polite"
>
{copied
? <><Icon name="check" size={13}/> <span>Copied</span></>
: <><Icon name="copy" size={13}/> <span>Copy</span></>}
</button>
</div>
<pre className="export-pre">{currentContent || '…generating…'}</pre>
</div>
<div className="dlg-foot">
<div>
<input ref={fileInputRef} type="file" accept=".md,.json" style={{ display: 'none' }} onChange={handleFileInput}/>
<button className="btn-secondary" onClick={() => fileInputRef.current?.click()}>
<Icon name="upload" size={12}/> Import file…
</button>
</div>
<div className="dlg-foot-actions">
<button className="btn-secondary" onClick={onClose}>Close</button>
<button className="btn-primary" onClick={downloadFile}>
<Icon name="download" size={12}/> Download {tab === 'md' ? '.md' : '.json'}
</button>
</div>
</div>
</Dialog>
);
}
window.App = App;
+233
View File
@@ -0,0 +1,233 @@
#!/usr/bin/env node
/**
* bundle.js — Flatten an HTML app and all its local deps into a single
* self-contained .html artifact, ready to open offline.
*
* What it does:
* - Inlines every local <script src> and <link rel="stylesheet" href>.
* - Pre-compiles JSX (type="text/babel") to plain JS using @babel/core,
* then removes the Babel CDN <script> tag — it's no longer needed.
* - Replaces React/ReactDOM dev CDN builds with production minified builds
* (much smaller; fetched once per run).
* - Skips anything inside HTML comments — commented-out blocks stay inert.
* - Leaves other remote URLs (fonts, etc.) untouched.
*
* Usage:
* node bundle.js <input.html> [output.html]
*
* Example:
* node bundle.js index.html ../webapp/index-style-v2.html
*
* Requirements:
* Node 18+ (uses global fetch to pull prod React builds).
* Run `npm install` once to get @babel/core + @babel/preset-react.
*/
const fs = require('fs');
const path = require('path');
// JSX compiler — graceful no-op if not installed.
// Require the preset as a module reference so Babel doesn't try to resolve it
// by name from cwd (which may differ from __dirname when invoked from another dir).
let babel, babelPresetReact;
try {
babel = require('@babel/core');
babelPresetReact = require('@babel/preset-react');
} catch { /* npm install to enable */ }
// ---- CDN substitution table -------------------------------------------------
// key: substring present in the CDN src URL
// value: prod URL to fetch & inline, or null to remove the tag entirely
const CDN_SWAP = [
{ match: 'unpkg.com/react-dom@', prod: 'https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js' },
{ match: 'unpkg.com/react@', prod: 'https://unpkg.com/react@18.3.1/umd/react.production.min.js' },
{ match: 'unpkg.com/@babel/standalone', prod: null }, // removed — we pre-compile
];
// react-dom must come before react in the list so substring matching works correctly
// (react-dom URL also contains "react@")
// ---- Helpers -----------------------------------------------------------------
const args = process.argv.slice(2).filter(a => !a.startsWith('--'));
if (args.length < 1) {
console.error('Usage: node bundle.js <input.html> [output.html]');
process.exit(1);
}
const inputPath = args[0];
const inputDir = path.dirname(path.resolve(inputPath));
const inputBase = path.basename(inputPath, '.html');
const outputPath = args[1] || path.join(inputDir, `${inputBase} (single file).html`);
function isRemote(url) { return /^(https?:)?\/\//i.test(url) || url.startsWith('data:'); }
function readLocal(ref) {
const full = path.resolve(inputDir, ref);
if (!fs.existsSync(full)) { console.warn(` ⚠ missing: ${ref}`); return null; }
return fs.readFileSync(full, 'utf8');
}
async function fetchRemote(url) {
console.log(` ⤓ fetching ${url}`);
const r = await fetch(url);
if (!r.ok) { console.warn(` ⚠ ${r.status} ${url}`); return null; }
return r.text();
}
// Returns [start, end] pairs for every <!-- ... --> span in html.
function commentSpans(html) {
const spans = [];
const re = /<!--[\s\S]*?-->/g;
let m;
while ((m = re.exec(html)) !== null) spans.push([m.index, m.index + m[0].length]);
return spans;
}
function insideComment(pos, spans) {
return spans.some(([s, e]) => pos >= s && pos < e);
}
// Escape </script inside a <script> block so the browser doesn't close it early.
function escForScript(src) { return src.replace(/<\/script/gi, '<\\/script'); }
function escForStyle(src) { return src.replace(/<\/style/gi, '<\\/style'); }
// Pre-compile JSX → JS. Falls back to raw source if babel isn't installed.
function compileJSX(src, filename) {
if (!babel || !babelPresetReact) return { code: src, compiled: false };
try {
const result = babel.transformSync(src, {
presets: [babelPresetReact],
filename,
sourceMaps: false,
compact: false,
});
return { code: result.code, compiled: true };
} catch (e) {
console.warn(` ⚠ JSX compile error in ${filename}: ${e.message}`);
return { code: src, compiled: false };
}
}
// ---- Inline local stylesheets -----------------------------------------------
async function inlineStylesheets(html) {
const comments = commentSpans(html);
const re = /<link\b([^>]*?)\brel\s*=\s*["']stylesheet["']([^>]*?)>/gi;
const matches = [...html.matchAll(re)];
let out = '', cursor = 0;
for (const m of matches) {
if (insideComment(m.index, comments)) {
out += html.slice(cursor, m.index + m[0].length);
cursor = m.index + m[0].length;
continue;
}
out += html.slice(cursor, m.index);
const hrefM = (m[1] + ' ' + m[2]).match(/\bhref\s*=\s*["']([^"']+)["']/i);
const href = hrefM?.[1];
const body = href && !isRemote(href) ? readLocal(href) : null;
if (body) { console.log(` ✓ stylesheet ${href}`); out += `<style>\n${escForStyle(body)}\n</style>`; }
else out += m[0];
cursor = m.index + m[0].length;
}
return out + html.slice(cursor);
}
// ---- Inline / swap / remove <script src="..."> tags -------------------------
async function inlineScripts(html) {
const comments = commentSpans(html);
const re = /<script\b([^>]*)\bsrc\s*=\s*["']([^"']+)["']([^>]*)>\s*<\/script>/gi;
const matches = [...html.matchAll(re)];
let out = '', cursor = 0;
for (const m of matches) {
// Keep everything inside HTML comments verbatim.
if (insideComment(m.index, comments)) {
out += html.slice(cursor, m.index + m[0].length);
cursor = m.index + m[0].length;
continue;
}
out += html.slice(cursor, m.index);
const [full, attrsBefore, src, attrsAfter] = m;
const allAttrs = (attrsBefore + ' ' + attrsAfter).trim();
const isBabel = /type\s*=\s*["']text\/babel["']/i.test(allAttrs);
if (isRemote(src)) {
// Look for a CDN substitution rule.
const rule = CDN_SWAP.find(r => src.includes(r.match));
if (rule === undefined) {
out += full; // unknown CDN — keep as-is
} else if (rule.prod === null) {
console.log(` ✗ removed ${src}`);
// tag dropped
} else {
const body = await fetchRemote(rule.prod);
if (body) { console.log(` ✓ inlined prod build`); out += `<script>\n${escForScript(body)}\n</script>`; }
else out += full;
}
} else {
// Local file.
let body = readLocal(src);
if (body == null) { out += full; }
else {
let label = src;
if (isBabel) {
const r = compileJSX(body, src);
body = r.code;
label = r.compiled ? `${src} (JSX→JS)` : src;
}
console.log(` ✓ script ${label}`);
// Strip type="text/babel", integrity, crossorigin — content has changed.
const attrs = allAttrs
.replace(/\btype\s*=\s*["']text\/babel["']/gi, '')
.replace(/\bintegrity\s*=\s*["'][^"']*["']/gi, '')
.replace(/\bcrossorigin\s*=\s*["'][^"']*["']/gi, '')
.replace(/\s+/g, ' ').trim();
out += `<script${attrs ? ' ' + attrs : ''}>\n${escForScript(body)}\n</script>`;
}
}
cursor = m.index + full.length;
}
return out + html.slice(cursor);
}
// ---- Compile remaining inline <script type="text/babel">…</script> blocks ---
function compileInlineBabel(html) {
const comments = commentSpans(html);
const re = /(<script\b[^>]*\btype\s*=\s*["']text\/babel["'][^>]*>)([\s\S]*?)(<\/script>)/gi;
return html.replace(re, (full, open, body, close, offset) => {
if (insideComment(offset, comments)) return full;
const { code, compiled } = compileJSX(body, 'inline.jsx');
if (!compiled) return full;
const attrs = open
.replace(/^<script\b/i, '')
.replace(/>$/, '')
.replace(/\btype\s*=\s*["']text\/babel["']/gi, '')
.replace(/\s+/g, ' ').trim();
console.log(' ✓ inline babel block compiled');
return `<script${attrs ? ' ' + attrs : ''}>\n${escForScript(code)}\n${close}`;
});
}
// ---- Main -------------------------------------------------------------------
(async () => {
if (babel && babelPresetReact) console.log('JSX: pre-compile enabled (@babel/core)');
else console.log('JSX: no @babel/core — run npm install to enable');
console.log(`\nReading ${inputPath}…`);
let html = fs.readFileSync(inputPath, 'utf8');
console.log('\nStylesheets:');
html = await inlineStylesheets(html);
console.log('\nScripts:');
html = await inlineScripts(html);
if (babel) {
console.log('\nInline Babel blocks:');
html = compileInlineBabel(html);
}
fs.writeFileSync(outputPath, html);
const kb = (fs.statSync(outputPath).size / 1024).toFixed(0);
console.log(`\n✔ wrote ${outputPath} (${kb} KB)`);
})();
+319
View File
@@ -0,0 +1,319 @@
// TEST-ONLY seed data for the Personal Backlog prototype.
// Not loaded by default — only included when running in test/demo mode.
// To activate: uncomment the data.jsx <script> tag in index.html.
// When loaded, exposes window.SEED_DATA; the app detects it and shows a
// "Load sample data" button in the Tweaks panel.
//
// Realistic personal-project material: side projects, household, learning.
// Statuses: open, in-progress, blocked, postponed, done, cancelled
// Priorities: P0, P1, P2, P3
// Up to 4 levels: Area → Project → Task → Sub-task
const SEED_DATA = {
entries: [
{
id: "area-side",
level: 1,
title: "Side projects",
priority: "P1",
status: "in-progress", progress: 50,
due: null,
tags: [],
reason: null,
collapsed: false,
children: [
{
id: "proj-backlog",
level: 2,
title: "Personal backlog app",
priority: "P0",
status: "in-progress", progress: 65,
due: "2026-05-12",
tags: ["build", "tooling"],
reason: null,
collapsed: false,
children: [
{
id: "t-parser",
level: 3,
title: "Markdown parser + integrity marker",
priority: "P0",
status: "done", progress: 100,
due: "2026-04-28",
tags: ["build"],
reason: null,
collapsed: false,
children: [
{ id: "st-tokenize", level: 4, title: "Tokenize three sections", priority: "P0", status: "done", progress: 100, due: null, tags: [], reason: null, children: [] },
{ id: "st-checksum", level: 4, title: "SHA-256 over entries+history", priority: "P0", status: "done", progress: 100, due: null, tags: [], reason: null, children: [] }
]
},
{
id: "t-renderer",
level: 3,
title: "Tree renderer with parent-visibility filter",
priority: "P0",
status: "in-progress", progress: 55,
due: "2026-05-04",
tags: ["build", "ui"],
reason: null,
collapsed: false,
children: [
{ id: "st-collapse", level: 4, title: "Expand / collapse animations", priority: "P1", status: "open", progress: 25, due: null, tags: ["ui"], reason: null, children: [] },
{ id: "st-dnd", level: 4, title: "Drag handle within priority group", priority: "P1", status: "open", progress: 0, due: "2026-05-06", tags: ["ui"], reason: null, children: [] }
]
},
{
id: "t-fsapi",
level: 3,
title: "File System Access API direct mode",
priority: "P1",
status: "blocked", progress: 50,
due: "2026-05-10",
tags: ["research"],
reason: "Need to test on Edge — no machine yet",
children: []
},
{
id: "t-admin",
level: 3,
title: "Admin page — backup browser + restore",
priority: "P2",
status: "open", progress: 0,
due: "2026-05-20",
tags: ["build", "ui"],
reason: null,
children: []
},
{
id: "t-stats",
level: 3,
title: "stats.jsonl rollups (90-day retention)",
priority: "P3",
status: "postponed", progress: 25,
due: null,
tags: ["build"],
reason: null,
children: []
}
]
},
{
id: "proj-cookbook",
level: 2,
title: "Family cookbook static site",
priority: "P2",
status: "in-progress", progress: 30,
due: "2026-06-15",
tags: ["writing"],
reason: null,
collapsed: true,
children: [
{ id: "t-recipes", level: 3, title: "Transcribe Mom's index cards", priority: "P1", status: "in-progress", progress: 75, due: "2026-05-30", tags: ["writing"], reason: null, children: [] },
{ id: "t-photos", level: 3, title: "Photograph each finished dish", priority: "P2", status: "open", progress: 0, due: null, tags: ["photo"], reason: null, children: [] },
{ id: "t-print", level: 3, title: "Test print layout (8.5×11)", priority: "P3", status: "open", progress: 0, due: null, tags: [], reason: null, children: [] }
]
}
]
},
{
id: "area-house",
level: 1,
title: "Home & life admin",
priority: "P1",
status: "in-progress", progress: 50,
due: null,
tags: [],
reason: null,
collapsed: false,
children: [
{
id: "proj-tax",
level: 2,
title: "File 2025 taxes",
priority: "P0",
status: "in-progress", progress: 50,
due: "2026-04-15",
tags: ["urgent", "finance"],
reason: null,
collapsed: false,
children: [
{ id: "t-w2", level: 3, title: "Gather W-2s and 1099s", priority: "P0", status: "done", progress: 100, due: "2026-03-10", tags: ["finance"], reason: null, children: [] },
{ id: "t-deduct", level: 3, title: "Itemize charitable deductions", priority: "P0", status: "in-progress", progress: 50, due: "2026-04-10", tags: ["finance"], reason: null, children: [] },
{ id: "t-cpa", level: 3, title: "Schedule call with CPA", priority: "P0", status: "blocked", progress: 25, due: "2026-04-12", tags: ["urgent"], reason: "CPA out until Apr 8", children: [] }
]
},
{
id: "proj-house",
level: 2,
title: "Apartment maintenance",
priority: "P2",
status: "in-progress", progress: 50,
due: null,
tags: [],
reason: null,
collapsed: false,
children: [
{ id: "t-filter", level: 3, title: "Replace HVAC filter", priority: "P1", status: "open", progress: 0, due: "2026-05-05", tags: ["recurring"], reason: null, children: [] },
{ id: "t-sink", level: 3, title: "Fix leaky kitchen sink", priority: "P2", status: "open", progress: 0, due: null, tags: [], reason: null, children: [] },
{ id: "t-storage", level: 3, title: "Donate boxes from closet", priority: "P3", status: "postponed", progress: 25, due: null, tags: [], reason: null, children: [] }
]
}
]
},
{
id: "area-learn",
level: 1,
title: "Learning",
priority: "P2",
status: "in-progress", progress: 50,
due: null,
tags: [],
reason: null,
collapsed: false,
children: [
{
id: "proj-rust",
level: 2,
title: "Rust in 30 days",
priority: "P2",
status: "in-progress", progress: 40,
due: "2026-07-01",
tags: ["learning", "rust"],
reason: null,
collapsed: true,
children: [
{ id: "t-book", level: 3, title: "Read chapters 1–6", priority: "P2", status: "done", progress: 100, due: "2026-04-20", tags: ["rust"], reason: null, children: [] },
{ id: "t-cli", level: 3, title: "Build a small CLI tool", priority: "P2", status: "open", progress: 0, due: "2026-06-01", tags: ["rust"], reason: null, children: [] }
]
},
{
id: "proj-piano",
level: 2,
title: "Pick piano back up",
priority: "P3",
status: "cancelled", progress: 0,
due: null,
tags: [],
reason: null,
collapsed: true,
children: []
}
]
},
{
// standalone task at level 2 (no children)
id: "oneoff-passport",
level: 2,
title: "Renew passport",
priority: "P1",
status: "open", progress: 0,
due: "2026-05-15",
tags: ["urgent"],
reason: null,
children: []
}
],
history: [
{ timestamp: "2026-05-01T08:12:30Z", itemId: "t-deduct", action: "status_changed", details: "open → in-progress" },
{ timestamp: "2026-04-30T19:44:02Z", itemId: "t-cpa", action: "status_changed", details: "open → blocked (CPA out until Apr 8)" },
{ timestamp: "2026-04-30T14:08:55Z", itemId: "t-renderer", action: "status_changed", details: "open → in-progress" },
{ timestamp: "2026-04-29T22:01:11Z", itemId: "st-checksum", action: "status_changed", details: "in-progress → done" },
{ timestamp: "2026-04-29T18:30:00Z", itemId: "t-parser", action: "status_changed", details: "in-progress → done" },
{ timestamp: "2026-04-28T09:15:42Z", itemId: "t-book", action: "status_changed", details: "in-progress → done" },
{ timestamp: "2026-04-27T11:22:18Z", itemId: "proj-piano", action: "status_changed", details: "postponed → cancelled" },
{ timestamp: "2026-04-25T13:50:09Z", itemId: "oneoff-passport", action: "item_created", details: "Renew passport" },
{ timestamp: "2026-04-24T07:33:50Z", itemId: "t-w2", action: "status_changed", details: "in-progress → done" },
{ timestamp: "2026-04-22T16:18:27Z", itemId: "t-recipes", action: "status_changed", details: "open → in-progress" },
{ timestamp: "2026-04-20T10:00:00Z", itemId: "proj-backlog", action: "item_created", details: "Personal backlog app" }
],
meta: {
saved: "2026-05-01T08:12:30Z",
checksum: "sha256:9f2c4a1b6e88d7f0a3c5b2e9d1f7a4c8b6e2d5f1a9c7b3e0d8f2a5c1b4e7d3f9",
entryCount: 27,
historyCount: 1284
},
backups: [
{ name: "backlog_2026-05-01_08-12-30.md", size: 18432, timestamp: "2026-05-01T08:12:30Z", entries: 27, valid: true },
{ name: "backlog_2026-04-30_19-44-02.md", size: 18380, timestamp: "2026-04-30T19:44:02Z", entries: 27, valid: true },
{ name: "backlog_2026-04-30_14-08-55.md", size: 18290, timestamp: "2026-04-30T14:08:55Z", entries: 27, valid: true },
{ name: "backlog_2026-04-29_22-01-11.md", size: 18102, timestamp: "2026-04-29T22:01:11Z", entries: 26, valid: true },
{ name: "backlog_2026-04-29_18-30-00.md", size: 18044, timestamp: "2026-04-29T18:30:00Z", entries: 26, valid: true },
{ name: "backlog_2026-04-28_09-15-42.md", size: 17988, timestamp: "2026-04-28T09:15:42Z", entries: 26, valid: true },
{ name: "backlog_2026-04-27_11-22-18.md", size: 17920, timestamp: "2026-04-27T11:22:18Z", entries: 26, valid: true },
{ name: "backlog_2026-04-25_13-50-09.md", size: 17812, timestamp: "2026-04-25T13:50:09Z", entries: 25, valid: false },
{ name: "backlog_2026-04-22_16-18-27.md", size: 17640, timestamp: "2026-04-22T16:18:27Z", entries: 24, valid: true }
],
health: {
integrityOk: true,
lastSave: "2026-05-01T08:12:30Z",
lastBackup: "2026-05-01T08:12:30Z",
masterSize: 18432,
backupDirSize: 162608,
backupCount: 9,
statsSize: 48210,
historySize: 84320,
historyOldest: "2025-11-14T09:02:11Z",
mode: "API server"
},
stats: {
createdThisWeek: 6,
completedThisWeek: 4,
avgInProgressDays: 2.4,
mostActiveProject: "Personal backlog app",
completionByDay: [
{ day: "Mon", count: 1 },
{ day: "Tue", count: 0 },
{ day: "Wed", count: 2 },
{ day: "Thu", count: 1 },
{ day: "Fri", count: 0 },
{ day: "Sat", count: 0 },
{ day: "Sun", count: 0 }
],
statusMix: { open: 9, "in-progress": 6, blocked: 2, postponed: 2, done: 7, cancelled: 1 }
}
};
// --- Inflate seed with a realistic-sized tag vocabulary, so the autocomplete
// has hundreds of entries to search through (mirrors a power-user backlog).
(function inflateTags() {
const POOL = [
// domains
"frontend","backend","infra","devops","mobile","ios","android","desktop","cli","web","api","sdk",
// languages / stacks
"js","ts","python","rust","go","sql","postgres","sqlite","redis","docker","k8s","aws","gcp","linux","macos","windows",
// work types
"research","spike","prototype","design","qa","testing","ci","release","launch","onboarding","docs","writing","talk","meeting",
// life
"home","family","kids","cooking","recipes","photo","garden","travel","car","health","fitness","reading","music","piano","guitar",
// finance / admin
"finance","tax","budget","invoice","insurance","subscription","passport","dmv","banking",
// priority signals
"urgent","quick-win","blocked-on-vendor","followup","waiting","external",
// affinity tags
"energy-low","deep-work","errand","weekend","evening","morning",
// specifics
"v2","v3","p0-week","oncall","review","rfc","interview","hiring","1-1","retro","planning","roadmap","sprint","standup"
];
// Gather all leaf+branch items
const all = [];
(function walk(arr) { for (const it of arr) { all.push(it); if (it.children) walk(it.children); } })(SEED_DATA.entries);
let seed = 1;
const rand = () => { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; };
for (const it of all) {
const existing = new Set(it.tags || []);
const n = 1 + Math.floor(rand() * 3); // 1–3 extra tags
for (let i = 0; i < n; i++) {
const t = POOL[Math.floor(rand() * POOL.length)];
existing.add(t);
}
it.tags = [...existing];
}
})();
window.SEED_DATA = SEED_DATA;
+372
View File
@@ -0,0 +1,372 @@
// Stylish dialog primitives + concrete dialogs (Edit/Add Item, Confirm).
const { useState: useStateD, useEffect: useEffectD, useRef: useRefD } = React;
function Dialog({ open, onClose, children, width = 460, labelledBy }) {
useEffectD(() => {
if (!open) return;
const onKey = (e) => { if (e.key === "Escape") onClose?.(); };
document.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [open, onClose]);
if (!open) return null;
return (
<div className="dlg-backdrop" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose?.(); }}>
<div className="dlg" role="dialog" aria-modal="true" aria-labelledby={labelledBy} style={{ maxWidth: width }}>
{children}
</div>
</div>
);
}
function DialogHeader({ eyebrow, title, onClose, id }) {
return (
<div className="dlg-head">
<div>
{eyebrow && <div className="eyebrow">{eyebrow}</div>}
<h3 id={id} className="dlg-title">{title}</h3>
</div>
{onClose && (
<button className="dlg-close" onClick={onClose} aria-label="Close">
<svg width="14" height="14" viewBox="0 0 16 16"><path d="M3 3l10 10M13 3L3 13" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>
</button>
)}
</div>
);
}
// --- Item editor (used for both edit and add) ---
function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] }) {
const [title, setTitle] = useStateD("");
const [priority, setPriority] = useStateD("P2");
const [status, setStatus] = useStateD("open");
const [due, setDue] = useStateD("");
const [tags, setTags] = useStateD([]);
const [tagInput, setTagInput] = useStateD("");
const [reason, setReason] = useStateD("");
const [progress, setProgress] = useStateD(0);
const titleRef = useRefD(null);
useEffectD(() => {
if (!open) return;
setTitle(initial?.title || "");
setPriority(initial?.priority || "P2");
setStatus(initial?.status || "open");
setDue(initial?.due || "");
setTags(initial?.tags || []);
setTagInput("");
setReason(initial?.reason || "");
setProgress(initial?.progress ?? 0);
setTimeout(() => titleRef.current?.focus(), 50);
}, [open, initial]);
const addTag = (t) => {
const v = t.trim().replace(/^#/, "");
if (!v) return;
if (tags.includes(v)) return;
setTags([...tags, v]);
};
const removeTag = (t) => setTags(tags.filter(x => x !== t));
const submit = (e) => {
e?.preventDefault();
if (!title.trim()) return;
// Reconcile progress with status before submitting.
let outProgress = snapProgress(progress);
if (status === "done") outProgress = 100;
onSubmit({
title: title.trim(),
priority, status,
due: due || null,
tags,
reason: status === "blocked" ? reason.trim() || null : null,
progress: outProgress
});
};
const tagSuggestions = recentTags.filter(t => !tags.includes(t)).slice(0, 8);
return (
<Dialog open={open} onClose={onClose} width={520} labelledBy="dlg-item-title">
<form onSubmit={submit}>
<DialogHeader
id="dlg-item-title"
eyebrow={mode === "edit" ? "Edit item" : (mode === "add-child" ? "Add sub-item" : "New item")}
title={mode === "edit" ? "Edit details" : "Create item"}
onClose={onClose}
/>
<div className="dlg-body">
<div className="field">
<label className="field-label">Title</label>
<input
ref={titleRef}
className="text-input"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="What needs to happen?"
required
/>
</div>
<div className="field-row">
<div className="field">
<label className="field-label">Priority</label>
<div className="seg">
{PRIORITIES.map(p => (
<button type="button" key={p}
className={`seg-btn pri-${p} ${priority === p ? "active" : ""}`}
onClick={() => setPriority(p)}>
{p === "P0" && <span className="p0-dot"/>}
{p}
</button>
))}
</div>
</div>
<div className="field">
<label className="field-label">Due</label>
<input className="text-input" type="date" value={due} onChange={(e) => setDue(e.target.value)}/>
</div>
</div>
<div className="field">
<label className="field-label">Status</label>
<div className="status-grid">
{STATUSES.map(s => (
<button type="button" key={s.key}
className={`status-card ${status === s.key ? "active" : ""}`}
onClick={() => setStatus(s.key)}>
<span className={`status-card-icon status-${s.key}`}>
<StatusIcon status={s.key} size={16}/>
</span>
<span className="status-card-label">{s.label}</span>
</button>
))}
</div>
</div>
{status !== "cancelled" && (
<div className="field">
<label className="field-label">
Completion
<span className="field-hint">
{status === "done"
? "locked at 100% while marked done"
: "rough estimate — set 100% to mark done"}
</span>
</label>
<div className="progress-row">
<div className={`seg progress-seg ${status === "done" ? "locked" : ""}`}>
{PROGRESS_STEPS.map(p => (
<button type="button" key={p}
className={`seg-btn ${progress === p ? "active" : ""}`}
onClick={() => setProgress(p)}
disabled={status === "done"}>
{p}%
</button>
))}
</div>
<ProgressGauge value={status === "done" ? 100 : progress} size="md"/>
</div>
</div>
)}
{status === "blocked" && (
<div className="field">
<label className="field-label">Block reason</label>
<input className="text-input" value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Waiting for…"/>
</div>
)}
<div className="field">
<label className="field-label">Tags <span className="field-hint">type a name, press Enter or comma to add</span></label>
<TagAutocompleteInput
tags={tags}
setTags={setTags}
tagInput={tagInput}
setTagInput={setTagInput}
addTag={addTag}
removeTag={removeTag}
allTags={recentTags}
/>
</div>
</div>
<div className="dlg-foot">
<span className="dlg-hint">⌘↩ to save · Esc to cancel</span>
<div className="dlg-foot-actions">
<button type="button" className="btn-secondary" onClick={onClose}>Cancel</button>
<button type="submit" className="btn-primary" disabled={!title.trim()}>
{mode === "edit" ? "Save changes" : "Create item"}
</button>
</div>
</div>
</form>
</Dialog>
);
}
// --- Confirm dialog ---
function ConfirmDialog({ open, title, message, detail, confirmLabel = "Confirm", danger = false, onCancel, onConfirm }) {
return (
<Dialog open={open} onClose={onCancel} width={420} labelledBy="dlg-confirm-title">
<DialogHeader id="dlg-confirm-title" eyebrow="Confirm" title={title} onClose={onCancel}/>
<div className="dlg-body">
<p className="dlg-msg">{message}</p>
{detail && <div className="dlg-detail">{detail}</div>}
</div>
<div className="dlg-foot">
<span/>
<div className="dlg-foot-actions">
<button type="button" className="btn-secondary" onClick={onCancel}>Cancel</button>
<button type="button"
className={danger ? "btn-danger" : "btn-primary"}
onClick={onConfirm} autoFocus>{confirmLabel}</button>
</div>
</div>
</Dialog>
);
}
// --- Tag autocomplete (typed input + filtered dropdown) ---
function TagAutocompleteInput({ tags, setTags, tagInput, setTagInput, addTag, removeTag, allTags }) {
const [focused, setFocused] = useStateD(false);
const [highlighted, setHighlighted] = useStateD(0);
const inputRef = useRefD(null);
const wrapRef = useRefD(null);
const q = tagInput.trim().toLowerCase().replace(/^#/, "");
// suggestions: filter out already-applied; rank exact prefix > contains
const pool = (allTags || []).filter(t => !tags.includes(t));
let suggestions;
if (q) {
const starts = pool.filter(t => t.toLowerCase().startsWith(q));
const contains = pool.filter(t => !t.toLowerCase().startsWith(q) && t.toLowerCase().includes(q));
suggestions = [...starts, ...contains].slice(0, 8);
} else {
suggestions = pool.slice(0, 8); // recents
}
const exactMatch = q && pool.some(t => t.toLowerCase() === q);
const showCreate = q && !exactMatch && !tags.includes(q);
// index 0 = "create new" (if shown), then suggestions
const items = [
...(showCreate ? [{ kind: "create", value: q }] : []),
...suggestions.map(t => ({ kind: "suggestion", value: t }))
];
useEffectD(() => { setHighlighted(0); }, [tagInput, focused]);
const pick = (it) => {
if (!it) return;
addTag(it.value);
setTagInput("");
setHighlighted(0);
};
const onKey = (e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
if (items.length) setHighlighted((h) => (h + 1) % items.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (items.length) setHighlighted((h) => (h - 1 + items.length) % items.length);
} else if (e.key === "Enter") {
e.preventDefault();
if (items[highlighted]) pick(items[highlighted]);
else if (tagInput.trim()) { addTag(tagInput); setTagInput(""); }
} else if (e.key === "," || e.key === "Tab") {
if (tagInput.trim()) {
e.preventDefault();
if (items[highlighted]) pick(items[highlighted]);
else { addTag(tagInput); setTagInput(""); }
}
} else if (e.key === "Backspace" && !tagInput && tags.length) {
removeTag(tags[tags.length - 1]);
} else if (e.key === "Escape") {
if (tagInput) { setTagInput(""); e.preventDefault(); }
else inputRef.current?.blur();
}
};
return (
<div className="tag-ac" ref={wrapRef}>
<div className={`tag-input ${focused ? "focused" : ""}`} onClick={(e) => {
if (e.target === e.currentTarget) inputRef.current?.focus();
}}>
{tags.map(t => (
<span key={t} className="tag-pill">
#{t}
<button type="button" onClick={() => removeTag(t)} aria-label={`Remove ${t}`}>×</button>
</span>
))}
<input
ref={inputRef}
className="tag-input-field"
placeholder={tags.length ? "+ add tag" : "e.g. urgent, frontend, errand"}
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setTimeout(() => setFocused(false), 150)}
onKeyDown={onKey}
autoComplete="off"
aria-autocomplete="list"
aria-expanded={focused && items.length > 0}
/>
</div>
{focused && items.length > 0 && (
<div className="tag-ac-pop" role="listbox">
{!q && (
<div className="tag-ac-cap">Recent</div>
)}
{items.map((it, i) => (
<button
key={`${it.kind}-${it.value}`}
type="button"
role="option"
aria-selected={i === highlighted}
className={`tag-ac-item ${i === highlighted ? "highlighted" : ""} ${it.kind === "create" ? "create" : ""}`}
onMouseEnter={() => setHighlighted(i)}
onMouseDown={(e) => { e.preventDefault(); pick(it); }}
>
{it.kind === "create" ? (
<>
<Icon name="plus" size={11}/>
<span>Create <strong>#{it.value}</strong></span>
</>
) : (
<span>{q ? <TagMatchHL text={it.value} match={q}/> : `#${it.value}`}</span>
)}
</button>
))}
<div className="tag-ac-foot">
<span><kbd>↑</kbd><kbd>↓</kbd> to navigate</span>
<span><kbd>↵</kbd> to add</span>
<span><kbd>esc</kbd> to dismiss</span>
</div>
</div>
)}
</div>
);
}
function TagMatchHL({ text, match }) {
const i = text.toLowerCase().indexOf(match);
if (i < 0) return `#${text}`;
return (
<>
#{text.slice(0, i)}<span className="hl">{text.slice(i, i + match.length)}</span>{text.slice(i + match.length)}
</>
);
}
window.Dialog = Dialog;
window.ItemDialog = ItemDialog;
window.ConfirmDialog = ConfirmDialog;
+196
View File
@@ -0,0 +1,196 @@
// Filter sidebar: status, priority, due, tags + scope.
function FilterPanel({ filters, setFilters, tagsList, counts }) {
const toggle = (key, val) => {
const cur = new Set(filters[key] || []);
cur.has(val) ? cur.delete(val) : cur.add(val);
setFilters({ ...filters, [key]: [...cur] });
};
const isActive = (key, val) => (filters[key] || []).includes(val);
return (
<aside className="filter-panel">
<div className="filter-section">
<div className="filter-label">Status</div>
<div className="filter-list">
{STATUSES.map(s => (
<button
key={s.key}
className={`filter-chip ${isActive("statuses", s.key) ? "active" : ""}`}
onClick={() => toggle("statuses", s.key)}
>
<StatusIcon status={s.key} size={13}/>
<span className="filter-name">{s.label}</span>
<span className="filter-count">{counts[s.key] || 0}</span>
</button>
))}
</div>
</div>
<div className="filter-section">
<div className="filter-label">Priority</div>
<div className="filter-list filter-list-row">
{PRIORITIES.map(p => (
<button
key={p}
className={`filter-chip mini ${isActive("priorities", p) ? "active" : ""} pri-${p}`}
onClick={() => toggle("priorities", p)}
>
{p === "P0" && <span className="p0-dot" />}
{p}
</button>
))}
</div>
</div>
<div className="filter-section">
<div className="filter-label">Due</div>
<div className="filter-list">
{[
{ k: "overdue", n: "Overdue" },
{ k: "today", n: "Today" },
{ k: "week", n: "This week" },
{ k: "month", n: "This month" }
].map(o => (
<button
key={o.k}
className={`filter-chip ${filters.dueRange === o.k ? "active" : ""}`}
onClick={() => setFilters({ ...filters, dueRange: filters.dueRange === o.k ? null : o.k })}
>
<span className="filter-name">{o.n}</span>
</button>
))}
</div>
</div>
<TagFilter
tagsList={tagsList}
selected={filters.tags || []}
onToggle={(t) => toggle("tags", t)}
/>
<div className="filter-section">
<div className="filter-label">Scope</div>
<div className="filter-list">
{[
{ k: "all", n: "All items" },
{ k: "top", n: "Top-level only" }
].map(o => (
<button
key={o.k}
className={`filter-chip ${(filters.scope || "all") === o.k ? "active" : ""}`}
onClick={() => setFilters({ ...filters, scope: o.k })}
>
<span className="filter-name">{o.n}</span>
</button>
))}
</div>
</div>
{(filters.statuses?.length || filters.priorities?.length || filters.tags?.length || filters.dueRange) && (
<button className="clear-btn"
onClick={() => setFilters({ statuses: [], priorities: [], tags: [], dueRange: null, scope: filters.scope, text: filters.text })}>
Clear filters
</button>
)}
</aside>
);
}
// ---- Tag filter with autocomplete (handles hundreds of tags) ----
function TagFilter({ tagsList, selected, onToggle }) {
const [q, setQ] = React.useState("");
const [focused, setFocused] = React.useState(false);
const inputRef = React.useRef(null);
if (!tagsList.length) return null;
const ql = q.trim().toLowerCase();
const filtered = ql
? tagsList.filter(t => t.toLowerCase().includes(ql)).slice(0, 50)
: tagsList.slice(0, 12); // top dozen by frequency when not searching
const exact = ql && tagsList.includes(ql);
const TOTAL = tagsList.length;
return (
<div className="filter-section">
<div className="filter-label">
Tags <span className="tag-total mono">{TOTAL}</span>
</div>
{/* Selected tags chips (always visible if any) */}
{selected.length > 0 && (
<div className="tag-selected">
{selected.map(t => (
<button key={t} className="tag-chip active" onClick={() => onToggle(t)} title="Remove">
#{t}
<span className="tag-x">×</span>
</button>
))}
</div>
)}
{/* Search field */}
<div className={`tag-search ${focused ? "focused" : ""}`}>
<Icon name="search" size={12}/>
<input
ref={inputRef}
className="tag-search-input"
placeholder={`Search ${TOTAL} tags…`}
value={q}
onChange={(e) => setQ(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setTimeout(() => setFocused(false), 120)}
onKeyDown={(e) => {
if (e.key === "Enter" && filtered.length > 0) {
onToggle(filtered[0]);
setQ("");
} else if (e.key === "Escape") {
setQ("");
e.currentTarget.blur();
}
}}
/>
{q && <button className="tag-search-clear" onClick={() => { setQ(""); inputRef.current?.focus(); }}>×</button>}
</div>
{/* Results / suggestions */}
<div className="tag-results">
{filtered.length === 0 ? (
<div className="tag-empty">No tags match "{q}"</div>
) : (
<>
{filtered.map(t => {
const isSelected = selected.includes(t);
return (
<button
key={t}
className={`tag-chip ${isSelected ? "active" : ""}`}
onClick={() => onToggle(t)}
title={isSelected ? "Remove from filter" : "Add to filter"}
>
{ql && !isSelected ? <TagHighlight text={t} match={ql}/> : `#${t}`}
</button>
);
})}
{!ql && tagsList.length > 12 && (
<span className="tag-more mono">+{tagsList.length - 12} more — type to find</span>
)}
</>
)}
</div>
</div>
);
}
function TagHighlight({ text, match }) {
const i = text.toLowerCase().indexOf(match);
if (i < 0) return `#${text}`;
return (
<>
#{text.slice(0, i)}<span className="hl">{text.slice(i, i + match.length)}</span>{text.slice(i + match.length)}
</>
);
}
window.FilterPanel = FilterPanel;
+367
View File
@@ -0,0 +1,367 @@
// Shared helpers, status definitions, filter engine, icons.
const STATUSES = [
{ key: "open", glyph: "[ ]", label: "Open" },
{ key: "in-progress", glyph: "[/]", label: "In progress" },
{ key: "blocked", glyph: "[!]", label: "Blocked" },
{ key: "postponed", glyph: "[>]", label: "Postponed" },
{ key: "done", glyph: "[x]", label: "Done" },
{ key: "cancelled", glyph: "[-]", label: "Cancelled" }
];
const STATUS_BY_KEY = Object.fromEntries(STATUSES.map(s => [s.key, s]));
const STATUS_ORDER = STATUSES.map(s => s.key);
const PRIORITIES = ["P0", "P1", "P2", "P3"];
// ---- date helpers ----
function parseDate(s) { return s ? new Date(s + "T12:00:00Z") : null; }
function daysBetween(a, b) { return Math.round((a - b) / 86400000); }
function isOverdue(item) {
if (!item.due || item.status === "done" || item.status === "cancelled") return false;
return parseDate(item.due) < new Date();
}
function dueRelative(item) {
if (!item.due) return null;
const d = daysBetween(parseDate(item.due), new Date());
if (d < 0) return `${-d}d overdue`;
if (d === 0) return "today";
if (d === 1) return "tomorrow";
if (d < 7) return `in ${d}d`;
if (d < 30) return `in ${Math.round(d/7)}w`;
return item.due;
}
function fmtTimestamp(iso) {
if (!iso) return "—";
const d = new Date(iso);
const diff = (Date.now() - d.getTime()) / 1000;
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.round(diff/60)}m ago`;
if (diff < 86400) return `${Math.round(diff/3600)}h ago`;
if (diff < 86400*7) return `${Math.round(diff/86400)}d ago`;
return d.toISOString().slice(0, 10);
}
function fmtBytes(b) {
if (b < 1024) return `${b} B`;
if (b < 1024*1024) return `${(b/1024).toFixed(1)} KB`;
return `${(b/1024/1024).toFixed(2)} MB`;
}
const fmtRelative = fmtTimestamp;
function fmtShortDate(iso) {
if (!iso) return "—";
const d = new Date(iso);
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
return `${months[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
}
// ---- tree helpers ----
function walkTree(items, fn, depth = 0, parent = null) {
for (const it of items) {
fn(it, depth, parent);
if (it.children?.length) walkTree(it.children, fn, depth + 1, it);
}
}
function findItem(items, id) {
for (const it of items) {
if (it.id === id) return it;
if (it.children?.length) {
const f = findItem(it.children, id);
if (f) return f;
}
}
return null;
}
function allTags(items) {
const counts = new Map();
walkTree(items, it => (it.tags || []).forEach(t => counts.set(t, (counts.get(t) || 0) + 1)));
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([t]) => t);
}
function countAll(items) {
let n = 0; walkTree(items, () => n++); return n;
}
function countByStatus(items) {
const c = Object.fromEntries(STATUSES.map(s => [s.key, 0]));
walkTree(items, it => { c[it.status] = (c[it.status] || 0) + 1; });
return c;
}
// ---- filter engine with parent-visibility rule ----
function filterTree(items, f) {
function matches(it) {
if (f.statuses?.length && !f.statuses.includes(it.status)) return false;
if (f.priorities?.length && !f.priorities.includes(it.priority)) return false;
if (f.tags?.length && !(it.tags || []).some(t => f.tags.includes(t))) return false;
if (f.dueRange) {
const overdue = isOverdue(it);
const d = it.due ? daysBetween(parseDate(it.due), new Date()) : null;
if (f.dueRange === "overdue" && !overdue) return false;
if (f.dueRange === "today" && !(d === 0)) return false;
if (f.dueRange === "week" && !(d !== null && d >= 0 && d <= 7)) return false;
if (f.dueRange === "month" && !(d !== null && d >= 0 && d <= 30)) return false;
}
if (f.text) {
const q = f.text.toLowerCase();
const hay = [it.title, it.reason, ...(it.tags || [])].filter(Boolean).join(" ").toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
}
function recur(list) {
const out = [];
for (const it of list) {
const kids = it.children?.length ? recur(it.children) : [];
const self = matches(it);
if (self || kids.length) {
out.push({ ...it, children: kids, _matches: self });
}
}
return out;
}
return recur(items);
}
// ---- status visuals (4 styles, switchable via Tweaks) ----
// styles: "flat" (minimal monochrome SVG, inherits status color),
// "ascii" (original [/], [x] etc),
// "color" (filled colored icons, ChatGPT-style — green check, red blocked, etc),
// "emoji" (expressive native emoji — ✅ 🚧 ⏸ etc)
// Self-contained color palette for the "color" style — does NOT inherit currentColor.
const STATUS_COLORS = {
"open": { stroke: "oklch(0.62 0.01 70)", fill: "transparent" },
"in-progress": { stroke: "oklch(0.55 0.16 230)", fill: "oklch(0.62 0.16 230)" },
"blocked": { stroke: "oklch(0.50 0.20 28)", fill: "oklch(0.62 0.20 28)" },
"postponed": { stroke: "oklch(0.55 0.13 290)", fill: "oklch(0.65 0.13 290)" },
"done": { stroke: "oklch(0.48 0.16 145)", fill: "oklch(0.60 0.16 145)" },
"cancelled": { stroke: "oklch(0.55 0.01 70)", fill: "oklch(0.65 0.01 70)" },
};
// Expressive emoji pack — chosen for clarity at small sizes (avoid round colored dots).
const STATUS_EMOJI = {
"open": "⬜",
"in-progress": "✏️",
"blocked": "🚧",
"postponed": "⏸️",
"done": "✅",
"cancelled": "❌",
};
// Context so every StatusIcon in the tree picks up the current style automatically.
const StatusStyleContext = React.createContext("flat");
function StatusIcon({ status, style, size = 14 }) {
const ctx = React.useContext(StatusStyleContext);
style = style || ctx || "flat";
if (style === "ascii") {
return <span className={`glyph-ascii status-${status}`}>{STATUS_BY_KEY[status].glyph}</span>;
}
if (style === "color") {
return <ColorStatusIcon status={status} size={size}/>;
}
if (style === "emoji") {
return <span className="glyph-emoji" style={{ fontSize: size + 2, lineHeight: 1 }}>{STATUS_EMOJI[status]}</span>;
}
// flat — minimalistic SVG icons (square family, no circles)
const c = "currentColor";
const wrap = (children) => (
<svg width={size + 2} height={size + 2} viewBox="0 0 16 16" className={`glyph-flat status-${status}`}>
{children}
</svg>
);
switch (status) {
case "open":
// empty square outline
return wrap(<rect x="2.5" y="2.5" width="11" height="11" rx="2" fill="none" stroke={c} strokeWidth="1.4"/>);
case "in-progress":
// half-filled square (left filled)
return wrap(<>
<rect x="2.5" y="2.5" width="11" height="11" rx="2" fill="none" stroke={c} strokeWidth="1.4"/>
<path d="M3.5 3.5 H8 V12.5 H3.5 Z" fill={c}/>
</>);
case "blocked":
// filled square with horizontal bar (no entry)
return wrap(<>
<rect x="2.5" y="2.5" width="11" height="11" rx="2" fill={c}/>
<rect x="4.5" y="7.2" width="7" height="1.6" rx="0.5" fill="white"/>
</>);
case "postponed":
// square with right-pointing chevron (deferred)
return wrap(<>
<rect x="2.5" y="2.5" width="11" height="11" rx="2" fill="none" stroke={c} strokeWidth="1.4"/>
<polyline points="6,5.5 9,8 6,10.5" fill="none" stroke={c} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</>);
case "done":
// filled square with checkmark
return wrap(<>
<rect x="2.5" y="2.5" width="11" height="11" rx="2" fill={c}/>
<polyline points="5,8 7,10.2 11,5.8" fill="none" stroke="white" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/>
</>);
case "cancelled":
// outlined square with single clean diagonal slash
return wrap(<>
<rect x="2.5" y="2.5" width="11" height="11" rx="2" fill="none" stroke={c} strokeWidth="1.4"/>
<line x1="3.5" y1="12.5" x2="12.5" y2="3.5" stroke={c} strokeWidth="1.6" strokeLinecap="round"/>
</>);
default: return null;
}
}
// ---- color status icons (ChatGPT / Linear / GitHub style) ----
// Filled circles with semantic colors and white glyphs. Self-coloring,
// not affected by currentColor — looks good on any background.
function ColorStatusIcon({ status, size = 14 }) {
const c = STATUS_COLORS[status] || STATUS_COLORS.open;
const w = size + 2;
const cx = 8, cy = 8, r = 6.4;
switch (status) {
case "open":
// empty circle — thin gray ring
return (
<svg width={w} height={w} viewBox="0 0 16 16" className={`glyph-color status-${status}`}>
<circle cx={cx} cy={cy} r={r} fill="none" stroke={c.stroke} strokeWidth="1.5"/>
</svg>
);
case "in-progress":
// half-filled pie (clockwise 50%) — Linear-style "in progress"
return (
<svg width={w} height={w} viewBox="0 0 16 16" className={`glyph-color status-${status}`}>
<circle cx={cx} cy={cy} r={r} fill="none" stroke={c.fill} strokeWidth="1.5"/>
<path d={`M 8 1.6 A 6.4 6.4 0 0 1 8 14.4 Z`} fill={c.fill}/>
</svg>
);
case "blocked":
// red filled circle with white horizontal bar
return (
<svg width={w} height={w} viewBox="0 0 16 16" className={`glyph-color status-${status}`}>
<circle cx={cx} cy={cy} r={r} fill={c.fill}/>
<rect x="4.6" y="7.2" width="6.8" height="1.6" rx="0.8" fill="white"/>
</svg>
);
case "postponed":
// purple filled circle with white right-chevron
return (
<svg width={w} height={w} viewBox="0 0 16 16" className={`glyph-color status-${status}`}>
<circle cx={cx} cy={cy} r={r} fill={c.fill}/>
<polyline points="6.4,5.4 9.4,8 6.4,10.6" fill="none" stroke="white" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
case "done":
// green filled circle with white check
return (
<svg width={w} height={w} viewBox="0 0 16 16" className={`glyph-color status-${status}`}>
<circle cx={cx} cy={cy} r={r} fill={c.fill}/>
<polyline points="5,8.2 7.2,10.3 11,5.9" fill="none" stroke="white" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
case "cancelled":
// gray filled circle with white X
return (
<svg width={w} height={w} viewBox="0 0 16 16" className={`glyph-color status-${status}`}>
<circle cx={cx} cy={cy} r={r} fill={c.fill}/>
<g stroke="white" strokeWidth="1.6" strokeLinecap="round">
<line x1="5.6" y1="5.6" x2="10.4" y2="10.4"/>
<line x1="10.4" y1="5.6" x2="5.6" y2="10.4"/>
</g>
</svg>
);
default: return null;
}
}
// ---- icons (inline SVG) ----
const Icon = ({ name, size = 14 }) => {
const paths = {
chevron: <polyline points="5,8 10,13 15,8" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>,
chevronRight: <polyline points="8,5 13,10 8,15" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>,
plus: <g stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><line x1="10" y1="4" x2="10" y2="16"/><line x1="4" y1="10" x2="16" y2="10"/></g>,
search: <g fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><circle cx="9" cy="9" r="5"/><line x1="13" y1="13" x2="17" y2="17"/></g>,
drag: <g fill="currentColor"><circle cx="7" cy="5" r="1.2"/><circle cx="13" cy="5" r="1.2"/><circle cx="7" cy="10" r="1.2"/><circle cx="13" cy="10" r="1.2"/><circle cx="7" cy="15" r="1.2"/><circle cx="13" cy="15" r="1.2"/></g>,
trash: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M5 6h10M8 6V4h4v2M6 6l1 10h6l1-10"/></g>,
cog: <g fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="10" cy="10" r="2.6"/><path d="M10 2v2M10 16v2M2 10h2M16 10h2M4.3 4.3l1.4 1.4M14.3 14.3l1.4 1.4M4.3 15.7l1.4-1.4M14.3 5.7l1.4-1.4"/></g>,
list: <g fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><line x1="6" y1="6" x2="16" y2="6"/><line x1="6" y1="10" x2="16" y2="10"/><line x1="6" y1="14" x2="16" y2="14"/><circle cx="3.5" cy="6" r="0.6" fill="currentColor"/><circle cx="3.5" cy="10" r="0.6" fill="currentColor"/><circle cx="3.5" cy="14" r="0.6" fill="currentColor"/></g>,
check: <polyline points="4,10 8,14 16,5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>,
download: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M10 3v10M5 9l5 5 5-5M3 17h14"/></g>,
upload: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M10 16V4M5 9l5-5 5 5M3 17h14"/></g>,
save: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"><path d="M4 4h9l3 3v9H4z"/><path d="M7 4v4h6V4M7 16v-5h6v5"/></g>,
refresh: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 10a7 7 0 0 1 12-5l2 2"/><path d="M17 4v3h-3"/><path d="M17 10a7 7 0 0 1-12 5l-2-2"/><path d="M3 16v-3h3"/></g>,
warn: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M10 3l8 14H2z"/><line x1="10" y1="8" x2="10" y2="12"/><circle cx="10" cy="14.5" r="0.6" fill="currentColor"/></g>,
eye: <g fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M2 10s3-5 8-5 8 5 8 5-3 5-8 5-8-5-8-5z"/><circle cx="10" cy="10" r="2"/></g>,
archive: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"><rect x="3" y="5" width="14" height="3"/><path d="M4 8v9h12V8"/><line x1="8" y1="11" x2="12" y2="11"/></g>,
edit: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 14v2h2l8-8-2-2-8 8z"/><path d="M12 4l2 2"/></g>,
addChild: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 4v6a3 3 0 0 0 3 3h4"/><line x1="14" y1="13" x2="14" y2="17"/><line x1="12" y1="15" x2="16" y2="15"/></g>,
copy: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="7" y="3" width="10" height="12" rx="1.5"/><path d="M13 17H4a1 1 0 0 1-1-1V6"/></g>,
statusDots: <g fill="currentColor"><circle cx="5" cy="10" r="1.6"/><circle cx="10" cy="10" r="1.6"/><circle cx="15" cy="10" r="1.6"/></g>
};
return (
<svg width={size} height={size} viewBox="0 0 20 20" style={{ display: "inline-block", verticalAlign: "middle", flexShrink: 0 }}>
{paths[name]}
</svg>
);
};
// --- Progress (completion %) ---
// Quantized to 0/25/50/75/100 — coarse on purpose; tasks are rarely "73% done".
const PROGRESS_STEPS = [0, 25, 50, 75, 100];
function snapProgress(p) {
if (p == null || isNaN(p)) return 0;
if (p <= 0) return 0;
if (p >= 100) return 100;
// round to nearest 25
return Math.round(p / 25) * 25;
}
// Effective progress: done is always 100 regardless of stored value;
// cancelled returns null (don't render).
function progressFor(item) {
if (item.status === "done") return 100;
if (item.status === "cancelled") return null;
return snapProgress(item.progress ?? 0);
}
// When progress should be visible inline. Done shows nothing (line-through is enough).
// Open shows only if user explicitly set >0.
function shouldShowProgress(item) {
if (item.status === "cancelled") return false;
if (item.status === "done") return false;
if (item.status === "open") return (item.progress ?? 0) > 0;
return true; // in-progress, blocked, postponed (always visible, including at 100%)
}
// Click to advance through 0/25/50/75/100, wrapping.
function cycleProgress(p) {
const i = PROGRESS_STEPS.indexOf(snapProgress(p));
return PROGRESS_STEPS[(i + 1) % PROGRESS_STEPS.length];
}
// 4-segment gauge (visual representation of 0/25/50/75/100).
// 0 = empty, 100 = all 4 filled.
function ProgressGauge({ value, onClick, interactive = false, size = "sm" }) {
const v = snapProgress(value);
const filled = v / 25; // 0..4
const segs = [0, 1, 2, 3].map(i => i < filled);
return (
<span
className={`gauge ${interactive ? "gauge-interactive" : ""} gauge-${size} gauge-v-${v}`}
onClick={onClick}
role={interactive ? "button" : undefined}
tabIndex={interactive ? 0 : undefined}
title={`${v}% complete${interactive ? " — click to change" : ""}`}
aria-label={`Progress ${v} percent`}
>
{segs.map((on, i) => (
<span key={i} className={`gauge-seg ${on ? "on" : ""}`}/>
))}
<span className="gauge-num">{v}%</span>
</span>
);
}
Object.assign(window, {
STATUSES, STATUS_BY_KEY, STATUS_ORDER, PRIORITIES,
parseDate, daysBetween, isOverdue, dueRelative, fmtTimestamp, fmtBytes, fmtRelative, fmtShortDate,
walkTree, findItem, allTags, countAll, countByStatus, filterTree,
Icon, StatusIcon, ColorStatusIcon, STATUS_COLORS, STATUS_EMOJI, StatusStyleContext,
PROGRESS_STEPS, snapProgress, progressFor, shouldShowProgress, cycleProgress, ProgressGauge
});
+51
View File
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="en" data-density="comfortable" data-show-ids="false" data-paper="true">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Personal Backlog</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<template id="__bundler_thumbnail">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<rect width="200" height="200" fill="#f7f3ec"/>
<g transform="translate(60,60)" stroke="#3a3530" stroke-width="3" fill="none" stroke-linecap="round">
<rect x="0" y="0" width="80" height="80" rx="4" fill="#ffffff"/>
<line x1="14" y1="22" x2="66" y2="22"/>
<line x1="14" y1="40" x2="56" y2="40"/>
<line x1="14" y1="58" x2="60" y2="58"/>
<circle cx="8" cy="22" r="2.5" fill="#3a3530"/>
<circle cx="8" cy="40" r="2.5" fill="#3a3530"/>
<circle cx="8" cy="58" r="2.5" fill="#3a3530"/>
</g>
</svg>
</template>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<!-- data.jsx: test-only seed data — not loaded by default.
To run with sample data, uncomment the line below:
<script type="text/babel" src="data.jsx"></script> -->
<script type="text/babel" src="helpers.jsx"></script>
<script type="text/babel" src="storage.jsx"></script>
<script type="text/babel" src="tweaks-panel.jsx"></script>
<script type="text/babel" src="filter-panel.jsx"></script>
<script type="text/babel" src="tree.jsx"></script>
<script type="text/babel" src="dialogs.jsx"></script>
<script type="text/babel" src="admin.jsx"></script>
<script type="text/babel" src="app.jsx"></script>
<script type="text/babel">
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<App />);
</script>
</body>
</html>
+620
View File
@@ -0,0 +1,620 @@
{
"name": "personal-backlog-bundler",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "personal-backlog-bundler",
"version": "1.0.0",
"dependencies": {
"@babel/core": "^7.29.0",
"@babel/preset-react": "^7.28.5"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/compat-data": {
"version": "7.29.3",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz",
"integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/core": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-compilation-targets": "^7.28.6",
"@babel/helper-module-transforms": "^7.28.6",
"@babel/helpers": "^7.28.6",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/traverse": "^7.29.0",
"@babel/types": "^7.29.0",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
"json5": "^2.2.3",
"semver": "^6.3.1"
},
"engines": {
"node": ">=6.9.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/babel"
}
},
"node_modules/@babel/generator": {
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-annotate-as-pure": {
"version": "7.27.3",
"resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz",
"integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.27.3"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.28.6",
"@babel/helper-validator-option": "^7.27.1",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-globals": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-imports": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.28.6",
"@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.28.6",
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/traverse": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0"
}
},
"node_modules/@babel/helper-plugin-utils": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
"integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-option": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helpers": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
"license": "MIT",
"dependencies": {
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/plugin-syntax-jsx": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz",
"integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==",
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-react-display-name": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz",
"integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==",
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-react-jsx": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz",
"integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==",
"license": "MIT",
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.3",
"@babel/helper-module-imports": "^7.28.6",
"@babel/helper-plugin-utils": "^7.28.6",
"@babel/plugin-syntax-jsx": "^7.28.6",
"@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-react-jsx-development": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz",
"integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==",
"license": "MIT",
"dependencies": {
"@babel/plugin-transform-react-jsx": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-react-pure-annotations": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz",
"integrity": "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==",
"license": "MIT",
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/preset-react": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.28.5.tgz",
"integrity": "sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==",
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-validator-option": "^7.27.1",
"@babel/plugin-transform-react-display-name": "^7.28.0",
"@babel/plugin-transform-react-jsx": "^7.27.1",
"@babel/plugin-transform-react-jsx-development": "^7.27.1",
"@babel/plugin-transform-react-pure-annotations": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/parser": "^7.28.6",
"@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0",
"debug": "^4.3.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.27",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz",
"integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/browserslist": {
"version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
"electron-to-chromium": "^1.5.328",
"node-releases": "^2.0.36",
"update-browserslist-db": "^1.2.3"
},
"bin": {
"browserslist": "cli.js"
},
"engines": {
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001791",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
"integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "CC-BY-4.0"
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.349",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz",
"integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==",
"license": "ISC"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
},
"engines": {
"node": ">=6"
}
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"license": "ISC",
"dependencies": {
"yallist": "^3.0.2"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.38",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
"integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"escalade": "^3.2.0",
"picocolors": "^1.1.1"
},
"bin": {
"update-browserslist-db": "cli.js"
},
"peerDependencies": {
"browserslist": ">= 4.21.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"license": "ISC"
}
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "personal-backlog-bundler",
"version": "1.0.0",
"description": "Flattens design-v2 into a single self-contained HTML artifact",
"private": true,
"dependencies": {
"@babel/core": "^7.29.0",
"@babel/preset-react": "^7.28.5"
}
}
+543
View File
@@ -0,0 +1,543 @@
// Storage layer: Parser, ApiBackend, DirectBackend, Storage detection, SyncPoller.
// Compatible with web/index.html and server/server.py file format.
//
// File format (backlog.md):
// # Backlog
// <!-- SECTION: ENTRIES -->
// - [ ] [P1] Title *(due: ..., reason: ..., tags: tag1 tag2, progress: 50)*
// - [x] [P0] Child *(progress: 100)*
// <!-- SECTION: HISTORY -->
// | Timestamp | Item ID | Action | Details |
// <!-- SECTION: INTEGRITY -->
// <!-- saved: ... | checksum: sha256:... | entries: N | history: N -->
// ---- Parser ----
const Parser = {
async parse(text) {
if (!text || !text.trim()) {
return { entries: [], history: [], meta: null, checksumOk: false };
}
const entStart = text.indexOf('<!-- SECTION: ENTRIES -->');
const histStart = text.indexOf('<!-- SECTION: HISTORY -->');
const intStart = text.indexOf('<!-- SECTION: INTEGRITY -->');
const entriesText = entStart !== -1
? text.slice(entStart, histStart !== -1 ? histStart : intStart !== -1 ? intStart : text.length)
: text;
const historyText = histStart !== -1
? text.slice(histStart, intStart !== -1 ? intStart : text.length)
: '';
// Parse integrity marker
let meta = null;
let checksumOk = false;
if (intStart !== -1) {
const m = text.slice(intStart).match(
/saved:\s*([^|]+?)\s*\|\s*checksum:\s*([^|]+?)\s*\|\s*entries:\s*(\d+)\s*\|\s*history:\s*(\d+)/
);
if (m) {
meta = {
saved: m[1].trim(),
checksum: m[2].trim(),
entryCount: +m[3],
historyCount: +m[4],
};
// Strip section-marker prefix + surrounding whitespace to get raw content,
// matching server.py's compute_checksum() which uses raw (marker-free) payload.
const rawEnt = entriesText.slice('<!-- SECTION: ENTRIES -->'.length).trim();
const rawHist = historyText.slice('<!-- SECTION: HISTORY -->'.length).trim();
const payload = rawEnt + '\n' + rawHist;
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload));
const hash = 'sha256:' + Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
checksumOk = hash === meta.checksum;
}
}
// Parse history table
const history = [];
for (const line of historyText.split('\n')) {
if (!line.trim().startsWith('|')) continue;
const parts = line.split('|').map(s => s.trim()).filter(Boolean);
if (parts.length >= 4 && parts[0] !== 'Timestamp' && !parts[0].match(/^-+$/)) {
history.push({ timestamp: parts[0], itemId: parts[1], action: parts[2], details: parts[3] });
}
}
// Parse entries (indented markdown list)
const GLYPH_MAP = { ' ': 'open', 'x': 'done', '!': 'blocked', '>': 'postponed', '/': 'in-progress', '-': 'cancelled' };
let idSeq = Date.now();
const uid = () => 'i-' + (++idSeq).toString(36);
const entries = [];
const stack = [{ children: entries, depth: -1, level: 0 }];
for (const line of entriesText.split('\n')) {
const m = line.match(/^(\s*)[-*]\s*\[([ x!>/\-])\]\s*(.*)$/);
if (!m) continue;
const depth = m[1].length / 2;
const status = GLYPH_MAP[m[2]] || 'open';
const raw = m[3].trim();
let title = raw, due = null, reason = null, tags = [], priority = 'P1', progress = 0;
const metaM = raw.match(/^(.*?)\s*\*\((.*)\)\*\s*$/);
if (metaM) {
title = metaM[1].trim();
for (const p of metaM[2].split(',').map(s => s.trim())) {
if (p.startsWith('due:')) due = p.slice(4).trim();
else if (p.startsWith('reason:')) reason = p.slice(7).trim();
else if (p.startsWith('tags:')) tags = p.slice(5).trim().split(/\s+/).filter(Boolean);
else if (p.startsWith('priority:')) priority = p.slice(9).trim().toUpperCase();
else if (p.startsWith('progress:')) progress = parseInt(p.slice(9).trim(), 10) || 0;
}
}
// Priority prefix [P0] embedded in title
const pm = title.match(/^\[(P\d)\]\s*/);
if (pm) { priority = pm[1]; title = title.slice(pm[0].length); }
while (stack.length > 1 && stack[stack.length - 1].depth >= depth) stack.pop();
const parentLevel = stack[stack.length - 1].level;
const item = {
id: uid(),
level: parentLevel + 1,
title, status, priority, due, reason, tags,
progress: status === 'done' ? 100 : progress,
collapsed: false,
children: [],
};
stack[stack.length - 1].children.push(item);
stack.push({ children: item.children, depth, level: item.level });
}
return { entries, history, meta, checksumOk };
},
async serialize(data) {
const entriesText = this._serializeEntries(data.entries);
const historyText = this._serializeHistory(data.history);
const payload = entriesText + '\n' + historyText;
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload));
const hash = 'sha256:' + Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
const saved = new Date().toISOString().slice(0, 19) + 'Z';
const nEntries = this._countItems(data.entries);
const nHistory = data.history.length;
const marker = `<!-- saved: ${saved} | checksum: ${hash} | entries: ${nEntries} | history: ${nHistory} -->`;
return `# Backlog\n\n<!-- SECTION: ENTRIES -->\n\n${entriesText}\n\n<!-- SECTION: HISTORY -->\n\n${historyText}\n\n<!-- SECTION: INTEGRITY -->\n\n${marker}\n`;
},
_serializeEntries(items, depth = 0) {
const indent = ' '.repeat(depth);
const GLYPH = { open: ' ', 'in-progress': '/', blocked: '!', postponed: '>', done: 'x', cancelled: '-' };
const lines = [];
for (const it of items) {
const meta = [];
if (it.due) meta.push(`due: ${it.due}`);
if (it.reason) meta.push(`reason: ${it.reason}`);
if (it.tags?.length) meta.push(`tags: ${it.tags.join(' ')}`);
if (it.priority && it.priority !== 'P1') meta.push(`priority: ${it.priority}`);
if ((it.progress ?? 0) > 0) meta.push(`progress: ${it.progress}`);
const metaStr = meta.length ? ` *(${meta.join(', ')})*` : '';
const prefix = it.priority ? `[${it.priority}] ` : '';
lines.push(`${indent}- [${GLYPH[it.status] || ' '}] ${prefix}${it.title}${metaStr}`);
if (it.children?.length) {
lines.push(...this._serializeEntries(it.children, depth + 1).split('\n'));
}
}
return lines.join('\n');
},
_serializeHistory(rows) {
const lines = ['| Timestamp | Item ID | Action | Details |', '|-----------|---------|--------|---------|'];
for (const r of rows) lines.push(`| ${r.timestamp} | ${r.itemId} | ${r.action} | ${r.details} |`);
return lines.join('\n');
},
_countItems(items) {
let n = 0;
for (const it of items) { n++; n += this._countItems(it.children || []); }
return n;
},
};
// ---- API Backend (python server.py) ----
const ApiBackend = {
async detect() {
if (location.protocol === 'file:') return false; // no server in file:// context
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 800);
try {
const r = await fetch('/api/health', { signal: ctrl.signal });
clearTimeout(t);
return r.ok;
} catch { clearTimeout(t); return false; }
},
async load() {
const r = await fetch('/api/backlog');
if (!r.ok) throw new Error('API load failed: ' + r.status);
return r.json(); // { content, checksum }
},
async save(content) {
const r = await fetch('/api/backlog', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
});
if (!r.ok) throw new Error('API save failed: ' + r.status);
return r.json(); // { ok, checksum, saved }
},
async listBackups() {
try {
const r = await fetch('/api/backups');
if (!r.ok) return [];
return (await r.json()).backups || [];
} catch { return []; }
},
async restoreBackup(name) {
const r = await fetch('/api/backups/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
return r.ok ? r.json() : { ok: false };
},
async getHealthInfo() {
try {
const r = await fetch('/api/health');
if (!r.ok) return {};
const j = await r.json();
return {
masterSize: j.masterSize || 0,
statsSize: 0,
masterPath: j.masterPath || '',
backupsPath: j.backupsPath || '',
};
} catch { return {}; }
},
};
// ---- Direct File Backend (File System Access API) ----
const DirectBackend = {
dirHandle: null,
_DB_NAME: 'pb-storage-v2',
_STORE: 'handles',
_KEY: 'root',
async detect() {
return typeof window.showDirectoryPicker === 'function';
},
// Silent reconnect — no user gesture. Returns true if the stored handle
// is still accessible and permission is already granted.
async tryAutoConnect() {
this.dirHandle = await this._loadHandle();
if (!this.dirHandle) return false;
try {
return (await this.dirHandle.queryPermission({ mode: 'readwrite' })) === 'granted';
} catch { return false; }
},
// Explicit connect — requires a user gesture (button click).
// Shows the directory picker on first use; re-prompts for permission on subsequent uses.
async connect() {
let handle = await this._loadHandle();
if (!handle) {
handle = await window.showDirectoryPicker({ mode: 'readwrite' });
await this._saveHandle(handle);
}
const perm = await handle.requestPermission({ mode: 'readwrite' });
if (perm !== 'granted') throw new Error('Permission denied');
this.dirHandle = handle;
},
_loadHandle() {
return new Promise(res => {
const req = indexedDB.open(this._DB_NAME, 1);
req.onupgradeneeded = e => e.target.result.createObjectStore(this._STORE);
req.onsuccess = e => {
const get = e.target.result.transaction(this._STORE, 'readonly').objectStore(this._STORE).get(this._KEY);
get.onsuccess = () => res(get.result || null);
get.onerror = () => res(null);
};
req.onerror = () => res(null);
});
},
_saveHandle(handle) {
return new Promise((res, rej) => {
const req = indexedDB.open(this._DB_NAME, 1);
req.onsuccess = e => {
const tx = e.target.result.transaction(this._STORE, 'readwrite');
tx.objectStore(this._STORE).put(handle, this._KEY);
tx.oncomplete = () => res();
tx.onerror = () => rej(tx.error);
};
});
},
async _readFile(name) {
try {
const h = await this.dirHandle.getFileHandle(name);
return (await h.getFile()).text();
} catch { return null; }
},
async _writeFile(name, content, dirHandle) {
const dir = dirHandle || this.dirHandle;
const h = await dir.getFileHandle(name, { create: true });
const w = await h.createWritable();
await w.write(content);
await w.close();
},
async load() {
const content = await this._readFile('backlog.md');
return { content: content || '', checksum: '' };
},
async save(content) {
await this._writeFile('backlog.md', content);
// Create backup
try {
const ts = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
const dir = await this.dirHandle.getDirectoryHandle('backups', { create: true });
await this._writeFile(`backlog_${ts}.md`, content, dir);
} catch { /* backup failure is non-fatal */ }
return { ok: true };
},
async listBackups() {
try {
const dir = await this.dirHandle.getDirectoryHandle('backups', { create: true });
const backups = [];
for await (const [name, h] of dir.entries()) {
if (!name.startsWith('backlog_') || !name.endsWith('.md')) continue;
const f = await h.getFile();
const text = await f.text();
backups.push({
name,
size: f.size,
timestamp: new Date(f.lastModified).toISOString(),
valid: text.includes('<!-- SECTION: INTEGRITY -->'),
});
}
return backups.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
} catch { return []; }
},
async restoreBackup(name) {
try {
const dir = await this.dirHandle.getDirectoryHandle('backups');
const h = await dir.getFileHandle(name);
const text = await (await h.getFile()).text();
await this._writeFile('backlog.md', text);
return { ok: true };
} catch (e) { return { ok: false, error: e.message }; }
},
async getHealthInfo() {
try {
const f = await (await this.dirHandle.getFileHandle('backlog.md')).getFile();
return { masterSize: f.size, statsSize: 0, dirName: this.dirHandle?.name ?? null };
} catch { return { masterSize: 0, statsSize: 0, dirName: this.dirHandle?.name ?? null }; }
},
};
// ---- Storage — detect backend and delegate ----
const Storage = {
backend: null,
mode: 'local', // 'api' | 'direct' | 'local'
async detect() {
if (await ApiBackend.detect()) {
this.backend = ApiBackend;
this.mode = 'api';
return 'api';
}
if (await DirectBackend.detect()) {
this.backend = DirectBackend;
this.mode = 'direct';
return 'direct';
}
this.mode = 'local';
return 'local';
},
// Try to reconnect silently (no user gesture). Returns true if connected.
// For API mode this is always true; for Direct it checks the stored handle.
async tryAutoConnect() {
if (this.backend === DirectBackend) return DirectBackend.tryAutoConnect();
return true;
},
// Explicit connect — call from a user-gesture handler (button click).
async connect() {
if (this.backend === DirectBackend) await DirectBackend.connect();
},
// True only when a backend is detected AND fully initialised (dirHandle set for Direct).
isConnected() {
if (!this.backend) return false;
if (this.backend === DirectBackend) return !!DirectBackend.dirHandle;
return true;
},
async load() { return this.backend?.load() ?? null; },
async save(content) { return this.backend?.save(content) ?? null; },
async listBackups() { return this.backend?.listBackups() ?? []; },
async restoreBackup(name) { return this.backend?.restoreBackup(name) ?? { ok: false }; },
async getHealthInfo() { return this.backend?.getHealthInfo?.() ?? {}; },
};
// ---- SyncPoller — detect external file changes (5s interval) ----
const SyncPoller = {
_timer: null,
lastChecksum: '',
_onExternalChange: null,
_isDirty: null,
start({ onExternalChange, isDirty }) {
this._onExternalChange = onExternalChange;
this._isDirty = isDirty;
this._timer = setInterval(() => this._tick(), 5000);
},
stop() {
if (this._timer) { clearInterval(this._timer); this._timer = null; }
},
async _tick() {
if (Storage.mode === 'local' || !Storage.backend) return;
try {
const raw = await Storage.load();
if (!raw?.content) return;
const parsed = await Parser.parse(raw.content);
const cs = parsed.meta?.checksum || '';
if (!cs || cs === this.lastChecksum) return;
this.lastChecksum = cs;
if (this._isDirty?.()) {
this._onExternalChange?.('warn', null);
} else {
this._onExternalChange?.('reload', parsed);
}
} catch { /* silent on poll errors */ }
},
};
// ---- buildDataFromStorage — assemble the full App data object from parsed content ----
async function buildDataFromStorage(parsed, backups, storageMode, sizeInfo = {}) {
const { entries, history, meta, checksumOk } = parsed;
// Assign level field (derived from tree depth, not stored in file)
function setLevels(items, level) {
for (const it of items) {
it.level = level;
if (it.children?.length) setLevels(it.children, level + 1);
}
}
setLevels(entries, 1);
// Migrate: ensure progress field is always a number
const progressDefaults = { done: 100, cancelled: 0, 'in-progress': 50, blocked: 25, postponed: 25, open: 0 };
walkTree(entries, it => {
if (typeof it.progress !== 'number') it.progress = progressDefaults[it.status] ?? 0;
else if (it.status === 'done') it.progress = 100;
});
// Status counts
const statusMix = { open: 0, 'in-progress': 0, blocked: 0, postponed: 0, done: 0, cancelled: 0 };
walkTree(entries, it => { statusMix[it.status] = (statusMix[it.status] || 0) + 1; });
// Most active item by history event count
const actCounts = {};
walkTree(entries, it => { actCounts[it.id] = 0; });
for (const h of history) { if (h.itemId in actCounts) actCounts[h.itemId]++; }
const topId = Object.entries(actCounts).sort((a, b) => b[1] - a[1])[0]?.[0];
const topItem = topId ? findItem(entries, topId) : null;
const mostActiveProject = topItem?.title || entries[0]?.title || '—';
// Recent activity (last 7 days)
const sevenAgo = new Date(Date.now() - 7 * 86400000).toISOString();
const recentH = history.filter(h => h.timestamp >= sevenAgo);
const createdThisWeek = recentH.filter(h => h.action === 'item_created').length;
const completedThisWeek = recentH.filter(h => h.action === 'status_changed' && h.details.includes('→ done')).length;
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const completionByDay = dayNames.map(day => ({ day, count: 0 }));
const createdByDay = dayNames.map(day => ({ day, count: 0 }));
for (const h of recentH) {
const dow = (new Date(h.timestamp).getDay() + 6) % 7; // 0=Mon
if (h.action === 'status_changed' && h.details.includes('→ done')) completionByDay[dow].count++;
if (h.action === 'item_created') createdByDay[dow].count++;
}
// Average days spent in-progress: pair "→ in-progress" with next "in-progress →" per item.
const ipStart = {}; // itemId → start timestamp ms
const ipDurations = [];
for (const h of [...history].reverse()) { // chronological order
if (h.action !== 'status_changed') continue;
if (h.details.includes('→ in-progress')) ipStart[h.itemId] = new Date(h.timestamp).getTime();
else if (h.details.startsWith('in-progress →') && ipStart[h.itemId]) {
const days = (new Date(h.timestamp).getTime() - ipStart[h.itemId]) / 86400000;
if (days >= 0 && days < 365) ipDurations.push(days);
delete ipStart[h.itemId];
}
}
const avgInProgressDays = ipDurations.length > 0
? +(ipDurations.reduce((a, b) => a + b, 0) / ipDurations.length).toFixed(1)
: null;
const historyOldest = history.length > 0 ? history[history.length - 1].timestamp : null;
const backupDirSize = backups.reduce((s, b) => s + (b.size || 0), 0);
const historySize = new TextEncoder().encode(JSON.stringify(history)).length;
const modeLabel = storageMode === 'api' ? 'API server'
: storageMode === 'direct' ? 'Direct (File System API)'
: 'localStorage only';
return {
entries,
history,
meta: meta || { saved: null, checksum: '—', entryCount: countAll(entries), historyCount: history.length },
health: {
integrityOk: checksumOk,
lastSave: meta?.saved || null,
lastBackup: backups[0]?.timestamp || null,
masterSize: sizeInfo.masterSize || 0,
backupDirSize,
backupCount: backups.length,
statsSize: sizeInfo.statsSize || 0,
historySize,
historyOldest,
mode: modeLabel,
masterPath: sizeInfo.masterPath || (sizeInfo.dirName ? `${sizeInfo.dirName}/backlog.md` : null),
backupsPath: sizeInfo.backupsPath || (sizeInfo.dirName ? `${sizeInfo.dirName}/backups/` : null),
},
stats: {
createdThisWeek,
completedThisWeek,
avgInProgressDays,
mostActiveProject,
completionByDay,
createdByDay,
statusMix,
},
backups,
};
}
Object.assign(window, {
Parser,
ApiBackend,
DirectBackend,
Storage,
SyncPoller,
buildDataFromStorage,
});
+1063
View File
File diff suppressed because it is too large Load Diff
+238
View File
@@ -0,0 +1,238 @@
// Backlog tree view: rows, expand/collapse, status cycling, drag handle, add/delete.
const { useState, useRef, useEffect, useMemo } = React;
// Inline priority popover
function PriorityPicker({ value, onChange, onClose }) {
const ref = useRef(null);
useEffect(() => {
const close = (e) => { if (!ref.current?.contains(e.target)) onClose(); };
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [onClose]);
return (
<div className="pri-pop" ref={ref} onClick={(e) => e.stopPropagation()}>
{PRIORITIES.map(p => (
<button key={p} className={`pri-pop-item pri-${p} ${value === p ? "active" : ""}`}
onClick={() => { onChange(p); onClose(); }}>
{p === "P0" && <span className="p0-dot"/>}
<span>{p}</span>
<span className="pri-pop-hint">{p === "P0" ? "Burning" : p === "P1" ? "High" : p === "P2" ? "Normal" : "Low"}</span>
</button>
))}
</div>
);
}
// Inline status popover (replaces ●●● menu)
function StatusPopover({ value, style, onChange, onClose, anchorRect }) {
const ref = useRef(null);
useEffect(() => {
const close = (e) => { if (!ref.current?.contains(e.target)) onClose(); };
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [onClose]);
return (
<div className="status-pop" ref={ref} onClick={(e) => e.stopPropagation()}>
{STATUSES.map(s => (
<button key={s.key} className={`status-pop-item ${value === s.key ? "active" : ""}`}
onClick={() => { onChange(s.key); onClose(); }}>
<StatusIcon status={s.key} style={style} size={14}/>
<span>{s.label}</span>
</button>
))}
</div>
);
}
// Single tree row
function ItemRow({ item, depth, expanded, hasChildren, statusStyle,
onToggle, onSetStatus, onAddChild, onDelete, onEdit, onSetPriority,
onMoveUp, onMoveDown, canMoveUp, canMoveDown,
onDragStart, onDragOver, onDrop,
dimmed, query, dropTarget }) {
const [statusOpen, setStatusOpen] = useState(false);
const [priOpen, setPriOpen] = useState(false);
const overdue = isOverdue(item);
const isP0 = item.priority === "P0" && item.status !== "done" && item.status !== "cancelled";
const isDone = item.status === "done" || item.status === "cancelled";
const renderTitle = () => {
if (!query) return item.title;
const i = item.title.toLowerCase().indexOf(query.toLowerCase());
if (i < 0) return item.title;
return (<>
{item.title.slice(0, i)}
<mark className="hl">{item.title.slice(i, i + query.length)}</mark>
{item.title.slice(i + query.length)}
</>);
};
return (
<div
className={`row level-${depth} ${dimmed ? "dimmed" : ""} ${isP0 ? "p0" : ""} ${dropTarget ? "drop-target" : ""}`}
draggable
onDragStart={(e) => onDragStart?.(e, item)}
onDragOver={(e) => onDragOver?.(e, item)}
onDrop={(e) => onDrop?.(e, item)}
>
<span className="row-rail" style={{ width: depth * 18 }}/>
<button
className={`twisty ${hasChildren ? "" : "empty"}`}
onClick={hasChildren ? onToggle : undefined}
aria-label={expanded ? "Collapse" : "Expand"}
tabIndex={hasChildren ? 0 : -1}
>
{hasChildren && <Icon name={expanded ? "chevron" : "chevronRight"} size={12}/>}
</button>
<span className="drag-handle" title="Drag to reorder within priority"><Icon name="drag" size={14}/></span>
<span className="status-wrap">
<button
className="status-btn"
onClick={() => setStatusOpen(v => !v)}
title={`${STATUS_BY_KEY[item.status].label} — click to change`}
>
<StatusIcon status={item.status} style={statusStyle} size={14}/>
</button>
{statusOpen && (
<StatusPopover value={item.status} style={statusStyle}
onChange={onSetStatus} onClose={() => setStatusOpen(false)}/>
)}
</span>
<span className="pri-wrap">
<button className={`pri pri-${item.priority}`} onClick={() => setPriOpen(v => !v)} title="Change priority">
{item.priority === "P0" && <span className="p0-dot"/>}
{item.priority}
</button>
{priOpen && (
<PriorityPicker value={item.priority}
onChange={(p) => onSetPriority(p)}
onClose={() => setPriOpen(false)}/>
)}
</span>
<span className={`title ${isDone ? "done" : ""}`} onDoubleClick={onEdit}>
{renderTitle()}
</span>
{shouldShowProgress(item) && (
<ProgressGauge value={progressFor(item)} />
)}
{item.due && (
<span className={`due ${overdue ? "overdue" : ""}`}>{dueRelative(item)}</span>
)}
{item.tags?.length > 0 && (
<span className="tags">
{item.tags.map(t => <span key={t} className="tag">#{t}</span>)}
</span>
)}
{item.status === "blocked" && item.reason && (
<span className="reason" title={item.reason}>↳ {item.reason}</span>
)}
<span className="row-spacer"/>
<span className="row-actions">
<button className="icon-btn" onClick={onMoveUp} disabled={!canMoveUp} data-tip="Move up" aria-label="Move up">
<svg width="14" height="14" viewBox="0 0 16 16"><polyline points="4,10 8,5 12,10" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
<button className="icon-btn" onClick={onMoveDown} disabled={!canMoveDown} data-tip="Move down" aria-label="Move down">
<svg width="14" height="14" viewBox="0 0 16 16"><polyline points="4,6 8,11 12,6" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
<span className="action-sep"/>
{depth < 3 && (
<button className="icon-btn" onClick={onAddChild} data-tip="Add sub-item" aria-label="Add sub-item">
<Icon name="addChild" size={14}/>
</button>
)}
<button className="icon-btn" onClick={onEdit} data-tip="Edit item" aria-label="Edit item">
<Icon name="edit" size={13}/>
</button>
<button className="icon-btn danger" onClick={onDelete} data-tip="Delete" aria-label="Delete">
<Icon name="trash" size={13}/>
</button>
</span>
</div>
);
}
// Expanded-state map kept in parent so filtering doesn't lose it.
function BacklogTree({ items, expandedMap, setExpanded, onMutate, query, statusStyle, manualOrder }) {
const [draggedId, setDraggedId] = useState(null);
const [dropTargetId, setDropTargetId] = useState(null);
const onDragStart = (e, item) => {
setDraggedId(item.id);
e.dataTransfer.effectAllowed = "move";
try { e.dataTransfer.setData("text/plain", item.id); } catch (_) {}
};
const onDragOver = (e, item) => {
if (!draggedId || draggedId === item.id) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDropTargetId(item.id);
};
const onDrop = (e, item) => {
e.preventDefault();
if (draggedId && draggedId !== item.id) {
onMutate.reorder(draggedId, item.id);
}
setDraggedId(null); setDropTargetId(null);
};
function renderList(list, depth, parentList) {
return list.map((item, idx) => {
const expanded = expandedMap[item.id] ?? !item.collapsed;
const hasChildren = item.children?.length > 0;
const forceOpen = query && item._matches !== true && hasChildren;
const showKids = (expanded || forceOpen) && hasChildren;
// can-move logic: only swap with adjacent same-priority sibling
const samePri = parentList
.map((x, i) => ({ x, i }))
.filter(({ x }) => x.priority === item.priority);
const myIdx = samePri.findIndex(({ x }) => x.id === item.id);
const canMoveUp = myIdx > 0;
const canMoveDown = myIdx >= 0 && myIdx < samePri.length - 1;
return (
<React.Fragment key={item.id}>
<ItemRow
item={item} depth={depth}
expanded={expanded || forceOpen}
hasChildren={hasChildren}
statusStyle={statusStyle}
dimmed={query && item._matches === false}
dropTarget={dropTargetId === item.id && draggedId !== item.id}
query={query}
onToggle={() => setExpanded(item.id, !expanded)}
onSetStatus={(s) => onMutate.setStatus(item.id, s)}
onSetPriority={(p) => onMutate.setPriority(item.id, p)}
onAddChild={() => onMutate.addChild(item.id)}
onDelete={() => onMutate.deleteItem(item.id)}
onEdit={() => onMutate.editItem(item.id)}
onMoveUp={() => onMutate.moveWithinPriority(item.id, -1)}
onMoveDown={() => onMutate.moveWithinPriority(item.id, +1)}
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
/>
{showKids && renderList(item.children, depth + 1, item.children)}
</React.Fragment>
);
});
}
return <div className="tree">{renderList(items, 0, items)}</div>;
}
window.BacklogTree = BacklogTree;
+425
View File
@@ -0,0 +1,425 @@
// tweaks-panel.jsx
// Reusable Tweaks shell + form-control helpers.
//
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
//
// Usage (in an HTML file that loads React + Babel):
//
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
// "primaryColor": "#D97757",
// "fontSize": 16,
// "density": "regular",
// "dark": false
// }/*EDITMODE-END*/;
//
// function App() {
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
// return (
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
// Hello
// <TweaksPanel>
// <TweakSection label="Typography" />
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
// onChange={(v) => setTweak('fontSize', v)} />
// <TweakRadio label="Density" value={t.density}
// options={['compact', 'regular', 'comfy']}
// onChange={(v) => setTweak('density', v)} />
// <TweakSection label="Theme" />
// <TweakColor label="Primary" value={t.primaryColor}
// onChange={(v) => setTweak('primaryColor', v)} />
// <TweakToggle label="Dark mode" value={t.dark}
// onChange={(v) => setTweak('dark', v)} />
// </TweaksPanel>
// </div>
// );
// }
//
// ─────────────────────────────────────────────────────────────────────────────
const __TWEAKS_STYLE = `
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
background:rgba(250,249,247,.78);color:#29261b;
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
.twk-hd{display:flex;align-items:center;justify-content:space-between;
padding:10px 8px 10px 14px;cursor:move;user-select:none}
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
overflow-y:auto;overflow-x:hidden;min-height:0;
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
.twk-body::-webkit-scrollbar{width:8px}
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
border:2px solid transparent;background-clip:content-box}
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
border:2px solid transparent;background-clip:content-box}
.twk-row{display:flex;flex-direction:column;gap:5px}
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
color:rgba(41,38,27,.72)}
.twk-lbl>span:first-child{font-weight:500}
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
color:rgba(41,38,27,.45);padding:10px 0 0}
.twk-sect:first-child{padding-top:0}
.twk-field{appearance:none;width:100%;height:26px;padding:0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
select.twk-field{padding-right:22px;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
background-repeat:no-repeat;background-position:right 8px center}
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
width:14px;height:14px;border-radius:50%;background:#fff;
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
background:rgba(0,0,0,.06);user-select:none}
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
.twk-seg.dragging .twk-seg-thumb{transition:none}
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
overflow-wrap:anywhere}
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
.twk-toggle[data-on="1"]{background:#34c759}
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
.twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
user-select:none;padding-right:8px}
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
outline:none;color:inherit;-moz-appearance:textfield}
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
-webkit-appearance:none;margin:0}
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
.twk-btn:hover{background:rgba(0,0,0,.88)}
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
background:transparent;flex-shrink:0}
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
`;
// ── useTweaks ───────────────────────────────────────────────────────────────
// Single source of truth for tweak values. setTweak persists via the host
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
function useTweaks(defaults) {
const [values, setValues] = React.useState(defaults);
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
// useState-style call doesn't write a "[object Object]" key into the persisted
// JSON block.
const setTweak = React.useCallback((keyOrEdits, val) => {
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
? keyOrEdits : { [keyOrEdits]: val };
setValues((prev) => ({ ...prev, ...edits }));
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
}, []);
return [values, setTweak];
}
// ── TweaksPanel ─────────────────────────────────────────────────────────────
// Floating shell. Registers the protocol listener BEFORE announcing
// availability — if the announce ran first, the host's activate could land
// before our handler exists and the toolbar toggle would silently no-op.
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
// is what actually hides the panel.
function TweaksPanel({ title = 'Tweaks', children }) {
const [open, setOpen] = React.useState(false);
const dragRef = React.useRef(null);
const offsetRef = React.useRef({ x: 16, y: 16 });
const PAD = 16;
const clampToViewport = React.useCallback(() => {
const panel = dragRef.current;
if (!panel) return;
const w = panel.offsetWidth, h = panel.offsetHeight;
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
offsetRef.current = {
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
};
panel.style.right = offsetRef.current.x + 'px';
panel.style.bottom = offsetRef.current.y + 'px';
}, []);
React.useEffect(() => {
if (!open) return;
clampToViewport();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', clampToViewport);
return () => window.removeEventListener('resize', clampToViewport);
}
const ro = new ResizeObserver(clampToViewport);
ro.observe(document.documentElement);
return () => ro.disconnect();
}, [open, clampToViewport]);
React.useEffect(() => {
const onMsg = (e) => {
const t = e?.data?.type;
if (t === '__activate_edit_mode') setOpen(true);
else if (t === '__deactivate_edit_mode') setOpen(false);
};
window.addEventListener('message', onMsg);
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
return () => window.removeEventListener('message', onMsg);
}, []);
const dismiss = () => {
setOpen(false);
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
};
const onDragStart = (e) => {
const panel = dragRef.current;
if (!panel) return;
const r = panel.getBoundingClientRect();
const sx = e.clientX, sy = e.clientY;
const startRight = window.innerWidth - r.right;
const startBottom = window.innerHeight - r.bottom;
const move = (ev) => {
offsetRef.current = {
x: startRight - (ev.clientX - sx),
y: startBottom - (ev.clientY - sy),
};
clampToViewport();
};
const up = () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
};
if (!open) return null;
return (
<>
<style>{__TWEAKS_STYLE}</style>
<div ref={dragRef} className="twk-panel" data-noncommentable=""
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
<div className="twk-hd" onMouseDown={onDragStart}>
<b>{title}</b>
<button className="twk-x" aria-label="Close tweaks"
onMouseDown={(e) => e.stopPropagation()}
onClick={dismiss}>✕</button>
</div>
<div className="twk-body">{children}</div>
</div>
</>
);
}
// ── Layout helpers ──────────────────────────────────────────────────────────
function TweakSection({ label, children }) {
return (
<>
<div className="twk-sect">{label}</div>
{children}
</>
);
}
function TweakRow({ label, value, children, inline = false }) {
return (
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
<div className="twk-lbl">
<span>{label}</span>
{value != null && <span className="twk-val">{value}</span>}
</div>
{children}
</div>
);
}
// ── Controls ────────────────────────────────────────────────────────────────
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
return (
<TweakRow label={label} value={`${value}${unit}`}>
<input type="range" className="twk-slider" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(Number(e.target.value))} />
</TweakRow>
);
}
function TweakToggle({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
role="switch" aria-checked={!!value}
onClick={() => onChange(!value)}><i /></button>
</div>
);
}
function TweakRadio({ label, value, options, onChange }) {
const trackRef = React.useRef(null);
const [dragging, setDragging] = React.useState(false);
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
const n = opts.length;
// The active value is read by pointer-move handlers attached for the lifetime
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
const valueRef = React.useRef(value);
valueRef.current = value;
const segAt = (clientX) => {
const r = trackRef.current.getBoundingClientRect();
const inner = r.width - 4;
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
return opts[Math.max(0, Math.min(n - 1, i))].value;
};
const onPointerDown = (e) => {
setDragging(true);
const v0 = segAt(e.clientX);
if (v0 !== valueRef.current) onChange(v0);
const move = (ev) => {
if (!trackRef.current) return;
const v = segAt(ev.clientX);
if (v !== valueRef.current) onChange(v);
};
const up = () => {
setDragging(false);
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<TweakRow label={label}>
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
<div className="twk-seg-thumb"
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
width: `calc((100% - 4px) / ${n})` }} />
{opts.map((o) => (
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
{o.label}
</button>
))}
</div>
</TweakRow>
);
}
function TweakSelect({ label, value, options, onChange }) {
return (
<TweakRow label={label}>
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => {
const v = typeof o === 'object' ? o.value : o;
const l = typeof o === 'object' ? o.label : o;
return <option key={v} value={v}>{l}</option>;
})}
</select>
</TweakRow>
);
}
function TweakText({ label, value, placeholder, onChange }) {
return (
<TweakRow label={label}>
<input className="twk-field" type="text" value={value} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
</TweakRow>
);
}
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
const clamp = (n) => {
if (min != null && n < min) return min;
if (max != null && n > max) return max;
return n;
};
const startRef = React.useRef({ x: 0, val: 0 });
const onScrubStart = (e) => {
e.preventDefault();
startRef.current = { x: e.clientX, val: value };
const decimals = (String(step).split('.')[1] || '').length;
const move = (ev) => {
const dx = ev.clientX - startRef.current.x;
const raw = startRef.current.val + dx * step;
const snapped = Math.round(raw / step) * step;
onChange(clamp(Number(snapped.toFixed(decimals))));
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<div className="twk-num">
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
<input type="number" value={value} min={min} max={max} step={step}
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
{unit && <span className="twk-num-unit">{unit}</span>}
</div>
);
}
function TweakColor({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<input type="color" className="twk-swatch" value={value}
onChange={(e) => onChange(e.target.value)} />
</div>
);
}
function TweakButton({ label, onClick, secondary = false }) {
return (
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
onClick={onClick}>{label}</button>
);
}
Object.assign(window, {
useTweaks, TweaksPanel, TweakSection, TweakRow,
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
TweakText, TweakNumber, TweakColor, TweakButton,
});