Files
WebForm-mw/components/ReportView.tsx
T
Joel Brock 814b560363 Report: sparkline charts on numeric history + stage-grouped date timeline
Two visual additions to the read-only activity report.

Sparkline: when the user expands earlier-entries on a numeric field
(number/currency/percent) with two or more numeric points, the
expansion now leads with a 240x56 inline SVG trend chart — chronological
polyline, faint area fill, small dots at every measurement, a slightly
larger emphasized dot on the most recent point. Min and max captions
sit beneath in tabular-nums, formatted in the field's native style
(currency uses Intl, percent appends %, etc.). Non-numeric fields are
unchanged.

DateTimeline: a new card between the context header and the section
accordions. Walks every date-type field in stage sections 1-5 (Stage 0
omitted as it isn't a stage in the journey sense), pulls each field's
most-recent entered date, and lays the events out in five horizontal
swim lanes — one per stage rank, labeled at the left. Time axis
spans from the earliest event to max(latest event, Date_Opened).
Stage 5's Date_Opened is rendered as a larger clay-700 dot with a
heavier ring so it reads as the journey's anchor at the right end.
A faint clay-300 dashed vertical line marks 'today' if it falls
within the range. Color scale across stages is leaf-300 / leaf-500
/ leaf-600 / leaf-700 / clay-700 — a sprout-to-fruit gradient that
matches the existing palette. Empty stage rows still draw their lane
line at half opacity so the structure stays readable. SR-only event
list provides screen-reader access to all plotted dates with their
labels.

Stub payload enriched with four cross-stage date entries so the
timeline has content in dev preview.
2026-05-19 16:16:57 -07:00

905 lines
29 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 { useEffect, useId, useMemo, useState } from "react";
import type {
FieldConfig,
FieldHistoryEntry,
FormConfig,
ReportPayload,
SelectOption,
StageSectionConfig,
} from "@/types/form";
import { STAGE_OPTION_GROUP_ID } from "@/config/form";
import { StageIcon } from "./StageIcon";
interface ReportViewProps {
config: FormConfig;
cid: string;
cs: string;
}
type LoadState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; data: ReportPayload };
type PathwayState = "past" | "current" | "future";
const STAGE_RANK: Record<string, number> = {
Inquiry: 0,
Organizing: 1,
Feasibility: 2,
"Business feasibility": 3,
"Store Implementation": 4,
"Stabilize newly opened co-op": 5,
};
export function ReportView({ config, cid, cs }: ReportViewProps) {
const [load, setLoad] = useState<LoadState>({ kind: "loading" });
useEffect(() => {
let cancelled = false;
async function go() {
try {
const url = new URL("/api/report", window.location.origin);
url.searchParams.set("cid", cid);
url.searchParams.set("cs", cs);
const res = await fetch(url.toString(), { cache: "no-store" });
if (!res.ok) {
const text = await res.text();
let message = `Could not load report (HTTP ${res.status}).`;
try {
const j = JSON.parse(text) as { error?: string };
if (j.error) message = j.error;
} catch { /* default */ }
if (!cancelled) setLoad({ kind: "error", message });
return;
}
const data: ReportPayload = await res.json();
if (!cancelled) setLoad({ kind: "ready", data });
} catch (e) {
if (!cancelled) {
setLoad({
kind: "error",
message: e instanceof Error ? e.message : "Unexpected error loading report.",
});
}
}
}
void go();
return () => {
cancelled = true;
};
}, [cid, cs]);
const sectionsToRender = useMemo(() => {
if (load.kind !== "ready") return [];
const { currentStage, fieldHistory } = load.data;
const currentRank = STAGE_RANK[currentStage] ?? 0;
return config.sections
.map((section) => {
const fieldsWithHistory = section.fields.filter(
(f) => fieldHistory[f.name] && fieldHistory[f.name].length > 0,
);
const pathwayState: PathwayState =
section.rank < currentRank ? "past" : section.rank === currentRank ? "current" : "future";
return { section, fieldsWithHistory, pathwayState };
})
.filter((e) => e.fieldsWithHistory.length > 0);
}, [load, config.sections]);
if (load.kind === "loading") return <LoadingState />;
if (load.kind === "error") return <ErrorState message={load.message} />;
const { data } = load;
const stageOpts = data.options?.[STAGE_OPTION_GROUP_ID] ?? [];
const currentStageLabel =
stageOpts.find((o) => o.value === data.currentStage)?.label ?? data.currentStage;
const currentRank = STAGE_RANK[data.currentStage] ?? 0;
const dateRange = computeDateRange(data.activities.map((a) => a.date));
const totalActivities = data.activities.length;
const totalFieldsTracked = Object.keys(data.fieldHistory).length;
return (
<div className="space-y-5">
<ReportContextHeader
orgName={data.orgName}
currentStageLabel={currentStageLabel}
currentRank={currentRank}
totalActivities={totalActivities}
totalFieldsTracked={totalFieldsTracked}
dateRange={dateRange}
/>
<DateTimeline sections={config.sections} fieldHistory={data.fieldHistory} />
{sectionsToRender.length === 0 ? (
<EmptyState />
) : (
<ol className="relative space-y-5 md:pl-12">
{sectionsToRender.map((entry, i) => {
const { section, pathwayState, fieldsWithHistory } = entry;
const nextState =
i < sectionsToRender.length - 1 ? sectionsToRender[i + 1].pathwayState : undefined;
return (
<li key={section.id} className="relative list-none">
<MobileStem show={i > 0} state={pathwayState} />
<RailMarker rank={section.rank} state={pathwayState} nextState={nextState} />
<ReportSection
section={section}
fields={fieldsWithHistory}
history={data.fieldHistory}
options={data.options ?? {}}
isCurrent={pathwayState === "current"}
defaultOpen={pathwayState === "current" || section.rank === 0}
/>
</li>
);
})}
</ol>
)}
</div>
);
}
function ReportContextHeader({
orgName,
currentStageLabel,
currentRank,
totalActivities,
totalFieldsTracked,
dateRange,
}: {
orgName: string;
currentStageLabel: string;
currentRank: number;
totalActivities: number;
totalFieldsTracked: number;
dateRange: { from: string; to: string } | null;
}) {
return (
<header className="rounded-lg border border-rule bg-paper-2/40 px-6 py-5 sm:px-7 sm:py-6">
<p className="text-[11px] uppercase tracking-[0.18em] text-ink-mute">Report for</p>
<h1 className="mt-1 font-display text-3xl font-medium leading-tight tracking-tight text-ink sm:text-[34px]">
{orgName}
</h1>
<div className="mt-4 flex items-center gap-3">
<StageProgress currentRank={currentRank} />
<span className="text-[10px] uppercase tracking-[0.16em] font-medium text-ink-mute">
Current stage
</span>
</div>
<p className="mt-1.5 font-display text-xl sm:text-2xl font-medium leading-tight tracking-tight text-leaf-800">
{currentStageLabel || "—"}
</p>
<dl className="mt-5 grid grid-cols-3 gap-3 border-t border-rule-soft pt-4 text-sm sm:gap-6">
<Stat label="Check-ins" value={String(totalActivities)} />
<Stat label="Fields tracked" value={String(totalFieldsTracked)} />
<Stat
label="Span"
value={dateRange ? `${formatShortDate(dateRange.from)} - ${formatShortDate(dateRange.to)}` : "—"}
/>
</dl>
</header>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-[10px] uppercase tracking-[0.14em] font-medium text-ink-mute">
{label}
</dt>
<dd className="mt-0.5 font-display text-base font-medium text-ink tabular-nums sm:text-lg">
{value}
</dd>
</div>
);
}
function StageProgress({ currentRank }: { currentRank: number }) {
return (
<div className="flex items-center gap-1.5" role="img" aria-label={`Stage ${currentRank} of 5`}>
{[0, 1, 2, 3, 4, 5].map((r) => (
<span
key={r}
className={
"h-1.5 rounded-full transition-all " +
(r < currentRank
? "w-2.5 bg-leaf-300"
: r === currentRank
? "w-6 bg-leaf-700"
: "w-2.5 bg-rule-soft")
}
/>
))}
</div>
);
}
/**
* 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.
*/
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 openedTime = events.find((e) => e.isOpened)?.date
? new Date(events.find((e) => e.isOpened)!.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;
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">
{[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
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"
/>
)}
{rowEvents.map((e, i) => {
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
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}`
}
/>
);
})}
</div>
</li>
);
})}
</ol>
{/* 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 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 ReportSection({
section,
fields,
history,
options,
isCurrent,
defaultOpen,
}: {
section: StageSectionConfig;
fields: FieldConfig[];
history: Record<string, FieldHistoryEntry[]>;
options: Record<number, SelectOption[]>;
isCurrent: boolean;
defaultOpen: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
const headingId = useId();
const panelId = useId();
const cardClass = isCurrent
? "border-2 border-leaf-600 shadow-[0_1px_0_0_rgba(0,0,0,0.04),0_8px_24px_-12px_rgba(60,80,40,0.18)] bg-white/95"
: "border border-rule bg-white/95 shadow-sm";
return (
<section
aria-labelledby={headingId}
className={"overflow-hidden rounded-lg transition " + cardClass}
>
<h2 id={headingId} className="m-0">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={panelId}
className={
"group relative flex w-full items-center gap-4 px-5 py-4 text-left transition-colors sm:px-6 " +
(isCurrent ? "bg-leaf-50/60" : "hover:bg-paper-2/60")
}
>
<StageRankMark rank={section.rank} isCurrent={isCurrent} />
<span className="flex-1 min-w-0">
<span className="block font-display text-lg font-medium leading-tight tracking-tight text-ink sm:text-xl">
{section.label}
</span>
<span className="mt-1 flex items-center gap-3 text-xs text-ink-mute">
{isCurrent && (
<span className="inline-flex items-center gap-1.5 rounded-full border border-clay-200 bg-clay-100/70 px-2 py-0.5 font-medium text-clay-700 uppercase tracking-[0.08em]">
<span className="h-1.5 w-1.5 rounded-full bg-clay-600" aria-hidden />
Current stage
</span>
)}
<span className="tabular-nums">
{fields.length} {fields.length === 1 ? "field" : "fields"} with entries
</span>
</span>
</span>
<Chevron open={open} />
</button>
</h2>
<div
id={panelId}
role="region"
aria-labelledby={headingId}
hidden={!open}
className="border-t border-rule-soft"
>
<div className="divide-y divide-rule-soft">
{fields.map((f) => (
<FieldHistoryRow
key={f.name}
field={f}
entries={history[f.name] ?? []}
options={options}
/>
))}
</div>
</div>
</section>
);
}
function StageRankMark({ rank, isCurrent }: { rank: number; isCurrent: boolean }) {
return (
<span aria-hidden className="relative inline-flex h-12 w-12 flex-shrink-0 items-center justify-center">
<StageIcon
rank={rank}
className={
"absolute inset-0 h-12 w-12 transition-opacity " +
(isCurrent ? "text-leaf-700 opacity-100" : "text-leaf-600 opacity-50")
}
/>
<span
className={
"relative z-10 inline-flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold tabular-nums shadow-sm " +
(isCurrent ? "bg-leaf-700 text-paper" : "bg-paper text-ink-soft border border-rule")
}
style={{ marginLeft: 28, marginTop: 28 }}
>
{rank}
</span>
</span>
);
}
function Chevron({ open }: { open: boolean }) {
return (
<svg
aria-hidden
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
className={"h-5 w-5 flex-shrink-0 text-ink-mute transition-transform duration-300 " + (open ? "rotate-180" : "")}
>
<path d="M5 8 L10 13 L15 8" />
</svg>
);
}
function FieldHistoryRow({
field,
entries,
options,
}: {
field: FieldConfig;
entries: FieldHistoryEntry[];
options: Record<number, SelectOption[]>;
}) {
const [expanded, setExpanded] = useState(false);
const latest = entries[0];
const priorEntries = entries.slice(1);
return (
<div className="px-5 py-4 sm:px-7">
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-baseline sm:justify-between sm:gap-6">
<div className="min-w-0 sm:max-w-[16rem]">
<p className="text-sm font-medium text-ink">{field.label}</p>
{field.help && (
<p className="mt-0.5 text-xs leading-relaxed text-ink-mute">{field.help}</p>
)}
</div>
<div className="flex-1 min-w-0 text-left sm:text-right">
<p className="font-display text-lg font-medium leading-snug text-leaf-800 tabular-nums">
<FormattedValue value={latest.value} field={field} options={options} />
</p>
<p className="mt-0.5 text-[11px] uppercase tracking-[0.1em] text-ink-mute">
as of {formatShortDate(latest.date)}
{priorEntries.length > 0 && (
<>
{" · "}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
className="font-medium normal-case tracking-normal text-leaf-700 hover:text-leaf-800 hover:underline focus:outline-none focus-visible:underline"
>
{expanded ? "Hide" : `${priorEntries.length} earlier ${priorEntries.length === 1 ? "entry" : "entries"}`}
</button>
</>
)}
</p>
</div>
</div>
{expanded && priorEntries.length > 0 && (
<div className="mt-3 sm:ml-auto sm:max-w-[24rem]">
{isNumericField(field) && (
<Sparkline field={field} entries={entries} />
)}
<ol
className={
"space-y-1.5 border-l-2 border-rule-soft pl-4 " +
(isNumericField(field) ? "mt-3" : "")
}
>
{priorEntries.map((e) => (
<li
key={e.activityId}
className="flex items-baseline justify-between gap-4 text-sm"
>
<span className="text-[11px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
{formatShortDate(e.date)}
</span>
<span className="text-right text-ink-soft tabular-nums">
<FormattedValue value={e.value} field={field} options={options} />
</span>
</li>
))}
</ol>
</div>
)}
</div>
);
}
function isNumericField(field: FieldConfig): boolean {
return field.type === "number" || field.type === "currency" || field.type === "percent";
}
/**
* Tiny inline trend chart for numeric field history. Plots entries in
* chronological order as a single leaf-toned polyline with small dots
* at each measurement; the most-recent dot is emphasised. Renders nothing
* unless there are at least two finite numeric values to connect.
*/
function Sparkline({
field,
entries,
}: {
field: FieldConfig;
entries: FieldHistoryEntry[];
}) {
// entries arrive DESC. Reverse for chronological X-axis.
const sorted = [...entries].reverse();
const points = sorted
.map((e) => ({
t: new Date(e.date).getTime(),
v: typeof e.value === "number" ? e.value : Number(e.value),
}))
.filter((p) => Number.isFinite(p.t) && Number.isFinite(p.v));
if (points.length < 2) return null;
const values = points.map((p) => p.v);
const times = points.map((p) => p.t);
const minV = Math.min(...values);
const maxV = Math.max(...values);
const minT = Math.min(...times);
const maxT = Math.max(...times);
const vRange = maxV - minV || 1;
const tRange = maxT - minT || 1;
const w = 240;
const h = 56;
const pad = 6;
const coords = points.map((p) => ({
x: pad + ((p.t - minT) / tRange) * (w - pad * 2),
y: h - pad - ((p.v - minV) / vRange) * (h - pad * 2),
v: p.v,
}));
const path = coords.map((c, i) => `${i === 0 ? "M" : "L"}${c.x},${c.y}`).join(" ");
// Subtle area fill beneath the line: extend the path down to the baseline
// and close.
const area = `${path} L${coords[coords.length - 1].x},${h - pad} L${coords[0].x},${h - pad} Z`;
const last = coords[coords.length - 1];
return (
<figure className="mb-3 rounded-md border border-rule-soft bg-paper-2/40 px-3 py-2.5">
<svg
viewBox={`0 0 ${w} ${h}`}
preserveAspectRatio="none"
role="img"
aria-label={`${field.label} trend across ${points.length} entries`}
className="block h-14 w-full text-leaf-600"
>
<path d={area} fill="currentColor" opacity="0.10" />
<path
d={path}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
{coords.map((c, i) => (
<circle
key={i}
cx={c.x}
cy={c.y}
r={i === coords.length - 1 ? 3 : 1.75}
className={i === coords.length - 1 ? "fill-leaf-800" : "fill-leaf-600"}
/>
))}
<circle
cx={last.x}
cy={last.y}
r="5"
fill="none"
stroke="currentColor"
strokeWidth="0.75"
opacity="0.5"
/>
</svg>
<figcaption className="mt-1 flex items-baseline justify-between text-[10px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
<span>
low{" "}
<span className="font-medium text-ink-soft normal-case tracking-normal">
{formatScalarText(minV, field)}
</span>
</span>
<span>
high{" "}
<span className="font-medium text-ink-soft normal-case tracking-normal">
{formatScalarText(maxV, field)}
</span>
</span>
</figcaption>
</figure>
);
}
function formatScalarText(value: number, field: FieldConfig): string {
switch (field.type) {
case "currency":
return currencyFmt.format(value);
case "percent":
return `${value}%`;
case "number":
return numberFmt.format(value);
default:
return String(value);
}
}
const currencyFmt = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 2,
});
const numberFmt = new Intl.NumberFormat("en-US");
function FormattedValue({
value,
field,
options,
}: {
value: unknown;
field: FieldConfig;
options: Record<number, SelectOption[]>;
}) {
if (value === null || value === undefined || value === "") return <></>;
const opts: SelectOption[] | undefined = field.optionGroupId
? options[field.optionGroupId]
: field.options;
switch (field.type) {
case "currency": {
const n = typeof value === "number" ? value : Number(value);
return <>{Number.isFinite(n) ? currencyFmt.format(n) : String(value)}</>;
}
case "percent": {
const n = typeof value === "number" ? value : Number(value);
return <>{Number.isFinite(n) ? `${n}%` : String(value)}</>;
}
case "number": {
const n = typeof value === "number" ? value : Number(value);
return <>{Number.isFinite(n) ? numberFmt.format(n) : String(value)}</>;
}
case "date":
return <>{formatLongDate(String(value))}</>;
case "boolean":
return <>{value ? "Yes" : "No"}</>;
case "select":
case "readonly": {
const v = String(value);
const found = opts?.find((o) => o.value === v);
return <>{found?.label ?? v}</>;
}
case "multiselect": {
let parts: string[];
if (Array.isArray(value)) {
parts = value.map(String);
} else {
parts = String(value).split(/[|,]/).map((s) => s.trim()).filter(Boolean);
}
const labels = parts.map((p) => opts?.find((o) => o.value === p)?.label ?? p);
return <>{labels.join(", ")}</>;
}
case "file": {
// Prefill / history wraps file values into { id, file_name } so the
// UI can show a human-readable name. Fall back to whatever scalar
// came through if the shape is different.
if (typeof value === "object" && value !== null) {
const o = value as Record<string, unknown>;
const fname = o.file_name ?? o.name ?? o.label ?? o.filename;
if (typeof fname === "string" && fname.length > 0) return <>{fname}</>;
if (typeof o.id !== "undefined") return <>Attachment #{String(o.id)}</>;
}
return <>{String(value)}</>;
}
case "textarea":
case "text":
case "email":
case "phone":
default:
return <>{String(value)}</>;
}
}
function formatShortDate(iso: string): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
function formatLongDate(iso: string): string {
if (!iso) return "—";
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
const d = m
? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
: new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" });
}
function computeDateRange(dates: string[]): { from: string; to: string } | null {
if (dates.length === 0) return null;
const times = dates
.map((d) => new Date(d).getTime())
.filter((t) => Number.isFinite(t));
if (times.length === 0) return null;
const min = new Date(Math.min(...times)).toISOString();
const max = new Date(Math.max(...times)).toISOString();
return { from: min, to: max };
}
function RailMarker({
rank,
state,
nextState,
}: {
rank: number;
state: PathwayState;
nextState?: PathwayState;
}) {
return (
<div
aria-hidden
className="pointer-events-none hidden md:block absolute -left-9 top-0 bottom-0 w-7"
>
{nextState && (
<span
className={
"absolute left-1/2 -translate-x-1/2 top-11 -bottom-11 " +
(nextState === "future"
? "w-0 border-l border-dashed border-rule"
: "w-px bg-leaf-500/70")
}
/>
)}
<MarkerCircle rank={rank} state={state} />
</div>
);
}
function MarkerCircle({ rank, state }: { rank: number; state: PathwayState }) {
if (state === "past") {
return (
<span className="absolute left-1/2 -translate-x-1/2 top-6 inline-flex h-5 w-5 items-center justify-center rounded-full bg-leaf-700 text-paper shadow-sm">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" className="h-2.5 w-2.5">
<path d="M3 6.5l2 2 4-5" />
</svg>
<span className="sr-only">Stage {rank} (past)</span>
</span>
);
}
if (state === "current") {
return (
<span className="absolute left-1/2 -translate-x-1/2 top-6 inline-flex h-6 w-6 items-center justify-center rounded-full bg-leaf-700 text-paper text-[10px] font-semibold shadow-md ring-2 ring-leaf-100">
{rank}
<span className="sr-only">Stage {rank} (current)</span>
</span>
);
}
return (
<span className="absolute left-1/2 -translate-x-1/2 top-6 inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-rule bg-paper text-ink-mute">
<span className="text-[9px] font-medium tabular-nums">{rank}</span>
<span className="sr-only">Stage {rank} (no entries)</span>
</span>
);
}
function MobileStem({ show, state }: { show: boolean; state: PathwayState }) {
if (!show) return null;
return (
<div aria-hidden className="md:hidden -mt-4 mb-1.5 flex justify-center">
<span
className={
"block h-4 " +
(state === "future"
? "w-0 border-l border-dashed border-rule"
: "w-px bg-leaf-500/70")
}
/>
</div>
);
}
function LoadingState() {
return (
<div className="rounded-lg border border-rule bg-paper px-6 py-12 text-center">
<div
aria-hidden
className="mx-auto mb-4 h-7 w-7 animate-spin rounded-full border-[1.5px] border-rule border-t-leaf-700"
/>
<p className="font-display text-base text-ink-soft italic">Loading your activity report</p>
</div>
);
}
function EmptyState() {
return (
<div className="rounded-lg border border-dashed border-rule bg-paper-2/30 px-6 py-10 text-center">
<p className="font-display text-lg text-ink-soft">No entries on file yet.</p>
<p className="mt-2 text-sm text-ink-mute">
Your co-op&apos;s first check-in will appear here once it&apos;s submitted.
</p>
</div>
);
}
function ErrorState({ message }: { message: string }) {
return (
<div role="alert" className="rounded-lg border-2 border-clay-200 bg-clay-100/30 px-6 py-7">
<h2 className="font-display text-xl font-medium text-clay-700">
We couldn&apos;t open your report.
</h2>
<p className="mt-3 text-sm leading-relaxed text-ink-soft">{message}</p>
<p className="mt-4 text-sm text-ink-soft">
If this keeps happening, please contact your engagement coordinator.
</p>
</div>
);
}