"use client"; import type { ActivitySummary, FieldHistoryEntry, StageSectionConfig } from "@/types/form"; import { formatShortDate } from "./FieldHistory"; import { buildStageRankAtDate, computeStageRanges, type StageRange } from "@/lib/stageRank"; /** * Date-grouped timeline strip at the top of the report. Renders five * horizontal swim lanes (Stage 5 on top down to Stage 1 at the bottom) * and overlays two kinds of events: * * 1. Stage-period ranges, one per stage-transition activity, drawn * as a soft bar from the activity date to the date of the next * activity that moved the co-op to a higher stage. The latest * open stage extends to today. * 2. Milestone dots from date-type custom fields. The dot is placed * on the lane corresponding to the stage the co-op was in on the * field's stored date (resolved from the activity stream). When * that's unknowable (date precedes any transition), it falls back * to the field's own section rank. Stage 5's `Date_Opened` is * pinned to Stage 5 as the journey anchor. * * A faint "Today" tick anchors the reader if today falls within the * range, and an adaptive month/year axis runs beneath the lanes. Each * dot exposes a tooltip on hover or keyboard focus. */ export function DateTimeline({ sections, fieldHistory, activities, }: { sections: StageSectionConfig[]; fieldHistory: Record; activities: ActivitySummary[]; }) { const stageRankAtDate = buildStageRankAtDate(activities); const ranges = computeStageRanges(activities); type MilestoneEvent = { rank: number; fieldLabel: string; date: string; isOpened: boolean; }; const milestones: MilestoneEvent[] = []; for (const section of sections) { for (const f of section.fields) { if (f.type !== "date") continue; const history = fieldHistory[f.name]; if (!history || history.length === 0) continue; const v = history[0].value; if (typeof v !== "string" || v.length === 0) continue; const isOpened = f.name === "Date_Opened"; const resolved = stageRankAtDate(v); let rank = isOpened ? 5 : (resolved ?? section.rank); if (rank < 1 || rank > 5) rank = section.rank; if (rank < 1 || rank > 5) continue; milestones.push({ rank, fieldLabel: f.label, date: v, isOpened }); } } // Collect every time value that should influence the time axis: range // starts, range ends (excluding open-ended), milestone dates, and today // if any range is open (so the open range visibly extends to "now"). const timeBag: number[] = []; let hasOpenRange = false; for (const r of ranges) { const ts = new Date(r.startDate).getTime(); if (Number.isFinite(ts)) timeBag.push(ts); if (r.endDate === null) { hasOpenRange = true; } else { const te = new Date(r.endDate).getTime(); if (Number.isFinite(te)) timeBag.push(te); } } for (const m of milestones) { const t = new Date(m.date).getTime(); if (Number.isFinite(t)) timeBag.push(t); } const todayT = Date.now(); if (hasOpenRange) timeBag.push(todayT); if (timeBag.length === 0) return null; let minT = Math.min(...timeBag); let maxT = Math.max(...timeBag); if (minT === maxT) { const pad = 30 * 24 * 3600 * 1000; minT -= pad; maxT += pad; } const tRange = maxT - minT || 1; const todayPct = todayT >= minT && todayT <= maxT ? ((todayT - minT) / tRange) * 100 : null; const ticks = generateAxisTicks(minT, maxT); const rangesByRank = new Map(); for (const r of ranges) { const list = rangesByRank.get(r.rank) ?? []; list.push(r); rangesByRank.set(r.rank, list); } return (

Timeline

{formatShortDate(new Date(minT).toISOString())} {" → "} {formatShortDate(new Date(maxT).toISOString())}

    {[5, 4, 3, 2, 1].map((rank) => { const rowRanges = rangesByRank.get(rank) ?? []; const rowMilestones = milestones.filter((e) => e.rank === rank); const isEmpty = rowRanges.length === 0 && rowMilestones.length === 0; return (
  1. Stage {rank}
    {/* Stage-period ranges: soft bar from activity start to next higher-stage activity (or to today if still open). */} {rowRanges.map((r, i) => { const ts = new Date(r.startDate).getTime(); if (!Number.isFinite(ts)) return null; const endT = r.endDate === null ? Math.max(todayT, ts) : new Date(r.endDate).getTime(); if (!Number.isFinite(endT)) return null; const startPct = ((ts - minT) / tRange) * 100; const endPct = ((endT - minT) / tRange) * 100; const widthPct = Math.max(endPct - startPct, 0.5); return ( ); })} {todayPct !== null && ( )} {/* Activity start dots — placed at each range start for keyboard focus + tooltip with the activity subject. */} {rowRanges.map((r, i) => { const t = new Date(r.startDate).getTime(); if (!Number.isFinite(t)) return null; const x = ((t - minT) / tRange) * 100; return ( ); })} {/* Milestone date-field dots layered on top. */} {rowMilestones.map((e, i) => { const t = new Date(e.date).getTime(); if (!Number.isFinite(t)) return null; const x = ((t - minT) / tRange) * 100; return ; })}
  2. ); })}
{/* Adaptive month/year axis. Render only when we have ≥2 ticks so a single tick doesn't dangle. Today gets its own labeled mark when it falls within range. */} {ticks.length >= 2 && (
{ticks.map((tk, i) => { const x = ((tk.t - minT) / tRange) * 100; const alignClass = x < 6 ? "left-0 origin-top-left" : x > 94 ? "right-0 origin-top-right text-right" : "left-1/2 -translate-x-1/2 text-center"; return ( {tk.label} ); })} {todayPct !== null && ( Today )}
)} {/* Accessible event list — invisible to sighted users but readable by SR */}
    {ranges.map((r, i) => (
  • Stage {r.rank}: {formatShortDate(r.startDate)} {r.endDate ? ` to ${formatShortDate(r.endDate)}` : " (current)"}
  • ))} {milestones.map((e, i) => (
  • Stage {e.rank}: {e.fieldLabel} — {formatShortDate(e.date)} {e.isOpened ? " (opened)" : ""}
  • ))}
); } function stageDotBg(rank: number): string { switch (rank) { case 1: return "bg-leaf-300"; case 2: return "bg-leaf-500"; case 3: return "bg-leaf-600"; case 4: return "bg-leaf-700"; case 5: return "bg-clay-700"; default: return "bg-leaf-600"; } } function stageRangeBg(rank: number): string { switch (rank) { case 1: return "bg-leaf-300/30"; case 2: return "bg-leaf-500/25"; case 3: return "bg-leaf-600/25"; case 4: return "bg-leaf-700/25"; case 5: return "bg-clay-700/25"; default: return "bg-leaf-500/25"; } } /** Soft horizontal bar marking a stage period on a lane. */ function StageRangeBar({ rank, range, startPct, widthPct, isOpen, }: { rank: number; range: StageRange; startPct: number; widthPct: number; isOpen: boolean; }) { return ( ); } /** Dot at the start of a stage period; carries the activity tooltip. */ function ActivityDot({ rank, range, x, }: { rank: number; range: StageRange; x: number; }) { const tipAlign = x < 18 ? "left-0" : x > 82 ? "right-0" : "left-1/2 -translate-x-1/2"; return ( ); } /** * A single milestone date-field dot plus its hover/focus tooltip. Keyboard * users can Tab to each dot and the tooltip will appear via * `group-focus-within`. The tooltip is anchored above the dot; for dots * near the left or right edge of the row, anchor flips so the card * doesn't overflow. */ function MilestoneDot({ rank, event, x, }: { rank: number; event: { fieldLabel: string; date: string; isOpened: boolean }; x: number; }) { const isOpened = event.isOpened; const bg = isOpened ? "bg-clay-700" : stageDotBg(rank); const size = isOpened ? "h-3.5 w-3.5" : "h-2.5 w-2.5"; const ring = isOpened ? "ring-2" : "ring-1"; const tipAlign = x < 18 ? "left-0" : x > 82 ? "right-0" : "left-1/2 -translate-x-1/2"; return ( ); } /** * Pick a "nice" tick interval for the time axis based on the visible * span, then walk that interval from min to max producing labeled ticks. * Uses months for shorter spans, years for longer ones; January-bordered * month ticks include the year so the reader has an anchor. */ export function generateAxisTicks(minT: number, maxT: number): Array<{ t: number; label: string }> { const range = maxT - minT; const monthMs = 30.4375 * 24 * 3600 * 1000; const months = range / monthMs; type Step = { unit: "month" | "year"; step: number }; let plan: Step; if (months < 4) plan = { unit: "month", step: 1 }; else if (months < 12) plan = { unit: "month", step: 2 }; else if (months < 24) plan = { unit: "month", step: 3 }; else if (months < 48) plan = { unit: "month", step: 6 }; else if (months / 12 < 12) plan = { unit: "year", step: 1 }; else plan = { unit: "year", step: 2 }; const ticks: Array<{ t: number; label: string }> = []; const start = new Date(minT); let y = start.getFullYear(); let m = plan.unit === "year" ? 0 : Math.floor(start.getMonth() / plan.step) * plan.step; while (true) { const t = new Date(y, m, 1).getTime(); if (t > maxT) break; if (t >= minT) { const d = new Date(y, m, 1); const label = plan.unit === "year" ? String(y) : m === 0 ? d.toLocaleDateString("en-US", { month: "short", year: "numeric" }) : d.toLocaleDateString("en-US", { month: "short" }); ticks.push({ t, label }); } if (plan.unit === "year") { y += plan.step; } else { m += plan.step; while (m > 11) { m -= 12; y += 1; } } } return ticks; }