964 lines
28 KiB
TypeScript
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>
|
|
);
|
|
}
|