Files
WebForm-mw/components/ReportView.tsx
T
Joel Brock ae4e47025c Report: dedicated Membership chart (goal vs actual over time)
New MembershipChart card sits between the DateTimeline and the section
accordions, rendering whenever Members__current_ or
Member_Goal_for_current_Stage has any historical data.

- Actual member count: smooth leaf-700 polyline with a faint leaf-500
  area fill underneath, dots at every measurement, an emphasized dot
  on the most recent point with the value labeled inline.
- Goal: dashed clay-600 step line — each goal value is treated as a
  target that holds until the next update, then extends flat to the
  right edge of the chart. Dots at each update; the most-recent goal
  value labeled at the right.
- Y-axis: niceYTicks picks 3–5 round-number ticks (snapped to
  1/2/2.5/5/10 × 10^N) spanning [min(0, dataMin), dataMax]; faint
  gridlines + tabular-num labels on the left. Anchoring at 0 keeps
  growth-from-small-base readable.
- X-axis: reuses generateAxisTicks for adaptive month/year stepping,
  matching the timeline above. Today gets a dashed clay vertical
  guide when in range.
- Header: title + subtitle + an inline 'NNN of MMM target · X to go'
  callout in tabular-nums, color-coded (clay-700 if behind goal,
  leaf-700 if above).
- Legend at the bottom with line+dot chips for both series.
2026-05-19 16:37:01 -07:00

