Files
WebForm-mw/components/report/DateTimeline.tsx
T
Joel Brock 8dfbbea0ba Report: drop Stage 0 lane, flip 5→1 axis, fix Actual label clipping
DateTimeline now skips section rank 0 (intake) and renders stage lanes
top-to-bottom 5→1, matching how the framework actually numbers stages.

MembershipChart's latest-Actual label was placed at the circle's
x + 6, which overflowed the SVG when the most-recent point landed near
the right padding. Anchor it to xOf(maxT) - 4 with textAnchor="end",
mirroring the Goal label so both endpoint labels stay inside the chart.
2026-06-11 13:39:42 -07:00

333 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import type { FieldHistoryEntry, StageSectionConfig } from "@/types/form";
import { formatShortDate } from "./FieldHistory";
/**
* Date-grouped timeline strip at the top of the report. Walks every
* date-type field across stage sections 15, pulls each field's
* most-recent entered date, and plots events into five horizontal swim
* lanes — one per stage, Stage 5 on top down to Stage 1 at the bottom.
* Stage 5's `Date_Opened` gets visual emphasis as the journey's anchor
* at the right end of the time axis. 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,
}: {
sections: StageSectionConfig[];
fieldHistory: Record<string, FieldHistoryEntry[]>;
}) {
type Event = {
rank: number;
fieldLabel: string;
date: string; // YYYY-MM-DD or full ISO
isOpened: boolean;
};
const events: Event[] = [];
for (const section of sections) {
if (section.rank < 1 || section.rank > 5) continue;
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;
events.push({
rank: section.rank,
fieldLabel: f.label,
date: v,
isOpened: f.name === "Date_Opened",
});
}
}
if (events.length === 0) return null;
const times = events.map((e) => new Date(e.date).getTime()).filter(Number.isFinite);
if (times.length === 0) return null;
const openedEvent = events.find((e) => e.isOpened);
const openedTime = openedEvent ? new Date(openedEvent.date).getTime() : undefined;
const minT = Math.min(...times);
// If Date_Opened is set, anchor the right edge there; if any other event
// is later, extend so nothing falls off the chart.
const maxT = Math.max(...times, openedTime ?? -Infinity);
const tRange = maxT - minT || 1;
const todayT = Date.now();
const todayPct =
todayT >= minT && todayT <= maxT ? ((todayT - minT) / tRange) * 100 : null;
const ticks = generateAxisTicks(minT, maxT);
return (
<section
aria-labelledby="timeline-heading"
className="rounded-lg border border-rule bg-paper-2/30 px-6 py-5 sm:px-7 sm:py-6"
>
<div className="flex items-baseline justify-between gap-4">
<h2
id="timeline-heading"
className="font-display text-base font-medium leading-tight text-ink"
>
Timeline
</h2>
<p className="text-[11px] uppercase tracking-[0.12em] text-ink-mute tabular-nums">
{formatShortDate(new Date(minT).toISOString())}
{" → "}
{formatShortDate(new Date(maxT).toISOString())}
</p>
</div>
<ol className="mt-4 space-y-2">
{[5, 4, 3, 2, 1].map((rank) => {
const rowEvents = events.filter((e) => e.rank === rank);
const isEmpty = rowEvents.length === 0;
return (
<li
key={rank}
className="grid grid-cols-[3.5rem_1fr] items-center gap-3 sm:grid-cols-[4.5rem_1fr]"
>
<span className="text-[10px] uppercase tracking-[0.14em] font-medium text-ink-mute">
Stage {rank}
</span>
<div className="relative h-5">
<span
aria-hidden
className={
"absolute inset-x-0 top-1/2 -translate-y-1/2 h-px " +
(isEmpty ? "bg-rule-soft/60" : "bg-rule-soft")
}
/>
{todayPct !== null && (
<span
aria-hidden
style={{ left: `${todayPct}%` }}
className="absolute top-0 bottom-0 w-px -translate-x-1/2 border-l border-dashed border-clay-300/70"
/>
)}
{rowEvents.map((e, i) => {
const t = new Date(e.date).getTime();
if (!Number.isFinite(t)) return null;
const x = ((t - minT) / tRange) * 100;
return (
<TimelineDot
key={i}
rank={rank}
event={e}
x={x}
/>
);
})}
</div>
</li>
);
})}
</ol>
{/* 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 && (
<div className="mt-3 grid grid-cols-[3.5rem_1fr] items-start gap-3 sm:grid-cols-[4.5rem_1fr]">
<span aria-hidden />
<div className="relative h-7">
<span
aria-hidden
className="absolute inset-x-0 top-0 h-px bg-rule-soft"
/>
{ticks.map((tk, i) => {
const x = ((tk.t - minT) / tRange) * 100;
// Edge labels shift so they don't overflow the row.
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 (
<span
key={i}
style={{ left: `${x}%` }}
className="absolute top-0 -translate-x-1/2"
>
<span
aria-hidden
className="block h-1.5 w-px bg-rule mx-auto"
/>
<span
className={
"absolute top-2 block whitespace-nowrap text-[9px] uppercase tracking-[0.08em] tabular-nums text-ink-mute " +
alignClass
}
>
{tk.label}
</span>
</span>
);
})}
{todayPct !== null && (
<span
style={{ left: `${todayPct}%` }}
className="absolute top-0 -translate-x-1/2"
>
<span
aria-hidden
className="block h-1.5 w-px bg-clay-500 mx-auto"
/>
<span className="absolute top-2 left-1/2 -translate-x-1/2 block whitespace-nowrap text-[9px] font-medium uppercase tracking-[0.08em] text-clay-700">
Today
</span>
</span>
)}
</div>
</div>
)}
{/* Accessible event list — invisible to sighted users but readable by SR */}
<ul className="sr-only">
{events.map((e, i) => (
<li key={i}>
Stage {e.rank}: {e.fieldLabel} {formatShortDate(e.date)}
{e.isOpened ? " (opened)" : ""}
</li>
))}
</ul>
</section>
);
}
function stageDotBg(rank: number): string {
switch (rank) {
case 0:
return "bg-leaf-200";
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";
}
}
/**
* A single dot on the timeline 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 TimelineDot({
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 (
<span
role="img"
tabIndex={0}
aria-label={`${event.fieldLabel}, ${formatShortDate(event.date)}${isOpened ? " (opened)" : ""}, stage ${rank}`}
style={{ left: `${x}%` }}
className={
"group/dot absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40 " +
`${ring} ring-paper ${size} ${bg}`
}
>
<span
role="tooltip"
className={
"pointer-events-none absolute bottom-full z-30 mb-2 hidden whitespace-nowrap rounded-md bg-ink/95 px-2.5 py-1.5 text-[11px] leading-snug text-paper shadow-lg group-hover/dot:block group-focus-within/dot:block " +
tipAlign
}
>
<span className="block font-medium">{event.fieldLabel}</span>
<span className="mt-0.5 block tabular-nums text-paper-2/80">
{formatShortDate(event.date)}
<span className="ml-2 uppercase tracking-[0.08em] text-paper-2/60">
Stage {rank}
</span>
</span>
{isOpened && (
<span className="mt-0.5 block text-clay-200 uppercase tracking-[0.08em]">
Opened
</span>
)}
</span>
</span>
);
}
/**
* 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;
// Don't skip a tick that sits right at minT — start from minT-aligned 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;
}