Report: extract DateTimeline to components/report/
This commit is contained in:
+1
-327
@@ -19,6 +19,7 @@ import {
|
|||||||
Chevron,
|
Chevron,
|
||||||
numberFmt,
|
numberFmt,
|
||||||
} from "./report/FieldHistory";
|
} from "./report/FieldHistory";
|
||||||
|
import { DateTimeline, generateAxisTicks } from "./report/DateTimeline";
|
||||||
|
|
||||||
interface ReportViewProps {
|
interface ReportViewProps {
|
||||||
config: FormConfig;
|
config: FormConfig;
|
||||||
@@ -226,333 +227,6 @@ function StageProgress({ currentRank }: { currentRank: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Date-grouped timeline strip at the top of the report. Walks every
|
|
||||||
* date-type field across all stage sections (0–5), 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,
|
|
||||||
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 < 0 || 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">
|
|
||||||
{[0, 1, 2, 3, 4, 5].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.
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MEMBERS_ACTUAL_NAME = "Members__current_";
|
const MEMBERS_ACTUAL_NAME = "Members__current_";
|
||||||
const MEMBERS_GOAL_NAME = "Member_Goal_for_current_Stage";
|
const MEMBERS_GOAL_NAME = "Member_Goal_for_current_Stage";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
"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 all stage sections (0–5), 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.
|
||||||
|
*/
|
||||||
|
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 < 0 || 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">
|
||||||
|
{[0, 1, 2, 3, 4, 5].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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user