1456 lines
48 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} />
<MembershipChart
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 (05), pulls each field's
* most-recent entered date, and plots events into six horizontal swim
* lanes — one per stage. Stage 5's `Date_Opened` gets visual emphasis
* as the journey's anchor at the right end of the time axis. A faint
* "Today" tick anchors the reader if today falls within the range, and
* an adaptive month/year axis runs beneath the lanes. Each dot exposes
* a tooltip on hover or keyboard focus.
*/
function DateTimeline({
sections,
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_GOAL_NAME = "Member_Goal_for_current_Stage";
/**
* Dedicated comparison chart for the org's actual member count vs the
* goal it set for the current stage. Renders when at least one of the
* two fields has historical data. Actual is drawn as a smooth leaf-toned
* line with a faint area fill; the goal is drawn as a dashed clay step
* line (each goal value is a target that holds until the next update).
* Header carries a current/goal summary with the gap; legend sits below
* the chart.
*/
function MembershipChart({
sections,
fieldHistory,
}: {
sections: StageSectionConfig[];
fieldHistory: Record<string, FieldHistoryEntry[]>;
}) {
// Find the two field configs by name across all sections (both are
// typically in Stage 0 today, but we look broadly so reorganizations
// don't break the chart).
let membersField: FieldConfig | undefined;
let goalField: FieldConfig | undefined;
for (const s of sections) {
for (const f of s.fields) {
if (f.name === MEMBERS_ACTUAL_NAME) membersField = f;
if (f.name === MEMBERS_GOAL_NAME) goalField = f;
}
}
const toPoints = (entries: FieldHistoryEntry[] | undefined) =>
(entries ?? [])
.slice()
.reverse()
.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));
const actualPoints = toPoints(fieldHistory[MEMBERS_ACTUAL_NAME]);
const goalPoints = toPoints(fieldHistory[MEMBERS_GOAL_NAME]);
if (actualPoints.length === 0 && goalPoints.length === 0) return null;
// Combined axes
const all = [...actualPoints, ...goalPoints];
const times = all.map((p) => p.t);
const values = all.map((p) => p.v);
let minT = Math.min(...times);
let maxT = Math.max(...times);
if (minT === maxT) {
// Single-point chart — pad the axis ±15 days so the dot isn't on
// top of the y-axis line.
const pad = 15 * 24 * 3600 * 1000;
minT -= pad;
maxT += pad;
}
// Always include 0 in y so growth from a small starting count reads true.
const rawMin = Math.min(...values, 0);
const rawMax = Math.max(...values);
const yTicks = niceYTicks(rawMin, rawMax, 4);
const yMin = yTicks[0];
const yMax = yTicks[yTicks.length - 1];
const yRange = yMax - yMin || 1;
const tRange = maxT - minT || 1;
// Chart geometry (viewBox units).
const W = 580;
const H = 200;
const padL = 40;
const padR = 28;
const padT = 14;
const padB = 30;
const plotW = W - padL - padR;
const plotH = H - padT - padB;
const xOf = (t: number) => padL + ((t - minT) / tRange) * plotW;
const yOf = (v: number) => padT + plotH - ((v - yMin) / yRange) * plotH;
// Smooth path for Actual; step path for Goal.
const actualCoords = actualPoints.map((p) => ({ x: xOf(p.t), y: yOf(p.v), v: p.v }));
const actualPath = actualCoords.length
? actualCoords
.map((c, i) => `${i === 0 ? "M" : "L"}${c.x.toFixed(2)},${c.y.toFixed(2)}`)
.join(" ")
: "";
const actualArea =
actualCoords.length >= 2
? `${actualPath} L${actualCoords[actualCoords.length - 1].x.toFixed(2)},${yOf(yMin).toFixed(2)} L${actualCoords[0].x.toFixed(2)},${yOf(yMin).toFixed(2)} Z`
: "";
// Step the goal: hold each value until the next change, then extend
// the final value to the right edge of the chart.
const goalCoords = goalPoints.map((p) => ({ x: xOf(p.t), y: yOf(p.v), v: p.v }));
let goalPath = "";
if (goalCoords.length === 1) {
const c = goalCoords[0];
goalPath = `M${xOf(minT).toFixed(2)},${c.y.toFixed(2)} L${xOf(maxT).toFixed(2)},${c.y.toFixed(2)}`;
} else if (goalCoords.length > 1) {
const parts: string[] = [];
parts.push(`M${goalCoords[0].x.toFixed(2)},${goalCoords[0].y.toFixed(2)}`);
for (let i = 1; i < goalCoords.length; i++) {
// step: horizontal to next x at previous y, then vertical to new y
parts.push(`L${goalCoords[i].x.toFixed(2)},${goalCoords[i - 1].y.toFixed(2)}`);
parts.push(`L${goalCoords[i].x.toFixed(2)},${goalCoords[i].y.toFixed(2)}`);
}
// Extend the most-recent goal to the right edge as a flat line.
const lastG = goalCoords[goalCoords.length - 1];
parts.push(`L${xOf(maxT).toFixed(2)},${lastG.y.toFixed(2)}`);
goalPath = parts.join(" ");
}
// Summary stat: take most-recent of each series for the gap callout.
const latestActual = actualPoints.length ? actualPoints[actualPoints.length - 1].v : null;
const latestGoal = goalPoints.length ? goalPoints[goalPoints.length - 1].v : null;
const gap =
latestActual !== null && latestGoal !== null ? latestGoal - latestActual : null;
const gapText =
gap === null
? null
: gap > 0
? `${numberFmt.format(gap)} to go`
: gap < 0
? `${numberFmt.format(-gap)} above goal`
: "at goal";
const gapTone =
gap === null
? ""
: gap > 0
? "text-clay-700"
: gap < 0
? "text-leaf-700"
: "text-leaf-700";
const xTicks = generateAxisTicks(minT, maxT);
const todayT = Date.now();
const todayInRange = todayT >= minT && todayT <= maxT;
return (
<section
aria-labelledby="membership-chart-heading"
className="rounded-lg border border-rule bg-paper-2/30 px-6 py-5 sm:px-7 sm:py-6"
>
<div className="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:justify-between sm:gap-4">
<div>
<h2
id="membership-chart-heading"
className="font-display text-base font-medium leading-tight text-ink"
>
Membership goal vs. actual
</h2>
<p className="mt-0.5 text-xs text-ink-mute">
Member count tracked over time against the goal set for the org&apos;s
current stage.
</p>
</div>
{latestActual !== null && (
<p className="text-[11px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
<span className="font-medium text-leaf-800">
{numberFmt.format(latestActual)}
</span>
{latestGoal !== null && (
<>
{" of "}
<span className="font-medium text-clay-700">
{numberFmt.format(latestGoal)}
</span>
{gapText && (
<span className={"ml-1.5 normal-case tracking-normal " + gapTone}>
· {gapText}
</span>
)}
</>
)}
</p>
)}
</div>
<svg
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
role="img"
aria-label="Membership goal versus actual over time"
className="mt-4 block h-48 w-full sm:h-52"
>
{/* Y-axis gridlines + value labels */}
{yTicks.map((v, i) => {
const y = yOf(v);
return (
<g key={i}>
<line
x1={padL}
x2={W - padR}
y1={y}
y2={y}
className="stroke-rule-soft"
strokeWidth="0.5"
/>
<text
x={padL - 6}
y={y}
textAnchor="end"
dominantBaseline="middle"
className="fill-ink-mute text-[9px] tabular-nums"
>
{numberFmt.format(v)}
</text>
</g>
);
})}
{/* Today guide */}
{todayInRange && (
<line
x1={xOf(todayT)}
x2={xOf(todayT)}
y1={padT}
y2={padT + plotH}
className="stroke-clay-300"
strokeWidth="0.75"
strokeDasharray="2 3"
/>
)}
{/* Goal step line */}
{goalPath && (
<path
d={goalPath}
fill="none"
className="stroke-clay-600"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="miter"
strokeDasharray="5 3"
/>
)}
{goalCoords.map((c, i) => (
<circle
key={`g-${i}`}
cx={c.x}
cy={c.y}
r="2.5"
className="fill-clay-600"
/>
))}
{/* Actual line with faint area fill */}
{actualArea && (
<path d={actualArea} className="fill-leaf-500" opacity="0.10" />
)}
{actualPath && (
<path
d={actualPath}
fill="none"
className="stroke-leaf-700"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
{actualCoords.map((c, i) => (
<circle
key={`a-${i}`}
cx={c.x}
cy={c.y}
r={i === actualCoords.length - 1 ? 3.5 : 2}
className={i === actualCoords.length - 1 ? "fill-leaf-800" : "fill-leaf-700"}
/>
))}
{/* Most-recent point value labels */}
{actualCoords.length > 0 && (
<text
x={actualCoords[actualCoords.length - 1].x + 6}
y={actualCoords[actualCoords.length - 1].y - 6}
className="fill-leaf-800 text-[10px] font-medium tabular-nums"
>
{numberFmt.format(actualCoords[actualCoords.length - 1].v)}
</text>
)}
{goalCoords.length > 0 && (
<text
x={xOf(maxT) - 4}
y={goalCoords[goalCoords.length - 1].y - 6}
textAnchor="end"
className="fill-clay-700 text-[10px] font-medium tabular-nums"
>
{numberFmt.format(goalCoords[goalCoords.length - 1].v)}
</text>
)}
{/* X-axis baseline + ticks */}
<line
x1={padL}
x2={W - padR}
y1={padT + plotH}
y2={padT + plotH}
className="stroke-rule"
strokeWidth="0.75"
/>
{xTicks.map((tk, i) => {
const x = xOf(tk.t);
return (
<g key={i}>
<line
x1={x}
x2={x}
y1={padT + plotH}
y2={padT + plotH + 3}
className="stroke-rule"
strokeWidth="0.75"
/>
<text
x={x}
y={padT + plotH + 14}
textAnchor="middle"
className="fill-ink-mute text-[9px] uppercase tracking-[0.08em] tabular-nums"
>
{tk.label}
</text>
</g>
);
})}
</svg>
{/* Legend */}
<ul className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-1.5 text-[11px] text-ink-soft">
<li className="inline-flex items-center gap-2">
<span aria-hidden className="block h-px w-6 bg-leaf-700">
<span className="relative -top-[3px] left-1/2 -translate-x-1/2 inline-block h-1.5 w-1.5 rounded-full bg-leaf-700" />
</span>
<span>
Actual{membersField?.label && membersField.label !== "Members (current)" ? ` (${membersField.label})` : ""}
</span>
</li>
<li className="inline-flex items-center gap-2">
<span aria-hidden className="block h-px w-6 border-t border-dashed border-clay-600">
<span className="relative -top-[3px] left-1/2 -translate-x-1/2 inline-block h-1.5 w-1.5 rounded-full bg-clay-600" />
</span>
<span>
Goal{goalField?.label && goalField.label !== "Member Goal for current Stage" ? ` (${goalField.label})` : ""}
</span>
</li>
</ul>
</section>
);
}
/**
* Choose 35 round-number tick values that span [min, max]. Step is snapped
* to 1 / 2 / 2.5 / 5 / 10 × 10^N so labels read as Y-axis values normally do.
*/
function niceYTicks(min: number, max: number, target = 4): number[] {
if (!Number.isFinite(min) || !Number.isFinite(max)) return [0];
if (min === max) return [min - 1, min, min + 1];
const range = max - min;
const rawStep = range / target;
const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
const normalized = rawStep / magnitude;
let step: number;
if (normalized < 1.5) step = 1 * magnitude;
else if (normalized < 3) step = 2 * magnitude;
else if (normalized < 4) step = 2.5 * magnitude;
else if (normalized < 7) step = 5 * magnitude;
else step = 10 * magnitude;
const niceMin = Math.floor(min / step) * step;
const niceMax = Math.ceil(max / step) * step;
const ticks: number[] = [];
for (let v = niceMin; v <= niceMax + step * 0.0001; v += step) {
ticks.push(Math.round(v * 1e6) / 1e6); // de-jitter float arithmetic
}
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&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>
);
}