"use client"; import { CheckIcon } from "lucide-react"; import { motion, useReducedMotion } from "motion/react"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export interface DriftStep { id: string; label: string; planned: number; actual?: number; done?: boolean; } export interface DriftChartProps { title: string; agent?: string; steps: DriftStep[]; unit?: string; tickEvery?: number; className?: string; } type Laid = DriftStep & { start: number; ghost: number; bar: number; state: "done" | "running" | "todo" }; function layout(steps: DriftStep[]) { let cursor = 0; const rows: Laid[] = steps.map((step) => { const start = cursor; const state: Laid["state"] = step.done ? "done" : step.actual !== undefined ? "running" : "todo"; const bar = step.actual ?? 0; const span = state === "done" ? bar : state === "running" ? Math.max(bar, step.planned) : step.planned; cursor += span; return { ...step, start, ghost: step.planned, bar, state }; }); return { rows, expected: cursor }; } function useReducedMotionAfterMount() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && !!prefers; } const BAR_Y = 4; const BAR_H = 20; const RADIUS = 6; function barPath(x: number, w: number, left: number, right: number, y = BAR_Y, h = BAR_H) { const width = Math.max(0, w); const rl = Math.min(left, width / 2, h / 2); const rr = Math.min(right, width / 2, h / 2); const f = (n: number) => n.toFixed(2); return [ `M${f(x + rl)},${f(y)}`, `H${f(x + width - rr)}`, `A${f(rr)},${f(rr)} 0 0 1 ${f(x + width)},${f(y + rr)}`, `V${f(y + h - rr)}`, `A${f(rr)},${f(rr)} 0 0 1 ${f(x + width - rr)},${f(y + h)}`, `H${f(x + rl)}`, `A${f(rl)},${f(rl)} 0 0 1 ${f(x)},${f(y + h - rl)}`, `V${f(y + rl)}`, `A${f(rl)},${f(rl)} 0 0 1 ${f(x + rl)},${f(y)}`, "Z", ].join(" "); } export function DriftChart({ title, agent, steps, unit = "m", tickEvery = 5, className }: DriftChartProps) { const reduce = useReducedMotionAfterMount(); const axisRef = useRef(null); const [trackW, setTrackW] = useState(0); useLayoutEffect(() => { const el = axisRef.current; if (!el) return; setTrackW(el.offsetWidth); const ro = new ResizeObserver(([entry]) => setTrackW(entry.contentRect.width)); ro.observe(el); return () => ro.disconnect(); }, []); const { rows, expected } = layout(steps); const planned = steps.reduce((s, x) => s + x.planned, 0); const max = Math.max(planned, expected) * 1.08; const px = (t: number) => (t / max) * trackW; const over = expected - planned; const late = over > 0.05; const doneCount = rows.filter((r) => r.state === "done").length; const running = rows.find((r) => r.state === "running"); const finished = doneCount === rows.length; const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 170, damping: 26 }; const ticks = Array.from({ length: Math.floor(max / tickEvery) + 1 }, (_, i) => i * tickEvery); const fmt = (t: number) => `${Math.round(t)}${unit}`; return (
{title} {!finished && !reduce && } {agent && {agent}} {agent && ยท} {finished ? "Finished" : running ? running.label : "Queued"}
Planned {fmt(planned)}
{finished ? "Took" : "Expected"} {fmt(expected)}
{rows.map((row) => { const overrun = row.bar > row.ghost; const x = px(row.start); return (
{row.state === "done" && } {row.state === "running" && } {row.label}
{trackW > 0 && ( <> {row.state !== "todo" && row.bar > 0 && ( {overrun ? `+${Math.round((row.bar - row.ghost) * 10) / 10}${unit}` : fmt(row.bar)} )} )}
); })} {trackW > 0 && (
)}
{trackW > 0 && ticks.map((t, i) => ( 0 && "-translate-x-1/2")} initial={false} key={t} transition={spring} > {t} {unit} ))}
Plan Actual Overrun {late ? ( <> Finish pushed +{Math.round(over)}{unit} ) : ( "On plan" )}
); }