import { useEffect, useMemo, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import type { NodeStatus, NodeType, TaskNode, TreeFile } from "./types"; const STORAGE_KEY_MAIN = "spike-breakdown-tree-main-v1"; const STORAGE_KEY_GUEST = "spike-breakdown-tree-guest-v1"; const STATUS_ORDER: NodeStatus[] = ["todo", "in-progress", "done"]; const nowIso = () => new Date().toISOString(); const makeId = () => { if ("randomUUID" in crypto) { return crypto.randomUUID(); } return `node-${Math.random().toString(36).slice(2, 10)}`; }; const createNode = (input?: Partial): TaskNode => ({ id: input?.id ?? makeId(), title: input?.title ?? "New node", description: input?.description ?? "", type: input?.type ?? "task", status: input?.status ?? "todo", children: input?.children ?? [], createdAt: input?.createdAt ?? nowIso(), updatedAt: input?.updatedAt ?? nowIso(), }); const normalizeNode = (node: TaskNode): TaskNode => createNode({ ...node, children: node.children?.map(normalizeNode) ?? [], }); const defaultTree: TaskNode[] = [ createNode({ title: "Break down the work", type: "spike", status: "in-progress", description: "Capture unknowns, list questions, and split into tasks and stories.", children: [ createNode({ title: "Viewer can scan growth at a glance", type: "story", status: "todo", description: "Show progress and growth metrics for every node.", }), createNode({ title: "CRUD operations", type: "task", status: "todo", description: "Add, edit, remove, and move forward.", }), ], }), ]; const loadFromStorage = (key: string): TaskNode[] | null => { const saved = localStorage.getItem(key); if (!saved) return null; try { const parsed = JSON.parse(saved) as TreeFile; if (!parsed || !Array.isArray(parsed.nodes)) { return null; } return parsed.nodes.map((node) => normalizeNode(node)); } catch { return null; } }; const loadFromFile = async (): Promise => { try { const response = await fetch("/data.json", { cache: "no-store" }); if (!response.ok) return null; const parsed = (await response.json()) as TreeFile; if (!parsed || !Array.isArray(parsed.nodes)) { return null; } return parsed.nodes.map((node) => normalizeNode(node)); } catch { return null; } }; const persistTree = (nodes: TaskNode[], key: string) => { const payload: TreeFile = { version: 1, updatedAt: nowIso(), nodes, }; try { localStorage.setItem(key, JSON.stringify(payload, null, 2)); return true; } catch { return false; } }; const walkStats = (nodes: TaskNode[]) => { let total = 0; let done = 0; let spikes = 0; let tasks = 0; let stories = 0; const walk = (node: TaskNode) => { total += 1; if (node.status === "done") done += 1; if (node.type === "spike") spikes += 1; if (node.type === "task") tasks += 1; if (node.type === "story") stories += 1; node.children.forEach(walk); }; nodes.forEach(walk); return { total, done, spikes, tasks, stories }; }; const computeNodeStats = (node: TaskNode) => { let total = 1; let done = node.status === "done" ? 1 : 0; let childTotal = 0; let childDone = 0; node.children.forEach((child) => { const stats = computeNodeStats(child); total += stats.total; done += stats.done; childTotal += stats.total; childDone += stats.done; }); return { total, done, childTotal, childDone }; }; const updateNode = ( nodes: TaskNode[], id: string, updater: (node: TaskNode) => TaskNode ): TaskNode[] => nodes.map((node) => { if (node.id === id) { return updater(node); } if (node.children.length === 0) { return node; } return { ...node, children: updateNode(node.children, id, updater) }; }); const removeNode = (nodes: TaskNode[], id: string): TaskNode[] => nodes .filter((node) => node.id !== id) .map((node) => ({ ...node, children: removeNode(node.children, id), })); const filterTree = ( nodes: TaskNode[], matcher: (node: TaskNode) => boolean ): TaskNode[] => nodes.reduce((acc, node) => { const filteredChildren = filterTree(node.children, matcher); if (matcher(node) || filteredChildren.length > 0) { acc.push({ ...node, children: filteredChildren }); } return acc; }, []); const sortRoots = (nodes: TaskNode[], sortKey: string): TaskNode[] => [...nodes].sort((a, b) => { if (sortKey === "updated-desc") { return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); } if (sortKey === "created-desc") { return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); } if (sortKey === "title-asc") { return a.title.localeCompare(b.title); } return 0; }); const downloadJson = (nodes: TaskNode[]) => { const payload: TreeFile = { version: 1, updatedAt: nowIso(), nodes, }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json", }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = "data.json"; anchor.click(); URL.revokeObjectURL(url); }; type EditorState = { title: string; description: string; type: NodeType; status: NodeStatus; }; type DeleteTarget = { nodeId: string; label: string } | null; type ModalProps = { title: string; onClose: () => void; children: ReactNode; }; const Modal = ({ title, onClose, children }: ModalProps) => createPortal(

{title}

{children}
, document.body ); type NodeProps = { node: TaskNode; depth: number; onUpdate: (id: string, next: Partial) => void; onAddChild: (id: string, child: TaskNode) => void; onRequestDelete: (target: DeleteTarget) => void; onAdvanceStatus: (id: string) => void; activeId: string | null; }; const NodeCard = ({ node, depth, onUpdate, onAddChild, onRequestDelete, onAdvanceStatus, activeId, }: NodeProps) => { const [collapsed, setCollapsed] = useState(false); const [hasManualToggle, setHasManualToggle] = useState(false); const [addChildOpen, setAddChildOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); const [addChildError, setAddChildError] = useState(null); const [childDraft, setChildDraft] = useState({ title: "", description: "", type: "task" as NodeType, }); const [draft, setDraft] = useState({ title: node.title, description: node.description, type: node.type, status: node.status, }); const stats = computeNodeStats(node); const descendants = stats.childTotal; const statusLabel = node.status === "in-progress" ? "in progress" : node.status; const applyUpdate = () => { onUpdate(node.id, { title: draft.title.trim() || node.title, description: draft.description, type: draft.type, status: draft.status, updatedAt: nowIso(), }); }; const handleAddChild = () => { if (!childDraft.title.trim()) { setAddChildError("Title is required."); return; } onAddChild( node.id, createNode({ title: childDraft.title.trim(), description: childDraft.description, type: childDraft.type, }) ); setChildDraft({ title: "", description: "", type: "task" }); setAddChildOpen(false); setAddChildError(null); }; const handleRequestDelete = () => { onRequestDelete({ nodeId: node.id, label: node.title }); }; const handleContextMenu = (event: React.MouseEvent) => { event.preventDefault(); setDraft({ title: node.title, description: node.description, type: node.type, status: node.status, }); setEditOpen(true); }; const nodeStyle = { marginLeft: `${depth * 10}px`, ["--row-delay" as const]: `${depth * 40}ms`, }; const isActive = node.id === activeId; const renderCollapse = () => node.children.length > 0 ? ( ) : null; const renderStatusToggle = ( showLabel = true, showIndicator = true ) => ( ); const renderActions = ( showLabel = true, showIndicator = true, showAddLabel = false, showAdd = true ) => ( <> {renderStatusToggle(showLabel, showIndicator)} {showAdd && ( )} ); const progressClass = stats.childTotal > 0 ? stats.childDone === stats.childTotal ? "progress-done" : stats.childDone > 0 ? "progress-active" : "progress-todo" : "progress-none"; useEffect(() => { if (stats.childTotal > 0 && stats.childDone === stats.childTotal) { if (!hasManualToggle) { setCollapsed(true); } } }, [stats.childTotal, stats.childDone, hasManualToggle]); return (
{renderCollapse()} {node.type} {node.title} {node.description && ( - {node.description} )}
{renderStatusToggle(true, false)} {new Date(node.updatedAt).toLocaleString()} {stats.childTotal > 0 && ( {stats.childDone}/{stats.childTotal} )}
{node.children.length > 0 && (
{node.children.map((child) => ( ))}
)} {addChildOpen && ( setAddChildOpen(false)}>
{addChildError &&
{addChildError}
}