latest
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
12
index.html
Normal file
12
index.html
Normal 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
1681
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal 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
326
public/data.json
Normal 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
963
src/App.tsx
Normal 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
10
src/main.tsx
Normal 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
813
src/styles.css
Normal 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
19
src/types.ts
Normal 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
17
tsconfig.json
Normal 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
10
tsconfig.node.json
Normal 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
6
vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user