This commit is contained in:
2026-01-03 11:12:00 -06:00
commit 73cec8a07e
12 changed files with 3880 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spike Breakdown</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1681
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "spike-breakdown",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.4",
"vite": "^5.4.0"
}
}

326
public/data.json Normal file
View File

@@ -0,0 +1,326 @@
{
"version": 1,
"updatedAt": "2025-01-01T00:00:00.000Z",
"nodes": [
{
"id": "spike-1",
"title": "Break down the work",
"description": "Capture unknowns, list questions, and split into tasks and stories.",
"type": "spike",
"status": "in-progress",
"children": [
{
"id": "story-1",
"title": "Viewer can scan growth at a glance",
"description": "Show progress and growth metrics for every node.",
"type": "story",
"status": "todo",
"children": [],
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z"
},
{
"id": "task-1",
"title": "CRUD operations",
"description": "Add, edit, remove, and move forward.",
"type": "task",
"status": "todo",
"children": [],
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z"
}
],
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z"
},
{
"id": "spike-2",
"title": "Map user flow",
"description": "Define the end-to-end journey and critical handoffs.",
"type": "spike",
"status": "todo",
"children": [
{
"id": "task-2",
"title": "Draft entry points",
"description": "List all entry points and expected outcomes.",
"type": "task",
"status": "todo",
"children": [],
"createdAt": "2025-01-02T00:00:00.000Z",
"updatedAt": "2025-01-02T00:00:00.000Z"
},
{
"id": "story-2",
"title": "Happy path walkthrough",
"description": "Validate the primary success path end-to-end.",
"type": "story",
"status": "todo",
"children": [],
"createdAt": "2025-01-02T00:00:00.000Z",
"updatedAt": "2025-01-02T00:00:00.000Z"
}
],
"createdAt": "2025-01-02T00:00:00.000Z",
"updatedAt": "2025-01-02T00:00:00.000Z"
},
{
"id": "spike-3",
"title": "Design explorer view",
"description": "Iterate on a compact tree explorer layout.",
"type": "spike",
"status": "in-progress",
"children": [
{
"id": "task-3",
"title": "Define row anatomy",
"description": "Decide which metadata lives in a row.",
"type": "task",
"status": "done",
"children": [],
"createdAt": "2025-01-03T00:00:00.000Z",
"updatedAt": "2025-01-03T00:00:00.000Z"
},
{
"id": "task-4",
"title": "Rail + connector styling",
"description": "Pick rails, bullets, and indentation rhythm.",
"type": "task",
"status": "in-progress",
"children": [],
"createdAt": "2025-01-03T00:00:00.000Z",
"updatedAt": "2025-01-03T00:00:00.000Z"
},
{
"id": "story-3",
"title": "At-a-glance status scan",
"description": "Make status readable from the right edge.",
"type": "story",
"status": "todo",
"children": [],
"createdAt": "2025-01-03T00:00:00.000Z",
"updatedAt": "2025-01-03T00:00:00.000Z"
}
],
"createdAt": "2025-01-03T00:00:00.000Z",
"updatedAt": "2025-01-03T00:00:00.000Z"
},
{
"id": "spike-4",
"title": "Mind map layout",
"description": "Cluster related nodes for exploratory planning.",
"type": "spike",
"status": "todo",
"children": [
{
"id": "story-4",
"title": "Cluster by type",
"description": "Group spikes, tasks, and stories into clusters.",
"type": "story",
"status": "todo",
"children": [],
"createdAt": "2025-01-04T00:00:00.000Z",
"updatedAt": "2025-01-04T00:00:00.000Z"
},
{
"id": "task-5",
"title": "Define bubble spacing",
"description": "Set spacing between sibling bubbles.",
"type": "task",
"status": "todo",
"children": [],
"createdAt": "2025-01-04T00:00:00.000Z",
"updatedAt": "2025-01-04T00:00:00.000Z"
}
],
"createdAt": "2025-01-04T00:00:00.000Z",
"updatedAt": "2025-01-04T00:00:00.000Z"
},
{
"id": "spike-5",
"title": "Diagram layout",
"description": "Card-based layout for deeper reviews.",
"type": "spike",
"status": "in-progress",
"children": [
{
"id": "task-6",
"title": "Header layout",
"description": "Align title, status, and type in header.",
"type": "task",
"status": "in-progress",
"children": [],
"createdAt": "2025-01-05T00:00:00.000Z",
"updatedAt": "2025-01-05T00:00:00.000Z"
},
{
"id": "task-7",
"title": "Footer metadata",
"description": "Define chips for children + done counts.",
"type": "task",
"status": "todo",
"children": [],
"createdAt": "2025-01-05T00:00:00.000Z",
"updatedAt": "2025-01-05T00:00:00.000Z"
}
],
"createdAt": "2025-01-05T00:00:00.000Z",
"updatedAt": "2025-01-05T00:00:00.000Z"
},
{
"id": "spike-6",
"title": "Data persistence",
"description": "Define how the tree is stored and restored.",
"type": "spike",
"status": "done",
"children": [
{
"id": "task-8",
"title": "Local storage strategy",
"description": "Keep main data separate from guest imports.",
"type": "task",
"status": "done",
"children": [],
"createdAt": "2025-01-06T00:00:00.000Z",
"updatedAt": "2025-01-06T00:00:00.000Z"
},
{
"id": "story-5",
"title": "Export baseline JSON",
"description": "Provide a data.json seed for sharing.",
"type": "story",
"status": "done",
"children": [],
"createdAt": "2025-01-06T00:00:00.000Z",
"updatedAt": "2025-01-06T00:00:00.000Z"
}
],
"createdAt": "2025-01-06T00:00:00.000Z",
"updatedAt": "2025-01-06T00:00:00.000Z"
},
{
"id": "spike-7",
"title": "Workflow readiness",
"description": "Ensure tasks are sequenced and ready to execute.",
"type": "spike",
"status": "todo",
"children": [
{
"id": "task-9",
"title": "Identify blockers",
"description": "Capture constraints and dependencies.",
"type": "task",
"status": "todo",
"children": [],
"createdAt": "2025-01-07T00:00:00.000Z",
"updatedAt": "2025-01-07T00:00:00.000Z"
},
{
"id": "story-6",
"title": "Review with stakeholders",
"description": "Validate scope and success criteria.",
"type": "story",
"status": "todo",
"children": [],
"createdAt": "2025-01-07T00:00:00.000Z",
"updatedAt": "2025-01-07T00:00:00.000Z"
}
],
"createdAt": "2025-01-07T00:00:00.000Z",
"updatedAt": "2025-01-07T00:00:00.000Z"
},
{
"id": "spike-8",
"title": "Implementation prep",
"description": "Translate stories into actionable tasks.",
"type": "spike",
"status": "todo",
"children": [
{
"id": "task-10",
"title": "Define acceptance criteria",
"description": "Write clear done conditions.",
"type": "task",
"status": "todo",
"children": [],
"createdAt": "2025-01-08T00:00:00.000Z",
"updatedAt": "2025-01-08T00:00:00.000Z"
},
{
"id": "task-11",
"title": "Sequence implementation steps",
"description": "Order tasks to reduce context switching.",
"type": "task",
"status": "todo",
"children": [],
"createdAt": "2025-01-08T00:00:00.000Z",
"updatedAt": "2025-01-08T00:00:00.000Z"
}
],
"createdAt": "2025-01-08T00:00:00.000Z",
"updatedAt": "2025-01-08T00:00:00.000Z"
},
{
"id": "spike-9",
"title": "Mobile pass",
"description": "Ensure layouts are clear on small screens.",
"type": "spike",
"status": "todo",
"children": [
{
"id": "story-7",
"title": "Touch-friendly actions",
"description": "Confirm buttons and targets remain usable.",
"type": "story",
"status": "todo",
"children": [],
"createdAt": "2025-01-09T00:00:00.000Z",
"updatedAt": "2025-01-09T00:00:00.000Z"
},
{
"id": "task-12",
"title": "Condense metadata",
"description": "Collapse low-priority chips on mobile.",
"type": "task",
"status": "todo",
"children": [],
"createdAt": "2025-01-09T00:00:00.000Z",
"updatedAt": "2025-01-09T00:00:00.000Z"
}
],
"createdAt": "2025-01-09T00:00:00.000Z",
"updatedAt": "2025-01-09T00:00:00.000Z"
},
{
"id": "spike-10",
"title": "Release plan",
"description": "Prepare for rollout and iteration.",
"type": "spike",
"status": "todo",
"children": [
{
"id": "task-13",
"title": "Define milestones",
"description": "Set checkpoints for delivery.",
"type": "task",
"status": "todo",
"children": [],
"createdAt": "2025-01-10T00:00:00.000Z",
"updatedAt": "2025-01-10T00:00:00.000Z"
},
{
"id": "story-8",
"title": "Share rollout notes",
"description": "Communicate plan to the team.",
"type": "story",
"status": "todo",
"children": [],
"createdAt": "2025-01-10T00:00:00.000Z",
"updatedAt": "2025-01-10T00:00:00.000Z"
}
],
"createdAt": "2025-01-10T00:00:00.000Z",
"updatedAt": "2025-01-10T00:00:00.000Z"
}
]
}

