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.
1076 lines
35 KiB
TypeScript
1076 lines
35 KiB
TypeScript
"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 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;
|
||
}
|
||
|
||
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's first check-in will appear here once it'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'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>
|
||
);
|
||
}
|