Initial version

This commit is contained in:
Ilia Sharin
2026-05-05 18:42:06 -04:00
commit a2a3b7b72b
21 changed files with 14258 additions and 0 deletions
+367
View File
@@ -0,0 +1,367 @@
// Shared helpers, status definitions, filter engine, icons.
const STATUSES = [
{ key: "open", glyph: "[ ]", label: "Open" },
{ key: "in-progress", glyph: "[/]", label: "In progress" },
{ key: "blocked", glyph: "[!]", label: "Blocked" },
{ key: "postponed", glyph: "[>]", label: "Postponed" },
{ key: "done", glyph: "[x]", label: "Done" },
{ key: "cancelled", glyph: "[-]", label: "Cancelled" }
];
const STATUS_BY_KEY = Object.fromEntries(STATUSES.map(s => [s.key, s]));
const STATUS_ORDER = STATUSES.map(s => s.key);
const PRIORITIES = ["P0", "P1", "P2", "P3"];
// ---- date helpers ----
function parseDate(s) { return s ? new Date(s + "T12:00:00Z") : null; }
function daysBetween(a, b) { return Math.round((a - b) / 86400000); }
function isOverdue(item) {
if (!item.due || item.status === "done" || item.status === "cancelled") return false;
return parseDate(item.due) < new Date();
}
function dueRelative(item) {
if (!item.due) return null;
const d = daysBetween(parseDate(item.due), new Date());
if (d < 0) return `${-d}d overdue`;
if (d === 0) return "today";
if (d === 1) return "tomorrow";
if (d < 7) return `in ${d}d`;
if (d < 30) return `in ${Math.round(d/7)}w`;
return item.due;
}
function fmtTimestamp(iso) {
if (!iso) return "—";
const d = new Date(iso);
const diff = (Date.now() - d.getTime()) / 1000;
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.round(diff/60)}m ago`;
if (diff < 86400) return `${Math.round(diff/3600)}h ago`;
if (diff < 86400*7) return `${Math.round(diff/86400)}d ago`;
return d.toISOString().slice(0, 10);
}
function fmtBytes(b) {
if (b < 1024) return `${b} B`;
if (b < 1024*1024) return `${(b/1024).toFixed(1)} KB`;
return `${(b/1024/1024).toFixed(2)} MB`;
}
const fmtRelative = fmtTimestamp;
function fmtShortDate(iso) {
if (!iso) return "—";
const d = new Date(iso);
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
return `${months[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
}
// ---- tree helpers ----
function walkTree(items, fn, depth = 0, parent = null) {
for (const it of items) {
fn(it, depth, parent);
if (it.children?.length) walkTree(it.children, fn, depth + 1, it);
}
}
function findItem(items, id) {
for (const it of items) {
if (it.id === id) return it;
if (it.children?.length) {
const f = findItem(it.children, id);
if (f) return f;
}
}
return null;
}
function allTags(items) {
const counts = new Map();
walkTree(items, it => (it.tags || []).forEach(t => counts.set(t, (counts.get(t) || 0) + 1)));
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([t]) => t);
}
function countAll(items) {
let n = 0; walkTree(items, () => n++); return n;
}
function countByStatus(items) {
const c = Object.fromEntries(STATUSES.map(s => [s.key, 0]));
walkTree(items, it => { c[it.status] = (c[it.status] || 0) + 1; });
return c;
}
// ---- filter engine with parent-visibility rule ----
function filterTree(items, f) {
function matches(it) {
if (f.statuses?.length && !f.statuses.includes(it.status)) return false;
if (f.priorities?.length && !f.priorities.includes(it.priority)) return false;
if (f.tags?.length && !(it.tags || []).some(t => f.tags.includes(t))) return false;
if (f.dueRange) {
const overdue = isOverdue(it);
const d = it.due ? daysBetween(parseDate(it.due), new Date()) : null;
if (f.dueRange === "overdue" && !overdue) return false;
if (f.dueRange === "today" && !(d === 0)) return false;
if (f.dueRange === "week" && !(d !== null && d >= 0 && d <= 7)) return false;
if (f.dueRange === "month" && !(d !== null && d >= 0 && d <= 30)) return false;
}
if (f.text) {
const q = f.text.toLowerCase();
const hay = [it.title, it.reason, ...(it.tags || [])].filter(Boolean).join(" ").toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
}
function recur(list) {
const out = [];
for (const it of list) {
const kids = it.children?.length ? recur(it.children) : [];
const self = matches(it);
if (self || kids.length) {
out.push({ ...it, children: kids, _matches: self });
}
}
return out;
}
return recur(items);
}
// ---- status visuals (4 styles, switchable via Tweaks) ----
// styles: "flat" (minimal monochrome SVG, inherits status color),
// "ascii" (original [/], [x] etc),
// "color" (filled colored icons, ChatGPT-style — green check, red blocked, etc),
// "emoji" (expressive native emoji — ✅ 🚧 ⏸ etc)
// Self-contained color palette for the "color" style — does NOT inherit currentColor.
const STATUS_COLORS = {
"open": { stroke: "oklch(0.62 0.01 70)", fill: "transparent" },
"in-progress": { stroke: "oklch(0.55 0.16 230)", fill: "oklch(0.62 0.16 230)" },
"blocked": { stroke: "oklch(0.50 0.20 28)", fill: "oklch(0.62 0.20 28)" },
"postponed": { stroke: "oklch(0.55 0.13 290)", fill: "oklch(0.65 0.13 290)" },
"done": { stroke: "oklch(0.48 0.16 145)", fill: "oklch(0.60 0.16 145)" },
"cancelled": { stroke: "oklch(0.55 0.01 70)", fill: "oklch(0.65 0.01 70)" },
};
// Expressive emoji pack — chosen for clarity at small sizes (avoid round colored dots).
const STATUS_EMOJI = {
"open": "⬜",
"in-progress": "✏️",
"blocked": "🚧",
"postponed": "⏸️",
"done": "✅",
"cancelled": "❌",
};
// Context so every StatusIcon in the tree picks up the current style automatically.
const StatusStyleContext = React.createContext("flat");
function StatusIcon({ status, style, size = 14 }) {
const ctx = React.useContext(StatusStyleContext);
style = style || ctx || "flat";
if (style === "ascii") {
return <span className={`glyph-ascii status-${status}`}>{STATUS_BY_KEY[status].glyph}</span>;
}
if (style === "color") {
return <ColorStatusIcon status={status} size={size}/>;
}
if (style === "emoji") {
return <span className="glyph-emoji" style={{ fontSize: size + 2, lineHeight: 1 }}>{STATUS_EMOJI[status]}</span>;
}
// flat — minimalistic SVG icons (square family, no circles)
const c = "currentColor";
const wrap = (children) => (
<svg width={size + 2} height={size + 2} viewBox="0 0 16 16" className={`glyph-flat status-${status}`}>
{children}
</svg>
);
switch (status) {
case "open":
// empty square outline
return wrap(<rect x="2.5" y="2.5" width="11" height="11" rx="2" fill="none" stroke={c} strokeWidth="1.4"/>);
case "in-progress":
// half-filled square (left filled)
return wrap(<>
<rect x="2.5" y="2.5" width="11" height="11" rx="2" fill="none" stroke={c} strokeWidth="1.4"/>
<path d="M3.5 3.5 H8 V12.5 H3.5 Z" fill={c}/>
</>);
case "blocked":
// filled square with horizontal bar (no entry)
return wrap(<>
<rect x="2.5" y="2.5" width="11" height="11" rx="2" fill={c}/>
<rect x="4.5" y="7.2" width="7" height="1.6" rx="0.5" fill="white"/>
</>);
case "postponed":
// square with right-pointing chevron (deferred)
return wrap(<>
<rect x="2.5" y="2.5" width="11" height="11" rx="2" fill="none" stroke={c} strokeWidth="1.4"/>
<polyline points="6,5.5 9,8 6,10.5" fill="none" stroke={c} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</>);
case "done":
// filled square with checkmark
return wrap(<>
<rect x="2.5" y="2.5" width="11" height="11" rx="2" fill={c}/>
<polyline points="5,8 7,10.2 11,5.8" fill="none" stroke="white" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/>
</>);
case "cancelled":
// outlined square with single clean diagonal slash
return wrap(<>
<rect x="2.5" y="2.5" width="11" height="11" rx="2" fill="none" stroke={c} strokeWidth="1.4"/>
<line x1="3.5" y1="12.5" x2="12.5" y2="3.5" stroke={c} strokeWidth="1.6" strokeLinecap="round"/>
</>);
default: return null;
}
}
// ---- color status icons (ChatGPT / Linear / GitHub style) ----
// Filled circles with semantic colors and white glyphs. Self-coloring,
// not affected by currentColor — looks good on any background.
function ColorStatusIcon({ status, size = 14 }) {
const c = STATUS_COLORS[status] || STATUS_COLORS.open;
const w = size + 2;
const cx = 8, cy = 8, r = 6.4;
switch (status) {
case "open":
// empty circle — thin gray ring
return (
<svg width={w} height={w} viewBox="0 0 16 16" className={`glyph-color status-${status}`}>
<circle cx={cx} cy={cy} r={r} fill="none" stroke={c.stroke} strokeWidth="1.5"/>
</svg>
);
case "in-progress":
// half-filled pie (clockwise 50%) — Linear-style "in progress"
return (
<svg width={w} height={w} viewBox="0 0 16 16" className={`glyph-color status-${status}`}>
<circle cx={cx} cy={cy} r={r} fill="none" stroke={c.fill} strokeWidth="1.5"/>
<path d={`M 8 1.6 A 6.4 6.4 0 0 1 8 14.4 Z`} fill={c.fill}/>
</svg>
);
case "blocked":
// red filled circle with white horizontal bar
return (
<svg width={w} height={w} viewBox="0 0 16 16" className={`glyph-color status-${status}`}>
<circle cx={cx} cy={cy} r={r} fill={c.fill}/>
<rect x="4.6" y="7.2" width="6.8" height="1.6" rx="0.8" fill="white"/>
</svg>
);
case "postponed":
// purple filled circle with white right-chevron
return (
<svg width={w} height={w} viewBox="0 0 16 16" className={`glyph-color status-${status}`}>
<circle cx={cx} cy={cy} r={r} fill={c.fill}/>
<polyline points="6.4,5.4 9.4,8 6.4,10.6" fill="none" stroke="white" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
case "done":
// green filled circle with white check
return (
<svg width={w} height={w} viewBox="0 0 16 16" className={`glyph-color status-${status}`}>
<circle cx={cx} cy={cy} r={r} fill={c.fill}/>
<polyline points="5,8.2 7.2,10.3 11,5.9" fill="none" stroke="white" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
case "cancelled":
// gray filled circle with white X
return (
<svg width={w} height={w} viewBox="0 0 16 16" className={`glyph-color status-${status}`}>
<circle cx={cx} cy={cy} r={r} fill={c.fill}/>
<g stroke="white" strokeWidth="1.6" strokeLinecap="round">
<line x1="5.6" y1="5.6" x2="10.4" y2="10.4"/>
<line x1="10.4" y1="5.6" x2="5.6" y2="10.4"/>
</g>
</svg>
);
default: return null;
}
}
// ---- icons (inline SVG) ----
const Icon = ({ name, size = 14 }) => {
const paths = {
chevron: <polyline points="5,8 10,13 15,8" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>,
chevronRight: <polyline points="8,5 13,10 8,15" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>,
plus: <g stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><line x1="10" y1="4" x2="10" y2="16"/><line x1="4" y1="10" x2="16" y2="10"/></g>,
search: <g fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><circle cx="9" cy="9" r="5"/><line x1="13" y1="13" x2="17" y2="17"/></g>,
drag: <g fill="currentColor"><circle cx="7" cy="5" r="1.2"/><circle cx="13" cy="5" r="1.2"/><circle cx="7" cy="10" r="1.2"/><circle cx="13" cy="10" r="1.2"/><circle cx="7" cy="15" r="1.2"/><circle cx="13" cy="15" r="1.2"/></g>,
trash: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M5 6h10M8 6V4h4v2M6 6l1 10h6l1-10"/></g>,
cog: <g fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="10" cy="10" r="2.6"/><path d="M10 2v2M10 16v2M2 10h2M16 10h2M4.3 4.3l1.4 1.4M14.3 14.3l1.4 1.4M4.3 15.7l1.4-1.4M14.3 5.7l1.4-1.4"/></g>,
list: <g fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><line x1="6" y1="6" x2="16" y2="6"/><line x1="6" y1="10" x2="16" y2="10"/><line x1="6" y1="14" x2="16" y2="14"/><circle cx="3.5" cy="6" r="0.6" fill="currentColor"/><circle cx="3.5" cy="10" r="0.6" fill="currentColor"/><circle cx="3.5" cy="14" r="0.6" fill="currentColor"/></g>,
check: <polyline points="4,10 8,14 16,5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>,
download: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M10 3v10M5 9l5 5 5-5M3 17h14"/></g>,
upload: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M10 16V4M5 9l5-5 5 5M3 17h14"/></g>,
save: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"><path d="M4 4h9l3 3v9H4z"/><path d="M7 4v4h6V4M7 16v-5h6v5"/></g>,
refresh: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 10a7 7 0 0 1 12-5l2 2"/><path d="M17 4v3h-3"/><path d="M17 10a7 7 0 0 1-12 5l-2-2"/><path d="M3 16v-3h3"/></g>,
warn: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M10 3l8 14H2z"/><line x1="10" y1="8" x2="10" y2="12"/><circle cx="10" cy="14.5" r="0.6" fill="currentColor"/></g>,
eye: <g fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M2 10s3-5 8-5 8 5 8 5-3 5-8 5-8-5-8-5z"/><circle cx="10" cy="10" r="2"/></g>,
archive: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"><rect x="3" y="5" width="14" height="3"/><path d="M4 8v9h12V8"/><line x1="8" y1="11" x2="12" y2="11"/></g>,
edit: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 14v2h2l8-8-2-2-8 8z"/><path d="M12 4l2 2"/></g>,
addChild: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 4v6a3 3 0 0 0 3 3h4"/><line x1="14" y1="13" x2="14" y2="17"/><line x1="12" y1="15" x2="16" y2="15"/></g>,
copy: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="7" y="3" width="10" height="12" rx="1.5"/><path d="M13 17H4a1 1 0 0 1-1-1V6"/></g>,
statusDots: <g fill="currentColor"><circle cx="5" cy="10" r="1.6"/><circle cx="10" cy="10" r="1.6"/><circle cx="15" cy="10" r="1.6"/></g>
};
return (
<svg width={size} height={size} viewBox="0 0 20 20" style={{ display: "inline-block", verticalAlign: "middle", flexShrink: 0 }}>
{paths[name]}
</svg>
);
};
// --- Progress (completion %) ---
// Quantized to 0/25/50/75/100 — coarse on purpose; tasks are rarely "73% done".
const PROGRESS_STEPS = [0, 25, 50, 75, 100];
function snapProgress(p) {
if (p == null || isNaN(p)) return 0;
if (p <= 0) return 0;
if (p >= 100) return 100;
// round to nearest 25
return Math.round(p / 25) * 25;
}
// Effective progress: done is always 100 regardless of stored value;
// cancelled returns null (don't render).
function progressFor(item) {
if (item.status === "done") return 100;
if (item.status === "cancelled") return null;
return snapProgress(item.progress ?? 0);
}
// When progress should be visible inline. Done shows nothing (line-through is enough).
// Open shows only if user explicitly set >0.
function shouldShowProgress(item) {
if (item.status === "cancelled") return false;
if (item.status === "done") return false;
if (item.status === "open") return (item.progress ?? 0) > 0;
return true; // in-progress, blocked, postponed (always visible, including at 100%)
}
// Click to advance through 0/25/50/75/100, wrapping.
function cycleProgress(p) {
const i = PROGRESS_STEPS.indexOf(snapProgress(p));
return PROGRESS_STEPS[(i + 1) % PROGRESS_STEPS.length];
}
// 4-segment gauge (visual representation of 0/25/50/75/100).
// 0 = empty, 100 = all 4 filled.
function ProgressGauge({ value, onClick, interactive = false, size = "sm" }) {
const v = snapProgress(value);
const filled = v / 25; // 0..4
const segs = [0, 1, 2, 3].map(i => i < filled);
return (
<span
className={`gauge ${interactive ? "gauge-interactive" : ""} gauge-${size} gauge-v-${v}`}
onClick={onClick}
role={interactive ? "button" : undefined}
tabIndex={interactive ? 0 : undefined}
title={`${v}% complete${interactive ? " — click to change" : ""}`}
aria-label={`Progress ${v} percent`}
>
{segs.map((on, i) => (
<span key={i} className={`gauge-seg ${on ? "on" : ""}`}/>
))}
<span className="gauge-num">{v}%</span>
</span>
);
}
Object.assign(window, {
STATUSES, STATUS_BY_KEY, STATUS_ORDER, PRIORITIES,
parseDate, daysBetween, isOverdue, dueRelative, fmtTimestamp, fmtBytes, fmtRelative, fmtShortDate,
walkTree, findItem, allTags, countAll, countByStatus, filterTree,
Icon, StatusIcon, ColorStatusIcon, STATUS_COLORS, STATUS_EMOJI, StatusStyleContext,
PROGRESS_STEPS, snapProgress, progressFor, shouldShowProgress, cycleProgress, ProgressGauge
});