Files
Joel Brock 13d7f6e93b Timeline: draw stage-period ranges from activities, milestones on top
Each stage-transition activity now becomes a horizontal range on its lane: start = the activity's date, end = the next activity with a higher stage rank, or extending to today if still in effect. Milestone date-field dots overlay on top of the ranges. Activity dots at each range start carry a tooltip with the activity subject. Added computeStageRanges in lib/stageRank.ts and switched DateTimeline to take activities directly (deriving both the rank resolver and the ranges internally). Wide year-spanning timelines now show their full extent even when no milestone dates have been entered.
2026-06-11 14:14:36 -07:00

493 lines
17 KiB
TypeScript

"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<string, FieldHistoryEntry[]>;
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<number, StageRange[]>();
for (const r of ranges) {
const list = rangesByRank.get(r.rank) ?? [];
list.push(r);
rangesByRank.set(r.rank, list);
}
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 rowRanges = rangesByRank.get(rank) ?? [];
const rowMilestones = milestones.filter((e) => e.rank === rank);
const isEmpty = rowRanges.length === 0 && rowMilestones.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")
}
/>
{/* 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 (
<StageRangeBar
key={`r-${i}`}
rank={rank}
range={r}
startPct={startPct}
widthPct={widthPct}
isOpen={r.endDate === null}
/>
);
})}
{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"
/>
)}
{/* 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 (
<ActivityDot key={`a-${i}`} rank={rank} range={r} x={x} />
);
})}
{/* 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 <MilestoneDot key={`m-${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;
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">
{ranges.map((r, i) => (
<li key={`r-${i}`}>
Stage {r.rank}: {formatShortDate(r.startDate)}
{r.endDate ? ` to ${formatShortDate(r.endDate)}` : " (current)"}
</li>
))}
{milestones.map((e, i) => (
<li key={`m-${i}`}>
Stage {e.rank}: {e.fieldLabel} {formatShortDate(e.date)}
{e.isOpened ? " (opened)" : ""}
</li>
))}
</ul>
</section>
);
}
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 (
<span
aria-hidden
title={
`Stage ${rank}: ${formatShortDate(range.startDate)}` +
(range.endDate ? ` → ${formatShortDate(range.endDate)}` : " → current")
}
style={{ left: `${startPct}%`, width: `${widthPct}%` }}
className={
"absolute top-1/2 -translate-y-1/2 h-2 rounded-full " +
stageRangeBg(rank) +
(isOpen ? " ring-1 ring-inset ring-clay-300/40" : "")
}
/>
);
}
/** 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 (
<span
role="img"
tabIndex={0}
aria-label={`Stage ${rank} check-in, ${formatShortDate(range.startDate)}`}
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-1 ring-paper h-3 w-3 " +
stageDotBg(rank)
}
>
<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">
{range.subject ?? `Stage ${rank} check-in`}
</span>
<span className="mt-0.5 block tabular-nums text-paper-2/80">
{formatShortDate(range.startDate)}
{range.endDate ? (
<> {formatShortDate(range.endDate)}</>
) : (
<span className="ml-1.5 text-paper-2/60"> current</span>
)}
</span>
</span>
</span>
);
}
/**
* 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 (
<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;
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;
}