// 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 {STATUS_BY_KEY[status].glyph}; } if (style === "color") { return ; } if (style === "emoji") { return {STATUS_EMOJI[status]}; } // flat — minimalistic SVG icons (square family, no circles) const c = "currentColor"; const wrap = (children) => ( {children} ); switch (status) { case "open": // empty square outline return wrap(); case "in-progress": // half-filled square (left filled) return wrap(<> ); case "blocked": // filled square with horizontal bar (no entry) return wrap(<> ); case "postponed": // square with right-pointing chevron (deferred) return wrap(<> ); case "done": // filled square with checkmark return wrap(<> ); case "cancelled": // outlined square with single clean diagonal slash return wrap(<> ); 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 ( ); case "in-progress": // half-filled pie (clockwise 50%) — Linear-style "in progress" return ( ); case "blocked": // red filled circle with white horizontal bar return ( ); case "postponed": // purple filled circle with white right-chevron return ( ); case "done": // green filled circle with white check return ( ); case "cancelled": // gray filled circle with white X return ( ); default: return null; } } // ---- icons (inline SVG) ---- const Icon = ({ name, size = 14 }) => { const paths = { chevron: , chevronRight: , plus: , search: , drag: , trash: , cog: , list: , check: , download: , upload: , save: , refresh: , warn: , eye: , archive: , edit: , addChild: , copy: , statusDots: }; return ( {paths[name]} ); }; // --- 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 ( {segs.map((on, i) => ( ))} {v}% ); } 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 });