"use client"; import { useEffect, useMemo, useRef, useState } from "react"; type Granularity = "year" | "month" | "day"; type TreeNode = { id: string; label: string; ts: string; // ISO string from API countTotal: number; countReady: number; children?: TreeNode[]; }; type ApiTreeRow = { bucket: string; group_ts: string; count_total: number; count_ready: number; }; type ApiTreeResponse = { granularity: Granularity; start: string | null; end: string | null; nodes: ApiTreeRow[]; }; type Orientation = "vertical" | "horizontal"; type ExpandedState = Record; function monthKey(d: Date) { return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`; } function dayKey(d: Date) { return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`; } function yearKey(d: Date) { return `${d.getUTCFullYear()}`; } function buildHierarchy(dayRows: ApiTreeRow[]): TreeNode[] { const years = new Map(); const months = new Map(); for (const row of dayRows) { const date = new Date(row.group_ts); const year = yearKey(date); const month = monthKey(date); const day = dayKey(date); const yMapKey = `${row.bucket}:${year}`; const mMapKey = `${row.bucket}:${month}`; let yNode = years.get(yMapKey); if (!yNode) { yNode = { id: `y:${year}:${row.bucket}`, label: year, ts: new Date(Date.UTC(Number(year), 0, 1)).toISOString(), countTotal: 0, countReady: 0, children: [], }; years.set(yMapKey, yNode); } let mNode = months.get(mMapKey); if (!mNode) { const [yy, mm] = month.split("-"); mNode = { id: `m:${month}:${row.bucket}`, label: `${yy}-${mm}`, ts: new Date(Date.UTC(Number(yy), Number(mm) - 1, 1)).toISOString(), countTotal: 0, countReady: 0, children: [], }; months.set(mMapKey, mNode); yNode.children!.push(mNode); } const dNode: TreeNode = { id: `d:${day}:${row.bucket}`, label: day, ts: row.group_ts, countTotal: row.count_total, countReady: row.count_ready, }; mNode.children!.push(dNode); // rollups mNode.countTotal += row.count_total; mNode.countReady += row.count_ready; yNode.countTotal += row.count_total; yNode.countReady += row.count_ready; } // Sort ascending within each parent const yearNodes = Array.from(years.values()).sort((a, b) => a.label.localeCompare(b.label)); for (const y of yearNodes) { y.children = (y.children ?? []).sort((a, b) => a.label.localeCompare(b.label)); for (const m of y.children) { m.children = (m.children ?? []).sort((a, b) => a.label.localeCompare(b.label)); } } return yearNodes; } function gatherVisible( roots: TreeNode[], expanded: ExpandedState, ): Array<{ node: TreeNode; depth: number; parentId: string | null }> { const out: Array<{ node: TreeNode; depth: number; parentId: string | null }> = []; function walk(nodes: TreeNode[], depth: number, parentId: string | null) { for (const n of nodes) { out.push({ node: n, depth, parentId }); const isExpanded = expanded[n.id] ?? depth < 1; // default expand years if (n.children?.length && isExpanded) { walk(n.children, depth + 1, n.id); } } } walk(roots, 0, null); return out; } export function TimelineTree(props: { onSelectDay?: (dayIso: string) => void }) { const [orientation, setOrientation] = useState("vertical"); const [expanded, setExpanded] = useState({}); const [rows, setRows] = useState(null); const [error, setError] = useState(null); // simple pan/zoom via viewBox const svgRef = useRef(null); const [viewBox, setViewBox] = useState({ x: 0, y: 0, w: 1200, h: 800 }); const dragState = useRef<{ active: boolean; startX: number; startY: number; startViewBox: typeof viewBox; } | null>(null); useEffect(() => { let cancelled = false; async function load() { try { setError(null); const res = await fetch("/api/tree?granularity=day&limit=500&includeFailed=1", { cache: "no-store", }); if (!res.ok) throw new Error(`tree_fetch_failed:${res.status}`); const json = (await res.json()) as ApiTreeResponse; if (!cancelled) setRows(json.nodes); } catch (e) { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); } } void load(); return () => { cancelled = true; }; }, []); const roots = useMemo(() => (rows ? buildHierarchy(rows) : []), [rows]); const visible = useMemo(() => gatherVisible(roots, expanded), [roots, expanded]); const layout = useMemo(() => { const nodeGap = 56; const depthGap = 180; const positions = new Map(); for (let i = 0; i < visible.length; i++) { const { node, depth } = visible[i]; const primary = i * nodeGap; const secondary = depth * depthGap; const x = orientation === "vertical" ? secondary : primary; const y = orientation === "vertical" ? primary : secondary; positions.set(node.id, { x, y }); } // compute bounds let maxX = 0; let maxY = 0; for (const p of positions.values()) { maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); } const padding = 120; const w = maxX + padding * 2; const h = maxY + padding * 2; return { positions, w, h, padding }; }, [visible, orientation]); useEffect(() => { // reset viewBox when layout changes setViewBox((vb) => ({ ...vb, w: Math.max(layout.w, 1200), h: Math.max(layout.h, 800) })); }, [layout.w, layout.h]); function toggleNode(id: string) { setExpanded((prev) => ({ ...prev, [id]: !(prev[id] ?? false) })); } function onPointerDown(e: React.PointerEvent) { (e.currentTarget as SVGSVGElement).setPointerCapture(e.pointerId); dragState.current = { active: true, startX: e.clientX, startY: e.clientY, startViewBox: viewBox, }; } function onPointerMove(e: React.PointerEvent) { if (!dragState.current?.active) return; const dx = e.clientX - dragState.current.startX; const dy = e.clientY - dragState.current.startY; // Convert pixels to viewBox units roughly; this is intentionally simple. const scaleX = viewBox.w / 900; const scaleY = viewBox.h / 600; setViewBox({ ...viewBox, x: dragState.current.startViewBox.x - dx * scaleX, y: dragState.current.startViewBox.y - dy * scaleY, }); } function onPointerUp(e: React.PointerEvent) { dragState.current = null; try { (e.currentTarget as SVGSVGElement).releasePointerCapture(e.pointerId); } catch { // ignore } } function onWheel(e: React.WheelEvent) { e.preventDefault(); const factor = e.deltaY < 0 ? 0.9 : 1.1; const mouseX = e.clientX; const mouseY = e.clientY; const rect = e.currentTarget.getBoundingClientRect(); const px = (mouseX - rect.left) / rect.width; const py = (mouseY - rect.top) / rect.height; const newW = Math.max(300, Math.min(5000, viewBox.w * factor)); const newH = Math.max(300, Math.min(5000, viewBox.h * factor)); const newX = viewBox.x + (viewBox.w - newW) * px; const newY = viewBox.y + (viewBox.h - newH) * py; setViewBox({ x: newX, y: newY, w: newW, h: newH }); } return (
Timeline {rows ? {rows.length} day nodes : null}
{error ?
Error: {error}
: null} {!rows && !error ?
Loading tree…
: null}
{/* edges */} {visible.map(({ node, parentId }) => { if (!parentId) return null; const p = layout.positions.get(parentId); const c = layout.positions.get(node.id); if (!p || !c) return null; return ( ${node.id}`} x1={p.x} y1={p.y} x2={c.x} y2={c.y} stroke="#bbb" strokeWidth={2} /> ); })} {/* nodes */} {visible.map(({ node }) => { const pos = layout.positions.get(node.id); if (!pos) return null; const hasChildren = Boolean(node.children?.length); const isExpanded = expanded[node.id] ?? node.id.startsWith("y:"); const isDay = node.id.startsWith("d:"); const clickCursor = hasChildren || isDay ? "pointer" : "default"; return ( { if (hasChildren) toggleNode(node.id); if (isDay) props.onSelectDay?.(node.ts); }} > {node.label} ({node.countReady}/{node.countTotal}) {hasChildren ? (isExpanded ? " ▼" : " ▶") : ""} ); })}
Drag to pan, mouse wheel to zoom. Click years/months to expand.
); }