Timeline: hover/focus tooltips, stage 0 lane, adaptive date axis

Three improvements to the report's DateTimeline:

1. Tooltips on every dot. Each dot is now a focusable span (tabIndex,
   role=img, full aria-label). A small ink-tinted card appears above
   the dot on mouse hover or keyboard focus, showing field label,
   formatted date, stage rank, and an 'Opened' marker for Date_Opened.
   Anchor flips to left/center/right based on the dot's position so
   tooltips don't overflow the row at the edges.

2. Plot every date field, including stage 0. The previous version
   skipped Stage 0 dates (Internal_Startup_Assessment_Date,
   Date_Closed_Folded). Now there are six swim lanes (0-5) instead
   of five. Stage 0 gets bg-leaf-200 so the gradient extends one
   step lighter.

3. Adaptive month/year x-axis under the lanes. generateAxisTicks
   picks a 'nice' interval based on the visible span: 1mo / 2mo /
   3mo / 6mo / 1yr / 2yr. January-bordered ticks include the year
   so the reader has anchors. Today gets its own labeled clay tick
   when it falls in range.
This commit is contained in:
Joel Brock
2026-05-19 16:32:50 -07:00
parent 814b560363
commit 00e10a992f
+193 -22
View File
@@ -220,12 +220,13 @@ function StageProgress({ currentRank }: { currentRank: number }) {
/**
* Date-grouped timeline strip at the top of the report. Walks every
* date-type field in stage sections 15 (Stage 0 omitted intentionally),
* pulls each field's most-recent entered date, and plots events into
* five horizontal swim lanes — one per stage. 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's date
* falls within the plotted range.
* date-type field across all stage sections (05), pulls each field's
* most-recent entered date, and plots events into six horizontal swim
* lanes — one per stage. 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.
*/
function DateTimeline({
sections,
@@ -242,7 +243,7 @@ function DateTimeline({
};
const events: Event[] = [];
for (const section of sections) {
if (section.rank < 1 || section.rank > 5) continue;
if (section.rank < 0 || section.rank > 5) continue;
for (const f of section.fields) {
if (f.type !== "date") continue;
const history = fieldHistory[f.name];
@@ -262,9 +263,8 @@ function DateTimeline({
const times = events.map((e) => new Date(e.date).getTime()).filter(Number.isFinite);
if (times.length === 0) return null;
const openedTime = events.find((e) => e.isOpened)?.date
? new Date(events.find((e) => e.isOpened)!.date).getTime()
: undefined;
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.
@@ -274,6 +274,8 @@ function DateTimeline({
const todayPct =
todayT >= minT && todayT <= maxT ? ((todayT - minT) / tRange) * 100 : null;
const ticks = generateAxisTicks(minT, maxT);
return (
<section
aria-labelledby="timeline-heading"
@@ -293,7 +295,7 @@ function DateTimeline({
</p>
</div>
<ol className="mt-4 space-y-2">
{[1, 2, 3, 4, 5].map((rank) => {
{[0, 1, 2, 3, 4, 5].map((rank) => {
const rowEvents = events.filter((e) => e.rank === rank);
const isEmpty = rowEvents.length === 0;
return (
@@ -315,7 +317,6 @@ function DateTimeline({
{todayPct !== null && (
<span
aria-hidden
title="Today"
style={{ left: `${todayPct}%` }}
className="absolute top-0 bottom-0 w-px -translate-x-1/2 border-l border-dashed border-clay-300/70"
/>
@@ -324,18 +325,12 @@ function DateTimeline({
const t = new Date(e.date).getTime();
if (!Number.isFinite(t)) return null;
const x = ((t - minT) / tRange) * 100;
const bg = e.isOpened ? "bg-clay-700" : stageDotBg(rank);
const size = e.isOpened ? "h-3.5 w-3.5" : "h-2.5 w-2.5";
const ring = e.isOpened ? "ring-2" : "ring-1";
return (
<span
<TimelineDot
key={i}
title={`${e.fieldLabel}${formatShortDate(e.date)}`}
style={{ left: `${x}%` }}
className={
"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full shadow-sm " +
`${ring} ring-paper ${size} ${bg}`
}
rank={rank}
event={e}
x={x}
/>
);
})}
@@ -344,6 +339,65 @@ function DateTimeline({
);
})}
</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) => (
@@ -359,6 +413,8 @@ function DateTimeline({
function stageDotBg(rank: number): string {
switch (rank) {
case 0:
return "bg-leaf-200";
case 1:
return "bg-leaf-300";
case 2:
@@ -374,6 +430,121 @@ function stageDotBg(rank: number): string {
}
}
/**
* 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.
*/
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;
}
function ReportSection({
section,
fields,