963
src/App.tsx Normal file
View File

@@ -0,0 +1,963 @@
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>
);
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

813
src/styles.css Normal file
View File

@@ -0,0 +1,813 @@
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;600&display=swap");
:root {
color-scheme: dark;
--ink: #e9f2f3;
--muted: #8fa1a6;
--surface: #0a0f14;
--surface-strong: #111922;
--surface-soft: #0d151d;
--accent: #37f1c6;
--accent-deep: #168a73;
--highlight: #f0b35a;
--danger: #ff6b5a;
--stroke: #1f2b34;
--shadow: 0 16px 32px rgba(0, 0, 0, 0.35);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Space Grotesk", system-ui, sans-serif;
color: var(--ink);
background-color: var(--surface);
background-image: radial-gradient(
circle at 10% 20%,
rgba(55, 241, 198, 0.15),
transparent 45%
),
radial-gradient(
circle at 85% 15%,
rgba(240, 179, 90, 0.16),
transparent 40%
),
linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
background-size: auto, auto, 32px 32px, 32px 32px;
min-height: 100vh;
}
input,
textarea,
select,
button {
font-family: inherit;
color: inherit;
}
button {
border: none;
border-radius: 8px;
padding: 0.4rem 0.9rem;
cursor: pointer;
font-weight: 600;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
button:hover {
transform: translateY(-1px);
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.35);
}
.icon-only {
background: transparent;
border: none;
padding: 0;
box-shadow: none;
}
.icon-only:hover {
transform: none;
box-shadow: none;
}
.ghost.icon-only {
border: none;
background: transparent;
}
button.primary {
background: var(--accent);
color: #051513;
}
button.ghost {
background: rgba(15, 23, 29, 0.6);
border: 1px solid var(--stroke);
color: var(--ink);
}
button.danger {
background: rgba(255, 107, 90, 0.2);
color: var(--danger);
border: 1px solid rgba(255, 107, 90, 0.35);
}
button.compact {
padding: 0.3rem 0.75rem;
font-size: 0.8rem;
}
.collapse-button {
min-width: 2rem;
padding: 0.3rem 0.4rem;
font-family: "IBM Plex Mono", monospace;
}
.add-child-button {
min-width: 0.8rem;
height: 0.8rem;
padding: 0;
font-family: "IBM Plex Mono", monospace;
border-radius: 8px;
}
.add-child-button--label {
height: auto;
padding: 0.3rem 0.75rem;
}
.collapse-button svg {
width: 14px;
height: 14px;
display: block;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
.topbar {
display: flex;
flex-wrap: wrap;
gap: 1.4rem;
align-items: center;
justify-content: space-between;
margin-bottom: 1.6rem;
padding: 1rem 1.2rem;
background: rgba(11, 17, 23, 0.85);
border: 1px solid var(--stroke);
border-radius: 16px;
box-shadow: var(--shadow);
}
.brand {
display: flex;
gap: 0.8rem;
align-items: center;
}
.brand-mark {
width: 38px;
height: 38px;
border-radius: 10px;
background: linear-gradient(140deg, var(--accent), #1a3f40);
display: grid;
place-items: center;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: 0.08em;
color: #02110f;
}
.brand-title {
font-weight: 700;
font-size: 1.1rem;
}
.brand-subtitle {
font-size: 0.85rem;
color: var(--muted);
}
.filters {
display: flex;
gap: 0.8rem;
flex-wrap: wrap;
align-items: flex-end;
padding: 0.6rem 0.8rem;
border-radius: 12px;
background: rgba(8, 12, 16, 0.5);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.top-actions {
display: flex;
gap: 0.6rem;
align-items: center;
}
.view-tabs {
display: inline-flex;
gap: 0.4rem;
padding: 0.35rem;
border: 1px solid var(--stroke);
border-radius: 10px;
background: rgba(8, 12, 16, 0.6);
align-self: flex-end;
}
.view-tabs .ghost.is-active {
background: rgba(55, 241, 198, 0.15);
border-color: rgba(55, 241, 198, 0.4);
}
label {
display: grid;
gap: 0.35rem;
font-weight: 600;
font-size: 0.75rem;
font-family: "IBM Plex Mono", monospace;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
input,
textarea,
select {
border-radius: 8px;
border: 1px solid var(--stroke);
padding: 0.45rem 0.6rem;
font-size: 0.9rem;
background: var(--surface-soft);
}
textarea {
min-height: 90px;
resize: vertical;
}
.tree-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.tree-header h2 {
margin: 0;
}
.subtle {
margin: 0.4rem 0 0;
color: var(--muted);
font-size: 0.9rem;
}
.tree {
background: rgba(11, 17, 23, 0.78);
border: 1px solid var(--stroke);
border-radius: 18px;
padding: 1.2rem;
box-shadow: var(--shadow);
}
.tree-view--mindmap .node {
margin-left: 0 !important;
}
.mindmap-row {
margin-bottom: 0.6rem;
animation: fadeUp 0.35s ease both;
animation-delay: var(--row-delay, 0ms);
}
.mindmap-node {
background: rgba(14, 22, 29, 0.9);
border: 1px solid var(--stroke);
border-radius: 999px;
padding: 0.6rem 1rem;
display: inline-flex;
flex-direction: column;
gap: 0.4rem;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.25);
}
.mindmap-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.mindmap-text {
font-weight: 600;
}
.mindmap-desc {
color: var(--muted);
font-size: 0.85rem;
}
.mindmap-footer {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.mindmap-actions {
display: inline-flex;
gap: 0.4rem;
align-items: center;
}
.mindmap-actions .status-toggle {
font-size: 0.65rem;
}
.tree-view--mindmap .node-children {
border-left: none;
padding-left: 0;
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
margin-top: 0.6rem;
}
.tree-view--mindmap .node-children > .node {
margin-top: 0;
}
.tree-view--diagram .node {
margin-left: 0 !important;
}
.diagram-row {
margin-bottom: 0.8rem;
animation: fadeUp 0.35s ease both;
animation-delay: var(--row-delay, 0ms);
}
.diagram-card {
background: rgba(13, 20, 27, 0.95);
border: 1px solid var(--stroke);
border-radius: 12px;
padding: 0.8rem 1rem;
box-shadow: 0 14px 24px rgba(0, 0, 0, 0.3);
}
.diagram-header {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.diagram-title {
font-weight: 700;
}
.diagram-body {
margin-top: 0.5rem;
color: var(--muted);
font-size: 0.9rem;
}
.diagram-footer {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.6rem;
flex-wrap: wrap;
}
.diagram-actions {
margin-left: auto;
display: inline-flex;
gap: 0.4rem;
align-items: center;
}
.node {
background: transparent;
border-radius: 10px;
padding: 0;
margin-bottom: 0.4rem;
box-shadow: none;
border: none;
position: relative;
}
.node.is-active {
outline: none;
}
.node-row {
display: grid;
gap: 0.4rem;
align-items: center;
}
.node-title h3 {
margin: 0;
font-size: 0.98rem;
font-weight: 600;
white-space: nowrap;
}
.node-summary {
max-width: 360px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--muted);
font-size: 0.85rem;
}
.type-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--accent);
box-shadow: 0 0 8px rgba(55, 241, 198, 0.5);
display: inline-block;
}
.type-dot.type-spike {
background: var(--accent);
}
.type-dot.type-task {
background: #f9c288;
}
.type-dot.type-story {
background: #ff9e93;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #8b9aa0;
display: inline-block;
}
.status-dot.status-todo {
background: #ff6b5a;
}
.status-dot.status-in-progress {
background: #5fd3ff;
}
.status-dot.status-done {
background: #7bf3a6;
}
.meta-chip {
font-family: "IBM Plex Mono", monospace;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 0.2rem 0.5rem;
border-radius: 6px;
background: rgba(8, 12, 16, 0.85);
border: 1px solid var(--stroke);
color: var(--muted);
}
.meta-chip.status-todo {
color: #ff6b5a;
}
.meta-chip.status-in-progress {
color: #5fd3ff;
}
.meta-chip.status-done {
color: #7bf3a6;
}
.explorer-row {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 0.6rem;
align-items: center;
padding: 0.35rem 0.2rem;
border-bottom: 1px dashed rgba(255, 255, 255, 0.08);
animation: fadeUp 0.35s ease both;
animation-delay: var(--row-delay, 0ms);
}
.explorer-main {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.explorer-title {
font-weight: 600;
}
.explorer-summary {
color: var(--muted);
font-size: 0.85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.explorer-meta {
display: inline-flex;
gap: 0.4rem;
align-items: center;
flex-wrap: wrap;
}
.explorer-actions {
display: inline-flex;
gap: 0.4rem;
align-items: center;
}
.meta-date {
font-size: 0.7rem;
letter-spacing: 0.02em;
color: var(--muted);
}
.meta-action {
font-size: 0.75rem;
color: var(--muted);
min-width: 1.2rem;
height: 1.2rem;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.meta-chip.progress-done {
color: #7bf3a6;
border-color: rgba(123, 243, 166, 0.35);
}
.meta-chip.progress-active {
color: #5fd3ff;
border-color: rgba(95, 211, 255, 0.35);
}
.meta-chip.progress-todo {
color: #ff6b5a;
border-color: rgba(255, 107, 90, 0.35);
}
.type-pill,
.status-pill,
.node-growth,
.node-progress {
font-family: "IBM Plex Mono", monospace;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 0.2rem 0.5rem;
border-radius: 6px;
background: rgba(8, 12, 16, 0.85);
border: 1px solid var(--stroke);
}
.type-spike {
background: rgba(43, 228, 214, 0.12);
color: var(--accent);
}
.type-task {
background: rgba(242, 161, 74, 0.15);
color: #f9c288;
}
.type-story {
background: rgba(255, 107, 90, 0.18);
color: #ff9e93;
}
.status-todo {
color: #ff6b5a;
background: rgba(255, 107, 90, 0.16);
}
.status-in-progress {
color: #5fd3ff;
background: rgba(43, 228, 214, 0.12);
}
.status-done {
color: #7bf3a6;
background: rgba(60, 179, 113, 0.14);
}
.node-body {
margin-top: 0.65rem;
display: grid;
gap: 0.7rem;
}
.node-description {
color: var(--muted);
margin: 0;
}
.row {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.node-children {
margin-top: 0.4rem;
padding-left: 1.2rem;
border-left: 1px solid rgba(255, 255, 255, 0.12);
overflow: hidden;
max-height: 2000px;
opacity: 1;
transform: translateY(0);
transition: max-height 0.28s ease, opacity 0.2s ease, transform 0.2s ease;
}
.node-children > .node {
margin-top: 0.35rem;
}
.node-children.is-collapsed {
max-height: 0;
opacity: 0;
transform: translateY(-6px);
margin-top: 0;
padding-left: 1.2rem;
border-left-color: transparent;
pointer-events: none;
}
.empty {
padding: 1rem;
background: var(--surface-soft);
border-radius: 12px;
border: 1px dashed var(--stroke);
color: var(--muted);
}
.warning {
border: 1px solid rgba(240, 179, 90, 0.45);
background: rgba(240, 179, 90, 0.12);
color: #f0b35a;
padding: 0.5rem 0.7rem;
border-radius: 8px;
font-size: 0.85rem;
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(3, 9, 12, 0.72);
display: grid;
place-items: center;
z-index: 10;
padding: 1.5rem;
}
.modal {
background: var(--surface-strong);
border-radius: 18px;
padding: 1.2rem 1.4rem;
max-width: 560px;
width: 100%;
box-shadow: var(--shadow);
border: 1px solid var(--stroke);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.modal-body {
display: grid;
gap: 1rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.8rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.8rem;
}
.stat {
border: 1px solid var(--stroke);
border-radius: 12px;
padding: 0.7rem;
background: rgba(18, 27, 34, 0.8);
}
.stat-label {
display: block;
color: var(--muted);
font-size: 0.82rem;
}
.settings-actions {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
}
.file-input {
display: inline-flex;
align-items: center;
gap: 0.6rem;
border: 1px dashed var(--stroke);
border-radius: 999px;
padding: 0.35rem 0.9rem;
font-weight: 600;
cursor: pointer;
}
.file-input input {
display: none;
}
@media (max-width: 720px) {
.topbar {
flex-direction: column;
align-items: flex-start;
}
.node {
margin-left: 0 !important;
}
.node-header {
flex-direction: column;
align-items: flex-start;
}
}
.status-toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border: 1px solid var(--stroke);
border-radius: 999px;
padding: 0.2rem 0.6rem;
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
background: rgba(8, 12, 16, 0.85);
color: var(--muted);
}
.status-toggle--icon {
padding: 0.2rem 0.4rem;
}
.status-toggle .status-label {
line-height: 1;
}
.status-toggle .status-indicator {
width: 8px;
height: 8px;
border-radius: 999px;
background: currentColor;
}
.status-toggle.status-todo {
color: #ff6b5a;
}
.status-toggle.status-in-progress {
color: #5fd3ff;
}
.status-toggle.status-done {
color: #7bf3a6;
}

19
src/types.ts Normal file
View File

@@ -0,0 +1,19 @@
export type NodeType = "spike" | "task" | "story";
export type NodeStatus = "todo" | "in-progress" | "done";
export type TaskNode = {
id: string;
title: string;
description: string;
type: NodeType;
status: NodeStatus;
children: TaskNode[];
createdAt: string;
updatedAt: string;
};
export type TreeFile = {
version: number;
updatedAt: string;
nodes: TaskNode[];
};

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}

10
tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

6
vite.config.ts Normal file
View File

@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
});