From e8397bfa6976e8a9418fc3d28a79a64f582e418c Mon Sep 17 00:00:00 2001 From: ECWireless Date: Thu, 30 Jul 2026 20:31:15 +0000 Subject: [PATCH] feat: add node constellation --- SPEC.md | 42 ++ package.json | 2 + pnpm-lock.yaml | 39 ++ src/app/globals.css | 354 ++++++++++++++++ src/components/dashboard-shell.tsx | 98 ++++- src/components/icons.tsx | 28 ++ src/components/node-constellation.tsx | 560 ++++++++++++++++++++++++++ src/lib/nodes/constellation.ts | 86 ++++ tests/e2e/foundation.spec.ts | 183 +++++++++ tests/unit/node-constellation.test.ts | 104 +++++ 10 files changed, 1478 insertions(+), 18 deletions(-) create mode 100644 src/components/node-constellation.tsx create mode 100644 src/lib/nodes/constellation.ts create mode 100644 tests/unit/node-constellation.test.ts diff --git a/SPEC.md b/SPEC.md index 3f28453..0b7be90 100644 --- a/SPEC.md +++ b/SPEC.md @@ -68,6 +68,45 @@ The MVP does not include: - Bulk editing. - A recycle bin or general soft-delete system. +## Post-MVP extensions + +Features in this section are approved after the completed `v0.1.0` MVP. They do +not retroactively change the MVP scope or its historical non-goals. + +### Node Constellation + +The authenticated dashboard adds a read-only, full-workspace graphical view of +the current node hierarchy: + +- A graph icon beside "Show completed" and "New root node" switches between the + primary tree workspace and Node Constellation without changing the URL. +- The existing period filter and "Show completed" state apply to the + constellation. Hidden completed branches remain absent until the owner shows + completed nodes. +- Each visible node is a bubble connected to its parent. Bubble size represents + the node's rolled-up historical duration for the current period and completion + visibility, using bounded square-root scaling so zero-hour nodes remain + discoverable and outliers do not consume the view. +- Root nodes use the brand blue, running timers receive a restrained yellow + treatment, and completed nodes are visually muted. A compact legend explains + the root, completion, and sizing encodings without overloading yellow focus + and selection cues with another label. +- The layout is force-directed, settles instead of animating indefinitely, and + can be reset. The owner may locally nudge bubbles, zoom, and pan without + changing node order, parentage, or any persisted record. +- Choosing a bubble opens an in-view read-only summary with its breadcrumb, + direct duration, rolled-up duration, completion state, and an "Open in tree" + action. Opening the node leaves the constellation and uses the existing + selected-node navigation. +- Graph nodes are keyboard-focusable and have complete accessible names. + Reduced-motion preferences use a stable layout without continuous animation. +- The view remains usable at the supported desktop and mobile widths. Empty and + completed-only states retain clear recovery actions. + +Node Constellation does not add graph editing, persistent coordinates, new +analytics, export, sharing, stored aggregates, routes, database fields, or +server operations. The tree remains TimeTree's primary editing interface. + ## Scoped agent timekeeping TimeTree supports a narrow bearer-key integration for coding agents that record @@ -852,6 +891,7 @@ DashboardPage (server) ├── ActiveTimersStrip ├── DashboardToolbar │ └── PeriodFilter + ├── NodeConstellation ├── TreePane │ └── NodeTree │ ├── NodeRow @@ -873,6 +913,8 @@ DashboardPage (server) - The server page provides authoritative dashboard data and the selected node's first page of entries. - Expanded nodes, search text, and "Show completed" are ephemeral client state. +- Tree/constellation mode, constellation focus, and local viewport placement are + ephemeral client state. - The application does not add Redux or React Query. Agent setup and credentials are not persisted in browser-local storage. - Selecting a node updates the URL and loads its first entry page from the diff --git a/package.json b/package.json index e3ebb36..77cc9b9 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "dependencies": { "@dnd-kit/core": "^6.3.1", "better-auth": "^1.6.23", + "d3-force": "^3.0.0", "drizzle-orm": "^0.45.2", "next": "^16.2.11", "pg": "^8.22.0", @@ -34,6 +35,7 @@ "devDependencies": { "@playwright/test": "^1.61.1", "@tailwindcss/postcss": "^4.3.3", + "@types/d3-force": "^3.0.10", "@types/node": "^26.1.1", "@types/pg": "^8.20.0", "@types/react": "^19.2.17", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fa580d..6d13bdd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: better-auth: specifier: ^1.6.23 version: 1.6.23(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0))(next@16.2.11(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))) + d3-force: + specifier: ^3.0.0 + version: 3.0.0 drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0) @@ -42,6 +45,9 @@ importers: '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 + '@types/d3-force': + specifier: ^3.0.10 + version: 3.0.10 '@types/node': specifier: ^26.1.1 version: 26.1.1 @@ -1249,6 +1255,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -1713,6 +1722,22 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -4076,6 +4101,8 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-force@3.0.10': {} + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.9': {} @@ -4523,6 +4550,18 @@ snapshots: csstype@3.2.3: {} + d3-dispatch@3.0.1: {} + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-quadtree@3.0.1: {} + + d3-timer@3.0.1: {} + damerau-levenshtein@1.0.8: {} data-view-buffer@1.0.2: diff --git a/src/app/globals.css b/src/app/globals.css index 4341985..6e4c5d2 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -193,6 +193,18 @@ body { background: var(--color-blue); } +.icon-button--toggle[aria-pressed="true"] { + border-color: #73b9f5; + background: rgb(18 99 173 / 28%); + box-shadow: inset 0 -0.18rem 0 #73b9f5; + color: #d9eeff; +} + +.icon-button--toggle[aria-pressed="true"]:hover { + border-color: #9bd0ff; + background: rgb(18 99 173 / 38%); +} + .icon-button--danger { border-color: rgb(255 171 165 / 45%); color: #ffaba5; @@ -649,6 +661,305 @@ textarea { grid-template-columns: minmax(20rem, 42%) minmax(0, 1fr); } +.constellation { + display: flex; + min-height: calc(100vh - 9.75rem); + flex: 1; + flex-direction: column; + overflow: hidden; + background: #07090c; +} + +.constellation__header { + display: flex; + align-items: end; + justify-content: space-between; + gap: 1.5rem; + border-bottom: 1px solid rgb(154 166 178 / 18%); + padding: 1.1rem clamp(1rem, 3vw, 2rem); +} + +.constellation__header h1, +.constellation__header p { + margin: 0; +} + +.constellation__header h1 { + font-size: 1.15rem; +} + +.constellation__header p:last-child { + margin-top: 0.3rem; + color: var(--color-muted); + font-size: 0.75rem; +} + +.constellation__legend { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 0.55rem 1rem; + color: var(--color-muted); + font-size: 0.6875rem; +} + +.constellation__legend span { + display: inline-flex; + align-items: center; + gap: 0.35rem; + white-space: nowrap; +} + +.constellation__legend i { + display: block; + width: 0.8rem; + height: 0.8rem; + border: 1px solid rgb(115 185 245 / 68%); + border-radius: 50%; + background: #121d27; +} + +.constellation__legend .constellation__legend-root { + border-color: #73b9f5; + background: var(--color-blue); +} + +.constellation__legend .constellation__legend-completed { + border-style: dashed; + opacity: 0.55; +} + +.constellation__legend .constellation__legend-size { + width: 1rem; + height: 1rem; +} + +.constellation__stage { + position: relative; + min-height: 30rem; + flex: 1; + overflow: hidden; + background: + radial-gradient(circle at 50% 45%, rgb(18 99 173 / 13%), transparent 32rem), + radial-gradient(circle at 20% 80%, rgb(250 243 14 / 4%), transparent 18rem), + #05070a; +} + +.constellation__stage::before { + position: absolute; + inset: 0; + background-image: radial-gradient(circle, rgb(154 166 178 / 22%) 0.7px, transparent 0.8px); + background-size: 2.75rem 2.75rem; + content: ""; + mask-image: radial-gradient(circle at center, black, transparent 78%); + pointer-events: none; +} + +.constellation__canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + cursor: grab; + touch-action: none; + user-select: none; +} + +.constellation__canvas:active { + cursor: grabbing; +} + +.constellation__links { + pointer-events: none; +} + +.constellation__links line { + stroke: rgb(115 185 245 / 28%); + stroke-width: 1.25; +} + +.constellation-node { + cursor: grab; + outline: none; +} + +.constellation-node:active { + cursor: grabbing; +} + +.constellation-node__bubble { + fill: #121d27; + stroke: rgb(115 185 245 / 70%); + stroke-width: 1.5; + transition: fill 140ms ease, stroke 140ms ease, stroke-width 140ms ease; +} + +.constellation-node:hover .constellation-node__bubble, +.constellation-node:focus-visible .constellation-node__bubble, +.constellation-node--focused .constellation-node__bubble { + fill: #172b3d; + stroke: var(--color-yellow); + stroke-width: 2.5; +} + +.constellation-node--root .constellation-node__bubble { + fill: var(--color-blue); + stroke: #73b9f5; +} + +.constellation-node--completed { + opacity: 0.58; +} + +.constellation-node--completed .constellation-node__bubble { + stroke-dasharray: 4 3; +} + +.constellation-node__orbit { + fill: none; + stroke: var(--color-yellow); + stroke-width: 2; + transform-origin: center; + animation: constellation-pulse 1.8s ease-in-out infinite; +} + +.constellation-node__title, +.constellation-node__time { + fill: var(--color-ink); + font-family: var(--font-sans); + font-size: 0.625rem; + font-weight: 700; + pointer-events: none; +} + +.constellation-node__time { + fill: #d5dde7; + font-size: 0.5625rem; + font-weight: 600; +} + +.constellation__controls { + position: absolute; + z-index: 3; + top: 1rem; + right: 1rem; + display: flex; + gap: 0.45rem; +} + +.constellation__controls .icon-button { + background: rgb(8 12 17 / 88%); + backdrop-filter: blur(0.5rem); +} + +.constellation-card { + position: absolute; + z-index: 3; + bottom: 1rem; + left: 1rem; + width: min(22rem, calc(100% - 2rem)); + border: 1px solid rgb(115 185 245 / 35%); + border-radius: 0.65rem; + padding: 1rem; + background: rgb(8 12 17 / 92%); + box-shadow: 0 1rem 2.5rem rgb(0 0 0 / 45%); + backdrop-filter: blur(0.75rem); +} + +.constellation-card p, +.constellation-card h2, +.constellation-card dl, +.constellation-card dd { + margin: 0; +} + +.constellation-card__breadcrumb { + overflow: hidden; + color: var(--color-muted); + font-size: 0.6875rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.constellation-card__title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-top: 0.4rem; +} + +.constellation-card__title h2 { + overflow: hidden; + font-size: 1rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.constellation-card dl { + display: flex; + gap: 1.5rem; + margin: 0.9rem 0; +} + +.constellation-card dl > div { + display: grid; + gap: 0.15rem; +} + +.constellation-card dt { + color: var(--color-muted); + font-size: 0.625rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.constellation-card dd { + font-size: 0.875rem; + font-weight: 700; +} + +.constellation__hint { + position: absolute; + right: 1rem; + bottom: 1rem; + margin: 0; + color: var(--color-muted); + font-size: 0.6875rem; + pointer-events: none; +} + +.constellation__empty { + position: relative; + z-index: 1; + display: grid; + min-height: 24rem; + place-content: center; + justify-items: center; + gap: 0.5rem; + padding: 2rem; + color: var(--color-muted); + text-align: center; +} + +.constellation__empty p { + margin: 0; +} + +@keyframes constellation-pulse { + 0%, + 100% { + opacity: 0.45; + transform: scale(0.96); + } + + 50% { + opacity: 1; + transform: scale(1.06); + } +} + .tree-pane { min-width: 0; border-right: 1px solid rgb(154 166 178 / 18%); @@ -2237,6 +2548,10 @@ textarea { flex: 1; } + .constellation { + min-height: 0; + } + .tree-pane, .detail-pane { overflow-y: auto; @@ -2370,6 +2685,45 @@ textarea { min-height: calc(100vh - 11.75rem); } + .constellation { + min-height: calc(100vh - 11.75rem); + } + + .constellation__header { + align-items: start; + flex-direction: column; + gap: 0.75rem; + } + + .constellation__legend { + justify-content: flex-start; + } + + .constellation__stage { + min-height: 32rem; + } + + .constellation__controls { + top: 0.75rem; + right: 0.75rem; + } + + .constellation-card { + right: 0.75rem; + bottom: 0.75rem; + left: 0.75rem; + width: auto; + } + + .dashboard--timers .constellation__stage { + padding-bottom: calc(min(14rem, 38dvh) + 1.5rem); + } + + .dashboard--timers .constellation-card, + .dashboard--timers .constellation__hint { + bottom: calc(min(14rem, 38dvh) + 1.5rem); + } + .tree-pane { min-height: calc(100vh - 11.75rem); border-right: 0; diff --git a/src/components/dashboard-shell.tsx b/src/components/dashboard-shell.tsx index ef82e76..7db7321 100644 --- a/src/components/dashboard-shell.tsx +++ b/src/components/dashboard-shell.tsx @@ -35,12 +35,14 @@ import { SignOutButton } from "@/components/auth-buttons"; import { BrandMark } from "@/components/brand-mark"; import { ConfirmDeleteDialog, MoveNodeDialog } from "@/components/node-dialogs"; import { DashboardPeriodFilter } from "@/components/dashboard-period-filter"; +import { NodeConstellation } from "@/components/node-constellation"; import { CheckIcon, ChevronDownIcon, ChevronRightIcon, EyeIcon, EyeOffIcon, + GraphIcon, GripIcon, KeyIcon, MoveIcon, @@ -90,6 +92,10 @@ type DashboardShellProps = { timeTreeCanonicalOrigin: string | null; }; +type PendingDashboardFocus = + | { kind: "detail"; nodeId: string } + | { kind: "tree"; nodeId: string }; + function useSharedTimerClock(activeTimers: readonly ActiveTimerRecord[], initialNowMilliseconds: number) { const [nowMilliseconds, setNowMilliseconds] = useState(initialNowMilliseconds); @@ -1005,6 +1011,7 @@ export function DashboardShell({ const [creatingDetailChild, setCreatingDetailChild] = useState(false); const [searchText, setSearchText] = useState(""); const [showCompleted, setShowCompleted] = useState(false); + const [constellationOpen, setConstellationOpen] = useState(false); const [moveDialogOpen, setMoveDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [agentAccessDialogOpen, setAgentAccessDialogOpen] = useState(false); @@ -1024,10 +1031,9 @@ export function DashboardShell({ const detailFocusRef = useRef(null); const detailPaneRef = useRef(null); const nodeButtonRefs = useRef(new Map()); - const pendingFocus = useRef<"detail" | "tree" | null>(null); + const pendingFocus = useRef(null); const pendingScrollNodeId = useRef(null); const focusTreeAfterDelete = useRef(false); - const treeFocusNodeId = useRef(null); const treeHeadingRef = useRef(null); const moveTriggerRef = useRef(null); const deleteTriggerRef = useRef(null); @@ -1061,25 +1067,37 @@ export function DashboardShell({ const isMobile = window.matchMedia("(max-width: 760px)").matches; const frame = window.requestAnimationFrame(() => { - if (pendingFocus.current === "detail" && selectedNode) { + const pending = pendingFocus.current; + if ( + pending?.kind === "detail" && + selectedNode?.id === pending.nodeId && + !constellationOpen + ) { detailPaneRef.current?.scrollTo({ top: 0, left: 0, behavior: "auto" }); if (isMobile) { detailFocusRef.current?.focus({ preventScroll: true }); window.scrollTo({ top: 0, left: 0, behavior: "auto" }); } - } else if (isMobile && pendingFocus.current === "tree" && !selectedNode && treeFocusNodeId.current) { - const treeButton = nodeButtonRefs.current.get(treeFocusNodeId.current); - if (treeButton) { - treeButton.focus(); - } else { - treeHeadingRef.current?.focus(); + if (pendingFocus.current === pending) { + pendingFocus.current = null; + } + } else if (pending?.kind === "tree" && !selectedNode) { + if (isMobile) { + const treeButton = nodeButtonRefs.current.get(pending.nodeId); + if (treeButton) { + treeButton.focus(); + } else { + treeHeadingRef.current?.focus(); + } + } + if (pendingFocus.current === pending) { + pendingFocus.current = null; } } - pendingFocus.current = null; }); return () => window.cancelAnimationFrame(frame); - }, [selectedNode]); + }, [constellationOpen, selectedNode]); useEffect(() => { if (!focusTreeAfterDelete.current || selectedNode) { @@ -1109,9 +1127,10 @@ export function DashboardShell({ setAgentAccessNotice(null); setCreatingTreeChildFor(null); setCreatingDetailChild(false); + setConstellationOpen(false); const target = nodeId ? nodeById.get(nodeId) : undefined; if (target) { - pendingFocus.current = "detail"; + pendingFocus.current = { kind: "detail", nodeId: target.id }; setExpanded((current) => { const next = new Set(current); for (const ancestor of target.breadcrumb.slice(0, -1)) { @@ -1121,6 +1140,10 @@ export function DashboardShell({ }); } + if ((nodeId ?? null) === searchParams.get("node")) { + return; + } + const next = new URLSearchParams(searchParams.toString()); if (nodeId) { next.set("node", nodeId); @@ -1137,8 +1160,7 @@ export function DashboardShell({ } setAgentAccessNotice(null); - treeFocusNodeId.current = selectedNode.id; - pendingFocus.current = "tree"; + pendingFocus.current = { kind: "tree", nodeId: selectedNode.id }; setCreatingDetailChild(false); setExpanded((current) => { const next = new Set(current); @@ -1227,7 +1249,7 @@ export function DashboardShell({ setCreatingRoot(false); setCreatingTreeChildFor(null); setCreatingDetailChild(false); - pendingFocus.current = "detail"; + setConstellationOpen(false); navigateToNode(nodeId); } @@ -1248,10 +1270,17 @@ export function DashboardShell({ setShowCompleted(true); } setSearchText(""); + setConstellationOpen(false); pendingScrollNodeId.current = node.id; navigateToNode(node.id); } + function openNodeFromConstellation(nodeId: string) { + setConstellationOpen(false); + pendingScrollNodeId.current = nodeId; + navigateToNode(nodeId); + } + async function changeCompletion() { if (!selectedNode || lifecyclePending) { return; @@ -1339,7 +1368,8 @@ export function DashboardShell({
0 ? "dashboard--timers" : "", ].filter(Boolean).join(" ")} aria-label="TimeTree workspace" @@ -1402,7 +1432,7 @@ export function DashboardShell({ />
+ @@ -1429,6 +1477,19 @@ export function DashboardShell({
) : null} + {constellationOpen ? ( + { + setConstellationOpen(false); + setCreatingRoot(true); + }} + onOpenNode={openNodeFromConstellation} + onShowCompleted={() => setShowCompleted(true)} + /> + ) : (
@@ -1705,6 +1766,7 @@ export function DashboardShell({ )}
+ )} {selectedNode && moveDialogOpen ? ( + + + ); +} + +export function GraphIcon(props: IconProps) { + return ( + + + + + + + ); +} + +export function ResetIcon(props: IconProps) { + return ( + + + + + ); +} + export function EyeIcon(props: IconProps) { return ( diff --git a/src/components/node-constellation.tsx b/src/components/node-constellation.tsx new file mode 100644 index 0000000..1f51532 --- /dev/null +++ b/src/components/node-constellation.tsx @@ -0,0 +1,560 @@ +"use client"; + +import { + forceCenter, + forceCollide, + forceLink, + forceManyBody, + forceSimulation, + forceX, + forceY, + type Simulation, + type SimulationLinkDatum, + type SimulationNodeDatum, +} from "d3-force"; +import { + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, + type PointerEvent, + type WheelEvent, +} from "react"; + +import { MinusIcon, PlusIcon, ResetIcon } from "@/components/icons"; +import { + buildConstellationGraph, + constellationDurationSeconds, + staticConstellationPosition, +} from "@/lib/nodes/constellation"; +import type { DashboardNode } from "@/lib/nodes/tree"; +import { formatHistoricalDuration } from "@/lib/time-entries/duration"; +import type { ActiveTimerRecord } from "@/lib/timers/contracts"; + +type LayoutNode = SimulationNodeDatum & { + id: string; + node: DashboardNode; + radius: number; +}; + +type LayoutLink = SimulationLinkDatum & { + source: string | LayoutNode; + target: string | LayoutNode; +}; + +type ViewTransform = { + x: number; + y: number; + scale: number; +}; + +type DragState = + | { + kind: "canvas"; + pointerId: number; + startClientX: number; + startClientY: number; + startX: number; + startY: number; + } + | { + kind: "node"; + nodeId: string; + pointerId: number; + }; + +type NodeConstellationProps = { + activeTimers: readonly ActiveTimerRecord[]; + nodes: readonly DashboardNode[]; + showCompleted: boolean; + onCreateRoot: () => void; + onOpenNode: (nodeId: string) => void; + onShowCompleted: () => void; +}; + +const MIN_SCALE = 0.5; +const MAX_SCALE = 2.25; + +function capturePointer(element: Element, pointerId: number) { + try { + element.setPointerCapture(pointerId); + } catch { + // Some mobile browsers do not support pointer capture on SVG groups. + } +} + +function clamp(value: number, minimum: number, maximum: number) { + return Math.min(Math.max(value, minimum), maximum); +} + +function labelForNode(node: DashboardNode) { + const maximum = 22; + return node.title.length <= maximum ? node.title : `${node.title.slice(0, maximum - 1)}…`; +} + +function breadcrumbForNode(node: DashboardNode) { + return node.breadcrumb.map(({ title }) => title).join(" / "); +} + +function resolvedLinkNode(value: string | LayoutNode) { + return typeof value === "string" ? null : value; +} + +export function NodeConstellation({ + activeTimers, + nodes, + showCompleted, + onCreateRoot, + onOpenNode, + onShowCompleted, +}: NodeConstellationProps) { + const containerRef = useRef(null); + const simulationRef = useRef | null>(null); + const layoutNodesRef = useRef([]); + const dragStateRef = useRef(null); + const animationFrameRef = useRef(null); + const openNodeButtonRef = useRef(null); + const [dimensions, setDimensions] = useState({ width: 900, height: 620 }); + const [focusedNodeId, setFocusedNodeId] = useState(null); + const [renderNodes, setRenderNodes] = useState([]); + const [resetVersion, setResetVersion] = useState(0); + const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 }); + const graph = useMemo( + () => buildConstellationGraph(nodes, showCompleted), + [nodes, showCompleted], + ); + const activeNodeIds = useMemo( + () => new Set(activeTimers.map(({ nodeId }) => nodeId)), + [activeTimers], + ); + const focusedNode = focusedNodeId + ? graph.nodes.find(({ id }) => id === focusedNodeId) ?? null + : null; + + useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + + const updateDimensions = () => { + const { width, height } = container.getBoundingClientRect(); + if (width > 0 && height > 0) { + setDimensions({ + width: Math.round(width), + height: Math.round(height), + }); + } + }; + updateDimensions(); + const observer = new ResizeObserver(updateDimensions); + observer.observe(container); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (animationFrameRef.current !== null) { + window.cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + simulationRef.current?.stop(); + simulationRef.current = null; + + const goldenAngle = Math.PI * (3 - Math.sqrt(5)); + const layoutNodes: LayoutNode[] = graph.nodes.map((node, index) => { + const orbit = 28 * Math.sqrt(index); + return { + id: node.id, + node, + radius: graph.radiusByNodeId.get(node.id) ?? 24, + x: dimensions.width / 2 + Math.cos(index * goldenAngle) * orbit, + y: dimensions.height / 2 + Math.sin(index * goldenAngle) * orbit, + }; + }); + const layoutLinks: LayoutLink[] = graph.links.map(({ sourceId, targetId }) => ({ + source: sourceId, + target: targetId, + })); + layoutNodesRef.current = layoutNodes; + + if (layoutNodes.length === 0) { + animationFrameRef.current = window.requestAnimationFrame(() => { + animationFrameRef.current = null; + setRenderNodes([]); + }); + return () => { + if (animationFrameRef.current !== null) { + window.cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + }; + } + + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + const maximumRadius = layoutNodes.reduce( + (maximum, layoutNode) => Math.max(maximum, layoutNode.radius), + 24, + ); + layoutNodes.forEach((layoutNode, index) => { + const position = staticConstellationPosition( + index, + dimensions.width, + dimensions.height, + maximumRadius, + ); + layoutNode.x = position.x; + layoutNode.y = position.y; + }); + animationFrameRef.current = window.requestAnimationFrame(() => { + animationFrameRef.current = null; + setRenderNodes(layoutNodes.map((layoutNode) => ({ ...layoutNode }))); + }); + return () => { + if (animationFrameRef.current !== null) { + window.cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + }; + } + + const publishLayout = () => { + if (animationFrameRef.current !== null) { + return; + } + animationFrameRef.current = window.requestAnimationFrame(() => { + animationFrameRef.current = null; + setRenderNodes(layoutNodes.map((layoutNode) => ({ ...layoutNode }))); + }); + }; + const simulation = forceSimulation(layoutNodes) + .force( + "link", + forceLink(layoutLinks) + .id(({ id }) => id) + .distance((link) => { + const source = resolvedLinkNode(link.source); + const target = resolvedLinkNode(link.target); + return (source?.radius ?? 24) + (target?.radius ?? 24) + 48; + }) + .strength(0.28), + ) + .force("charge", forceManyBody().strength(-320)) + .force("collide", forceCollide().radius(({ radius }) => radius + 12).iterations(2)) + .force("center", forceCenter(dimensions.width / 2, dimensions.height / 2).strength(0.08)) + .force("x", forceX(dimensions.width / 2).strength(0.025)) + .force("y", forceY(dimensions.height / 2).strength(0.025)) + .alphaDecay(0.035) + .velocityDecay(0.28) + .on("tick", publishLayout) + .on("end", publishLayout); + simulationRef.current = simulation; + + return () => { + simulation.stop(); + if (animationFrameRef.current !== null) { + window.cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + }; + }, [dimensions.height, dimensions.width, graph, resetVersion]); + + const nodeById = useMemo( + () => new Map(renderNodes.map((layoutNode) => [layoutNode.id, layoutNode])), + [renderNodes], + ); + + function setScale(nextScale: number) { + const scale = clamp(nextScale, MIN_SCALE, MAX_SCALE); + setTransform((current) => { + const centerX = dimensions.width / 2; + const centerY = dimensions.height / 2; + const worldCenterX = (centerX - current.x) / current.scale; + const worldCenterY = (centerY - current.y) / current.scale; + return { + x: centerX - worldCenterX * scale, + y: centerY - worldCenterY * scale, + scale, + }; + }); + } + + function resetLayout() { + dragStateRef.current = null; + setTransform({ x: 0, y: 0, scale: 1 }); + setResetVersion((current) => current + 1); + } + + function handleWheel(event: WheelEvent) { + event.preventDefault(); + const factor = event.deltaY < 0 ? 1.1 : 0.9; + setScale(transform.scale * factor); + } + + function beginCanvasDrag(event: PointerEvent) { + dragStateRef.current = { + kind: "canvas", + pointerId: event.pointerId, + startClientX: event.clientX, + startClientY: event.clientY, + startX: transform.x, + startY: transform.y, + }; + capturePointer(event.currentTarget, event.pointerId); + } + + function beginNodeDrag(event: PointerEvent, node: LayoutNode) { + event.stopPropagation(); + dragStateRef.current = { kind: "node", nodeId: node.id, pointerId: event.pointerId }; + capturePointer(event.currentTarget, event.pointerId); + node.fx = node.x; + node.fy = node.y; + simulationRef.current?.alphaTarget(0.22).restart(); + } + + function movePointer(event: PointerEvent) { + const dragState = dragStateRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) { + return; + } + if (dragState.kind === "canvas") { + setTransform((current) => ({ + ...current, + x: dragState.startX + event.clientX - dragState.startClientX, + y: dragState.startY + event.clientY - dragState.startClientY, + })); + return; + } + + event.stopPropagation(); + const layoutNode = layoutNodesRef.current.find(({ id }) => id === dragState.nodeId); + const bounds = containerRef.current?.getBoundingClientRect(); + if (!layoutNode || !bounds) { + return; + } + layoutNode.fx = (event.clientX - bounds.left - transform.x) / transform.scale; + layoutNode.fy = (event.clientY - bounds.top - transform.y) / transform.scale; + if (!simulationRef.current) { + layoutNode.x = layoutNode.fx; + layoutNode.y = layoutNode.fy; + setRenderNodes( + layoutNodesRef.current.map((candidate) => ({ ...candidate })), + ); + } + } + + function endPointer(event: PointerEvent) { + const dragState = dragStateRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) { + return; + } + if (dragState.kind === "node") { + const layoutNode = layoutNodesRef.current.find(({ id }) => id === dragState.nodeId); + if (layoutNode) { + layoutNode.fx = null; + layoutNode.fy = null; + } + simulationRef.current?.alphaTarget(0); + } + dragStateRef.current = null; + } + + function chooseNode(node: DashboardNode) { + setFocusedNodeId(node.id); + } + + function handleNodeKeyDown(event: KeyboardEvent, node: DashboardNode) { + if (event.key !== "Enter" && event.key !== " ") { + return; + } + event.preventDefault(); + chooseNode(node); + window.requestAnimationFrame(() => openNodeButtonRef.current?.focus()); + } + + return ( +
+
+
+

Read-only view

+

Node Constellation

+

Follow the branches, then give a bubble a nudge.

+
+
+ Root + {showCompleted ? Completed : null} + Size is rolled-up time +
+
+ +
+ {graph.nodes.length === 0 ? ( +
+

{nodes.length === 0 ? "Create a node to start your constellation." : "No active nodes to map."}

+ {nodes.length === 0 ? ( + + ) : ( + + )} +
+ ) : ( + <> + + + + + {graph.nodes.map((node) => { + const layoutNode = nodeById.get(node.id); + if (!layoutNode) { + return null; + } + const duration = constellationDurationSeconds(node, showCompleted); + const running = activeNodeIds.has(node.id); + const root = !node.parentId || !nodeById.has(node.parentId); + const classes = [ + "constellation-node", + root ? "constellation-node--root" : "", + running ? "constellation-node--running" : "", + node.completedAt ? "constellation-node--completed" : "", + focusedNodeId === node.id ? "constellation-node--focused" : "", + ].filter(Boolean).join(" "); + const state = [ + `${formatHistoricalDuration(duration)} rolled up`, + `${formatHistoricalDuration(node.directDurationSeconds)} direct`, + running ? "timer running" : null, + node.completedAt ? "completed" : "active", + ].filter(Boolean).join(", "); + + return ( + chooseNode(node)} + onFocus={() => setFocusedNodeId(node.id)} + onKeyDown={(event) => handleNodeKeyDown(event, node)} + onPointerDown={(event) => beginNodeDrag(event, layoutNode)} + onPointerMove={movePointer} + onPointerUp={endPointer} + onPointerCancel={endPointer} + > + {running ? ( + + ) : null} + + + {labelForNode(node)} + + + {formatHistoricalDuration(duration)} + + + ); + })} + + + + +
+ + + +
+ + {focusedNode ? ( + + ) : ( +

Choose a node to inspect it.

+ )} + + )} +
+
+ ); +} diff --git a/src/lib/nodes/constellation.ts b/src/lib/nodes/constellation.ts new file mode 100644 index 0000000..d15613e --- /dev/null +++ b/src/lib/nodes/constellation.ts @@ -0,0 +1,86 @@ +import type { DashboardNode } from "./tree"; + +export const CONSTELLATION_MIN_RADIUS = 24; +export const CONSTELLATION_MAX_RADIUS = 68; + +export type ConstellationLink = { + sourceId: string; + targetId: string; +}; + +export type ConstellationGraph = { + links: ConstellationLink[]; + nodes: DashboardNode[]; + radiusByNodeId: Map; +}; + +export function constellationDurationSeconds( + node: DashboardNode, + includeCompleted: boolean, +) { + return includeCompleted + ? node.rolledUpDurationSecondsIncludingCompleted + : node.rolledUpDurationSeconds; +} + +export function constellationRadius(durationSeconds: number, maximumDurationSeconds: number) { + if (durationSeconds <= 0 || maximumDurationSeconds <= 0) { + return CONSTELLATION_MIN_RADIUS; + } + + const normalized = Math.min(durationSeconds / maximumDurationSeconds, 1); + return ( + CONSTELLATION_MIN_RADIUS + + Math.sqrt(normalized) * (CONSTELLATION_MAX_RADIUS - CONSTELLATION_MIN_RADIUS) + ); +} + +export function staticConstellationPosition( + index: number, + width: number, + height: number, + maximumRadius: number, +) { + if (index === 0) { + return { x: width / 2, y: height / 2 }; + } + + const goldenAngle = Math.PI * (3 - Math.sqrt(5)); + const spacing = maximumRadius * 2 + 24; + const orbit = spacing * Math.sqrt(index); + return { + x: width / 2 + Math.cos(index * goldenAngle) * orbit, + y: height / 2 + Math.sin(index * goldenAngle) * orbit, + }; +} + +export function buildConstellationGraph( + orderedNodes: readonly DashboardNode[], + includeCompleted: boolean, +): ConstellationGraph { + const nodes = includeCompleted + ? [...orderedNodes] + : orderedNodes.filter((node) => node.completedAt === null); + const visibleIds = new Set(nodes.map((node) => node.id)); + const maximumDurationSeconds = nodes.reduce( + (maximum, node) => + Math.max(maximum, constellationDurationSeconds(node, includeCompleted)), + 0, + ); + const radiusByNodeId = new Map( + nodes.map((node) => [ + node.id, + constellationRadius( + constellationDurationSeconds(node, includeCompleted), + maximumDurationSeconds, + ), + ]), + ); + const links = nodes.flatMap((node) => + node.parentId && visibleIds.has(node.parentId) + ? [{ sourceId: node.parentId, targetId: node.id }] + : [], + ); + + return { links, nodes, radiusByNodeId }; +} diff --git a/tests/e2e/foundation.spec.ts b/tests/e2e/foundation.spec.ts index 4c8c179..fbb2ad9 100644 --- a/tests/e2e/foundation.spec.ts +++ b/tests/e2e/foundation.spec.ts @@ -135,6 +135,48 @@ async function touchDragNodeTo( .toBe("touch"); } +async function nudgeConstellationBubble(page: Page, bubble: Locator, useTouch: boolean) { + const bounds = await bubble.boundingBox(); + expect(bounds).not.toBeNull(); + const start = { + x: bounds!.x + bounds!.width / 2, + y: bounds!.y + bounds!.height / 2, + }; + const end = { x: start.x + 48, y: start.y + 32 }; + + if (!useTouch) { + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + await page.mouse.move(end.x, end.y, { steps: 6 }); + await page.mouse.up(); + return; + } + + const session = await page.context().newCDPSession(page); + await session.send("Input.dispatchTouchEvent", { + type: "touchStart", + touchPoints: [{ ...start, id: 1, radiusX: 1, radiusY: 1, force: 1 }], + }); + for (let step = 1; step <= 8; step += 1) { + const progress = step / 8; + await session.send("Input.dispatchTouchEvent", { + type: "touchMove", + touchPoints: [ + { + x: start.x + (end.x - start.x) * progress, + y: start.y + (end.y - start.y) * progress, + id: 1, + radiusX: 1, + radiusY: 1, + force: 1, + }, + ], + }); + } + await session.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); + await session.detach(); +} + async function dragNodeWithAutoScroll(page: Page, sourceName: string, targetName: string) { const pane = page.locator(".tree-pane"); const sourceRow = page.getByRole("button", { name: sourceName, exact: true }).locator(".."); @@ -549,6 +591,147 @@ test("changes historical rollups when completed nodes are shown", async ({ conte } }); +test("explores the node constellation without changing the tree", async ({ + context, + isMobile, + page, +}) => { + const seeded = await seedSession(allowedEmail, true); + + try { + const rootId = await insertNode(seeded.userId, "Map root", 0); + const childId = await insertNode(seeded.userId, "Map child", 0, rootId); + const completedId = await insertNode( + seeded.userId, + "Map completed", + 1, + rootId, + true, + ); + await pool.query( + `insert into time_entries + (user_id, node_id, work_date, duration_seconds, hourly_rate_cents) + values + ($1, $2, '2026-07-22', 3600, 10000), + ($1, $3, '2026-07-22', 7200, 10000), + ($1, $4, '2026-07-22', 1800, 10000)`, + [seeded.userId, rootId, childId, completedId], + ); + await pool.query( + `insert into active_timers (user_id, node_id, started_at, work_date) + values ($1, $2, now() - interval '5 minutes', '2026-07-22')`, + [seeded.userId, childId], + ); + await context.addCookies([ + { + name: "better-auth.session_token", + value: seeded.cookie, + domain: "127.0.0.1", + path: "/", + httpOnly: true, + sameSite: "Lax", + }, + ]); + await page.goto(`/?node=${childId}`); + + const constellationToggle = page.getByRole("button", { name: "Node constellation" }); + await expect(constellationToggle).toHaveAttribute("aria-pressed", "false"); + await constellationToggle.click(); + await expect(constellationToggle).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByRole("heading", { level: 1, name: "Node Constellation" })).toBeVisible(); + await expect(page.getByRole("heading", { level: 1, name: "Node tree" })).toHaveCount(0); + + const rootBubble = page.locator(".constellation-node").filter({ hasText: "Map root" }); + const childBubble = page.locator(".constellation-node").filter({ hasText: "Map child" }); + await expect(rootBubble).toBeVisible(); + await expect(childBubble).toBeVisible(); + await expect(page.getByRole("button", { name: /Map completed:/ })).toHaveCount(0); + await expect(childBubble).toHaveClass(/constellation-node--running/); + + await rootBubble.focus(); + await expect(rootBubble).toBeFocused(); + const card = page.locator(".constellation-card"); + await expect(card).toContainText("Map root"); + await expect(card).toContainText("3h"); + await rootBubble.press("Enter"); + const openInTree = card.getByRole("button", { name: "Open in tree" }); + await expect(openInTree).toBeFocused(); + await openInTree.press("Enter"); + await expect(page).toHaveURL(new RegExp(`node=${rootId}`)); + await expect(constellationToggle).toHaveAttribute("aria-pressed", "false"); + if (isMobile) { + await expect(page.locator('[aria-label="Map root details"]')).toBeFocused(); + } + await constellationToggle.click(); + await rootBubble.focus(); + await rootBubble.press("Enter"); + await expect(card.getByRole("button", { name: "Open in tree" })).toBeFocused(); + await card.getByRole("button", { name: "Open in tree" }).press("Enter"); + if (isMobile) { + await expect(page.locator('[aria-label="Map root details"]')).toBeFocused(); + } + await constellationToggle.click(); + + await page.getByRole("button", { name: "Zoom in" }).click(); + await expect(page.getByRole("button", { name: "Reset constellation" })).toBeEnabled(); + await page.getByRole("button", { name: "Reset constellation" }).click(); + + await page.getByRole("button", { name: "Show completed" }).click(); + await expect(page.getByRole("button", { name: /Map completed: 30m rolled up/ })).toBeVisible(); + await expect(rootBubble).toHaveAttribute( + "aria-label", + "Map root: 3h 30m rolled up, 1h direct, active", + ); + + await page.getByRole("button", { name: "Map child Map root / Map child" }).click(); + await expect(page).toHaveURL(new RegExp(`node=${childId}`)); + await expect(constellationToggle).toHaveAttribute("aria-pressed", "false"); + await expect(page.getByRole("heading", { level: 1, name: "Map child" })).toBeVisible(); + await expect( + page.evaluate( + () => document.documentElement.scrollWidth > document.documentElement.clientWidth, + ), + ).resolves.toBe(false); + } finally { + await seeded.cleanup(); + } +}); + +test("keeps constellation nudging available with reduced motion", async ({ + context, + isMobile, + page, +}) => { + const seeded = await seedSession(allowedEmail, true); + + try { + await insertNode(seeded.userId, "Still root", 0); + await context.addCookies([ + { + name: "better-auth.session_token", + value: seeded.cookie, + domain: "127.0.0.1", + path: "/", + httpOnly: true, + sameSite: "Lax", + }, + ]); + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto("/"); + await page.getByRole("button", { name: "Node constellation" }).click(); + + const bubble = page.locator(".constellation-node").filter({ hasText: "Still root" }); + await expect(bubble).toBeVisible(); + await page.waitForTimeout(150); + const initialTransform = await bubble.getAttribute("transform"); + await nudgeConstellationBubble(page, bubble, isMobile); + + await expect(bubble).not.toHaveAttribute("transform", initialTransform!); + } finally { + await seeded.cleanup(); + } +}); + test.describe("tree period filter", () => { test.use({ timezoneId: "America/Los_Angeles" }); diff --git a/tests/unit/node-constellation.test.ts b/tests/unit/node-constellation.test.ts new file mode 100644 index 0000000..694cba2 --- /dev/null +++ b/tests/unit/node-constellation.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; + +import { + buildConstellationGraph, + CONSTELLATION_MAX_RADIUS, + CONSTELLATION_MIN_RADIUS, + constellationDurationSeconds, + constellationRadius, + staticConstellationPosition, +} from "../../src/lib/nodes/constellation"; +import { assembleNodeTree, type FlatNode } from "../../src/lib/nodes/tree"; + +function node(id: string, parentId: string | null, position: number, completed = false): FlatNode { + return { + id, + parentId, + position, + title: id, + description: null, + hourlyRateCents: null, + completedAt: completed ? "2026-07-22T00:00:00.000Z" : null, + }; +} + +describe("node constellation", () => { + it("uses bounded square-root scaling while retaining zero-hour nodes", () => { + expect(constellationRadius(0, 10_000)).toBe(CONSTELLATION_MIN_RADIUS); + expect(constellationRadius(10_000, 10_000)).toBe(CONSTELLATION_MAX_RADIUS); + expect(constellationRadius(2_500, 10_000)).toBe( + CONSTELLATION_MIN_RADIUS + + (CONSTELLATION_MAX_RADIUS - CONSTELLATION_MIN_RADIUS) / 2, + ); + }); + + it("provides an immediate deterministic reduced-motion layout", () => { + expect(staticConstellationPosition(0, 800, 600, 68)).toEqual({ x: 400, y: 300 }); + expect(staticConstellationPosition(4, 800, 600, 68)).toEqual( + staticConstellationPosition(4, 800, 600, 68), + ); + expect(staticConstellationPosition(4, 800, 600, 68)).not.toEqual( + staticConstellationPosition(5, 800, 600, 68), + ); + }); + + it("uses the visible rollup and omits completed branches until requested", () => { + const tree = assembleNodeTree( + [ + node("root", null, 0), + node("active", "root", 0), + node("completed", "root", 1, true), + node("completed-child", "completed", 0, true), + ], + [ + { + nodeId: "root", + durationSeconds: 3_600, + pricedValueNumerator: "0", + hasPricedTime: false, + hasUnpricedTime: true, + }, + { + nodeId: "active", + durationSeconds: 1_800, + pricedValueNumerator: "0", + hasPricedTime: false, + hasUnpricedTime: true, + }, + { + nodeId: "completed", + durationSeconds: 900, + pricedValueNumerator: "0", + hasPricedTime: false, + hasUnpricedTime: true, + }, + { + nodeId: "completed-child", + durationSeconds: 300, + pricedValueNumerator: "0", + hasPricedTime: false, + hasUnpricedTime: true, + }, + ], + ); + + const activeGraph = buildConstellationGraph(tree.ordered, false); + expect(activeGraph.nodes.map(({ id }) => id)).toEqual(["root", "active"]); + expect(activeGraph.links).toEqual([{ sourceId: "root", targetId: "active" }]); + expect(constellationDurationSeconds(tree.byId.get("root")!, false)).toBe(5_400); + + const completeGraph = buildConstellationGraph(tree.ordered, true); + expect(completeGraph.nodes.map(({ id }) => id)).toEqual([ + "root", + "active", + "completed", + "completed-child", + ]); + expect(completeGraph.links).toEqual([ + { sourceId: "root", targetId: "active" }, + { sourceId: "root", targetId: "completed" }, + { sourceId: "completed", targetId: "completed-child" }, + ]); + expect(constellationDurationSeconds(tree.byId.get("root")!, true)).toBe(6_600); + }); +});