Initial version
This commit is contained in:
+626
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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
@@ -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
|
||||
});
|
||||
@@ -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>
|
||||
Generated
+620
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+238
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
Reference in New Issue
Block a user