Files
2026-01-03 11:12:00 -06:00

964 lines
28 KiB
TypeScript

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>): 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<TaskNode[] | null> => {
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<TaskNode[]>((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(
<div className="modal-backdrop" role="dialog" aria-modal="true">
<div className="modal">
<div className="modal-header">
<h2>{title}</h2>
<button className="ghost compact" onClick={onClose}>
Close
</button>
</div>
{children}
</div>
</div>,
document.body
);
type NodeProps = {
node: TaskNode;
depth: number;
onUpdate: (id: string, next: Partial<TaskNode>) => 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<string | null>(null);
const [childDraft, setChildDraft] = useState({
title: "",
description: "",
type: "task" as NodeType,
});
const [draft, setDraft] = useState<EditorState>({
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 ? (
<button
className="ghost compact collapse-button icon-only"
onClick={() => {
setCollapsed((prev) => !prev);
setHasManualToggle(true);
}}
aria-label={collapsed ? "Expand children" : "Collapse children"}
title={collapsed ? "Expand children" : "Collapse children"}
>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d={collapsed ? "M3 8h10M8 3v10" : "M3 8h10"} />
</svg>
</button>
) : null;
const renderStatusToggle = (
showLabel = true,
showIndicator = true
) => (
<button
className={`status-toggle status-${node.status}${
showLabel ? "" : " status-toggle--icon"
}`}
onClick={() => onAdvanceStatus(node.id)}
aria-label="Advance status"
title="Advance status"
>
{showIndicator && <span className="status-indicator" />}
{showLabel && <span className="status-label">{statusLabel}</span>}
</button>
);
const renderActions = (
showLabel = true,
showIndicator = true,
showAddLabel = false,
showAdd = true
) => (
<>
{renderStatusToggle(showLabel, showIndicator)}
{showAdd && (
<button
className={`ghost compact add-child-button icon-only${
showAddLabel ? " add-child-button--label" : ""
}`}
onClick={() => setAddChildOpen(true)}
aria-label="Add child"
title="Add child"
>
{showAddLabel ? "Add child" : "+"}
</button>
)}
</>
);
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 (
<div
className={`node${isActive ? " is-active" : ""}`}
style={nodeStyle}
id={`node-${node.id}`}
>
<div className="explorer-row" onContextMenu={handleContextMenu}>
<div className="explorer-main">
{renderCollapse()}
<span className={`type-pill type-${node.type}`}>{node.type}</span>
<span className="explorer-title">{node.title}</span>
{node.description && (
<span className="explorer-summary">- {node.description}</span>
)}
</div>
<div className="explorer-meta">
{renderStatusToggle(true, false)}
<span className="meta-chip meta-date">
{new Date(node.updatedAt).toLocaleString()}
</span>
{stats.childTotal > 0 && (
<span className={`meta-chip ${progressClass}`}>
{stats.childDone}/{stats.childTotal}
</span>
)}
<button
className="meta-chip meta-action"
onClick={() => setAddChildOpen(true)}
aria-label="Add child"
title="Add child"
>
+
</button>
</div>
</div>
{node.children.length > 0 && (
<div className={`node-children${collapsed ? " is-collapsed" : ""}`}>
{node.children.map((child) => (
<NodeCard
key={child.id}
node={child}
depth={depth + 1}
onUpdate={onUpdate}
onAddChild={onAddChild}
onRequestDelete={onRequestDelete}
onAdvanceStatus={onAdvanceStatus}
activeId={activeId}
/>
))}
</div>
)}
{addChildOpen && (
<Modal title="Add child" onClose={() => setAddChildOpen(false)}>
<div className="modal-body">
{addChildError && <div className="warning">{addChildError}</div>}
<label>
Child title
<input
value={childDraft.title}
onChange={(event) =>
setChildDraft({ ...childDraft, title: event.target.value })
}
/>
</label>
<label>
Child description
<textarea
value={childDraft.description}
onChange={(event) =>
setChildDraft({
...childDraft,
description: event.target.value,
})
}
/>
</label>
<label>
Type
<select
value={childDraft.type}
onChange={(event) =>
setChildDraft({
...childDraft,
type: event.target.value as NodeType,
})
}
>
<option value="spike">spike</option>
<option value="task">task</option>
<option value="story">story</option>
</select>
</label>
<button className="primary" onClick={handleAddChild}>
Add child node
</button>
</div>
</Modal>
)}
{editOpen && (
<Modal title="Edit item" onClose={() => setEditOpen(false)}>
<div className="modal-body">
<label>
Title
<input
value={draft.title}
onChange={(event) =>
setDraft({ ...draft, title: event.target.value })
}
/>
</label>
<label>
Description
<textarea
value={draft.description}
onChange={(event) =>
setDraft({ ...draft, description: event.target.value })
}
/>
</label>
<div className="row">
<label>
Type
<select
value={draft.type}
onChange={(event) =>
setDraft({
...draft,
type: event.target.value as NodeType,
})
}
>
<option value="spike">spike</option>
<option value="task">task</option>
<option value="story">story</option>
</select>
</label>
<label>
Status
<select
value={draft.status}
onChange={(event) =>
setDraft({
...draft,
status: event.target.value as NodeStatus,
})
}
>
<option value="todo">todo</option>
<option value="in-progress">in progress</option>
<option value="done">done</option>
</select>
</label>
</div>
<div className="modal-actions">
<button
className="primary compact"
onClick={() => {
applyUpdate();
setEditOpen(false);
}}
>
Save changes
</button>
<button
className="ghost compact"
onClick={() => {
setEditOpen(false);
handleRequestDelete();
}}
>
Delete item
</button>
</div>
</div>
</Modal>
)}
</div>
);
};
export default function App() {
const [mainNodes, setMainNodes] = useState<TaskNode[]>(defaultTree);
const [guestNodes, setGuestNodes] = useState<TaskNode[]>([]);
const [newTitle, setNewTitle] = useState("");
const [newType, setNewType] = useState<NodeType>("spike");
const [newDescription, setNewDescription] = useState("");
const [typeFilter, setTypeFilter] = useState<"all" | NodeType>("all");
const [statusFilter, setStatusFilter] = useState<"all" | NodeStatus>("all");
const [sortKey, setSortKey] = useState("updated-desc");
const [settingsOpen, setSettingsOpen] = useState(false);
const [addRootOpen, setAddRootOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget>(null);
const [lastTouchedId, setLastTouchedId] = useState<string | null>(null);
const [warning, setWarning] = useState<string | null>(null);
const [addRootError, setAddRootError] = useState<string | null>(null);
useEffect(() => {
const load = async () => {
const mainStored = loadFromStorage(STORAGE_KEY_MAIN);
if (mainStored) {
setMainNodes(mainStored);
return;
}
const fromFile = await loadFromFile();
if (fromFile) {
setMainNodes(fromFile);
persistTree(fromFile, STORAGE_KEY_MAIN);
return;
}
setMainNodes(defaultTree);
persistTree(defaultTree, STORAGE_KEY_MAIN);
};
load();
}, []);
// Explorer view is the only enabled view right now.
const nodes = mainNodes;
const updateAndSave = (next: TaskNode[]) => {
setMainNodes(next);
const ok = persistTree(next, STORAGE_KEY_MAIN);
if (!ok) {
setWarning("Unable to save data to local storage.");
} else {
setWarning(null);
}
};
const handleUpdate = (id: string, next: Partial<TaskNode>) => {
updateAndSave(
updateNode(nodes, id, (node) => ({
...node,
...next,
}))
);
setLastTouchedId(id);
};
const handleAddChild = (id: string, child: TaskNode) => {
updateAndSave(
updateNode(nodes, id, (node) => ({
...node,
children: [...node.children, child],
updatedAt: nowIso(),
}))
);
setLastTouchedId(child.id);
};
const handleAdvanceStatus = (id: string) => {
updateAndSave(
updateNode(nodes, id, (node) => {
const currentIndex = STATUS_ORDER.indexOf(node.status);
const nextStatus =
STATUS_ORDER[(currentIndex + 1) % STATUS_ORDER.length];
return { ...node, status: nextStatus, updatedAt: nowIso() };
})
);
setLastTouchedId(id);
};
const handleRequestDelete = (target: DeleteTarget) => {
setDeleteTarget(target);
};
const handleConfirmDelete = () => {
if (!deleteTarget) return;
updateAndSave(removeNode(nodes, deleteTarget.nodeId));
setDeleteTarget(null);
};
const handleAddRoot = () => {
if (!newTitle.trim()) {
setAddRootError("Title is required.");
return;
}
const newNode = createNode({
title: newTitle.trim(),
description: newDescription,
type: newType,
});
const next = [
...nodes,
newNode,
];
updateAndSave(next);
setNewTitle("");
setNewDescription("");
setNewType("spike");
setAddRootOpen(false);
setLastTouchedId(newNode.id);
setAddRootError(null);
};
const handleExport = () => {
downloadJson(mainNodes);
};
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const parsed = JSON.parse(reader.result as string) as TreeFile;
if (!parsed || !Array.isArray(parsed.nodes)) {
return;
}
const guest = parsed.nodes.map((node) => normalizeNode(node));
setGuestNodes(guest);
persistTree(guest, STORAGE_KEY_GUEST);
} catch {
return;
}
};
reader.readAsText(file);
};
const stats = useMemo(() => walkStats(mainNodes), [mainNodes]);
const completion =
stats.total === 0 ? 0 : Math.round((stats.done / stats.total) * 100);
const visibleNodes = useMemo(() => {
const matcher = (node: TaskNode) => {
const typeMatch = typeFilter === "all" || node.type === typeFilter;
const statusMatch =
statusFilter === "all" || node.status === statusFilter;
return typeMatch && statusMatch;
};
return sortRoots(filterTree(mainNodes, matcher), sortKey);
}, [mainNodes, typeFilter, statusFilter, sortKey]);
const jumpToLast = () => {
if (!lastTouchedId) return;
let attempts = 0;
const tryScroll = () => {
const element = document.getElementById(`node-${lastTouchedId}`);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "center" });
return;
}
if (attempts < 5) {
attempts += 1;
setTimeout(tryScroll, 120);
}
};
tryScroll();
};
return (
<div className="app">
<header className="topbar">
<div className="brand">
<span className="brand-mark" aria-hidden="true">
<svg viewBox="0 0 32 32">
<path
d="M16 3l9 6v6c0 7-6 11-9 14-3-3-9-7-9-14V9l9-6z"
/>
<path d="M16 9v14" />
<path d="M12 13h8" />
</svg>
</span>
<div>
<div className="brand-title">Spike Breakdown</div>
<div className="brand-subtitle">Task + story tree</div>
</div>
</div>
<div className="filters">
<label>
Type
<select
value={typeFilter}
onChange={(event) =>
setTypeFilter(event.target.value as "all" | NodeType)
}
>
<option value="all">all</option>
<option value="spike">spike</option>
<option value="task">task</option>
<option value="story">story</option>
</select>
</label>
<label>
Status
<select
value={statusFilter}
onChange={(event) =>
setStatusFilter(event.target.value as "all" | NodeStatus)
}
>
<option value="all">all</option>
<option value="todo">todo</option>
<option value="in-progress">in progress</option>
<option value="done">done</option>
</select>
</label>
<label>
Sort
<select
value={sortKey}
onChange={(event) => setSortKey(event.target.value)}
>
<option value="updated-desc">latest changed</option>
<option value="created-desc">newest</option>
<option value="title-asc">title A-Z</option>
</select>
</label>
</div>
<div className="top-actions">
{lastTouchedId && (
<button className="ghost compact" onClick={jumpToLast}>
Jump to last
</button>
)}
<button className="primary compact" onClick={() => setAddRootOpen(true)}>
Add root
</button>
<button className="ghost compact" onClick={() => setSettingsOpen(true)}>
Settings
</button>
</div>
</header>
<section className="tree tree-view tree-view--explorer">
<div className="tree-header">
<div>
<h2>Tree view</h2>
<p className="subtle">
Explorer view is the primary layout.
</p>
</div>
</div>
{warning && <div className="warning">{warning}</div>}
{visibleNodes.length === 0 ? (
<p className="empty">
Nothing matches your filters yet. Add a root item to keep growing.
</p>
) : (
visibleNodes.map((node) => (
<NodeCard
key={node.id}
node={node}
depth={0}
onUpdate={handleUpdate}
onAddChild={handleAddChild}
onRequestDelete={handleRequestDelete}
onAdvanceStatus={handleAdvanceStatus}
activeId={lastTouchedId}
/>
))
)}
</section>
{settingsOpen && (
<Modal title="Settings" onClose={() => setSettingsOpen(false)}>
<div className="modal-body">
<div className="stats-grid">
<div className="stat">
<span className="stat-label">Total nodes</span>
<strong>{stats.total}</strong>
</div>
<div className="stat">
<span className="stat-label">Completion</span>
<strong>{completion}%</strong>
</div>
<div className="stat">
<span className="stat-label">Spikes</span>
<strong>{stats.spikes}</strong>
</div>
<div className="stat">
<span className="stat-label">Tasks</span>
<strong>{stats.tasks}</strong>
</div>
<div className="stat">
<span className="stat-label">Stories</span>
<strong>{stats.stories}</strong>
</div>
<div className="stat">
<span className="stat-label">Done</span>
<strong>
{stats.done}/{stats.total}
</strong>
</div>
</div>
<div className="settings-actions">
<button className="ghost" onClick={handleExport}>
Export JSON
</button>
<label className="file-input">
Import JSON
<input
type="file"
accept="application/json"
onChange={handleImport}
/>
</label>
</div>
</div>
</Modal>
)}
{addRootOpen && (
<Modal title="Add a root item" onClose={() => setAddRootOpen(false)}>
<div className="modal-body">
{addRootError && <div className="warning">{addRootError}</div>}
<label>
Title
<input
placeholder="New spike or task"
value={newTitle}
onChange={(event) => setNewTitle(event.target.value)}
/>
</label>
<label>
Description
<textarea
value={newDescription}
onChange={(event) => setNewDescription(event.target.value)}
placeholder="What should happen here?"
/>
</label>
<label>
Type
<select
value={newType}
onChange={(event) => setNewType(event.target.value as NodeType)}
>
<option value="spike">spike</option>
<option value="task">task</option>
<option value="story">story</option>
</select>
</label>
<button className="primary" onClick={handleAddRoot}>
Add root
</button>
</div>
</Modal>
)}
{deleteTarget && (
<Modal title="Confirm delete" onClose={() => setDeleteTarget(null)}>
<div className="modal-body">
<p className="subtle">
This will permanently remove{" "}
<strong>{deleteTarget.label}</strong>.
</p>
<div className="modal-actions">
<button className="ghost" onClick={() => setDeleteTarget(null)}>
Cancel
</button>
<button className="danger" onClick={handleConfirmDelete}>
Delete
</button>
</div>
</div>
</Modal>
)}
</div>
);
}