// 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) => (
);
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 (
);
};
// --- 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
});