"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"; import { FieldHistoryGroup, FieldHistoryRow, formatShortDate, computeDateRange, Chevron, numberFmt, } from "./report/FieldHistory"; 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 = { 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({ 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 ; if (load.kind === "error") return ; 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 (
{sectionsToRender.length === 0 ? ( ) : (
    {sectionsToRender.map((entry, i) => { const { section, pathwayState, fieldsWithHistory } = entry; const nextState = i < sectionsToRender.length - 1 ? sectionsToRender[i + 1].pathwayState : undefined; return (
  1. 0} state={pathwayState} />
  2. ); })}
)}
); } 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 (

Report for

{orgName}

Current stage

{currentStageLabel || "—"}

); } function Stat({ label, value }: { label: string; value: string }) { return (
{label}
{value}
); } function StageProgress({ currentRank }: { currentRank: number }) { return (
{[0, 1, 2, 3, 4, 5].map((r) => ( ))}
); } /** * 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; }) { 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 (

Timeline

{formatShortDate(new Date(minT).toISOString())} {" → "} {formatShortDate(new Date(maxT).toISOString())}

    {[0, 1, 2, 3, 4, 5].map((rank) => { const rowEvents = events.filter((e) => e.rank === rank); const isEmpty = rowEvents.length === 0; return (
  1. Stage {rank}
    {todayPct !== null && ( )} {rowEvents.map((e, i) => { const t = new Date(e.date).getTime(); if (!Number.isFinite(t)) return null; const x = ((t - minT) / tRange) * 100; return ( ); })}
  2. ); })}
{/* 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 && (
{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 ( {tk.label} ); })} {todayPct !== null && ( Today )}
)} {/* Accessible event list — invisible to sighted users but readable by SR */}
    {events.map((e, i) => (
  • Stage {e.rank}: {e.fieldLabel} — {formatShortDate(e.date)} {e.isOpened ? " (opened)" : ""}
  • ))}
); } 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 ( ); } /** * 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-owner count vs the * goal it set for the current stage. Renders when at least one of the * two fields has historical data. Both series are drawn as step lines * (carry-forward semantics — a measurement holds until the next one * updates it, then extends to the right edge). Header carries a * current/goal summary with the gap; legend sits below the chart. */ function MembershipChart({ membersField, goalField, membersHistory, goalHistory, }: { membersField?: FieldConfig; goalField?: FieldConfig; membersHistory?: FieldHistoryEntry[]; goalHistory?: FieldHistoryEntry[]; }) { 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(membersHistory); const goalPoints = toPoints(goalHistory); 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; // Both Actual and Goal use a carry-forward step line: between measurements // the chart holds the prior value rather than interpolating diagonally, // and the final value extends flat to the right edge of the chart. This // way periods with no fresh measurement read as "unchanged since last // reported" instead of suggesting a smooth dip or rise that we don't // actually have evidence for. const actualCoords = actualPoints.map((p) => ({ x: xOf(p.t), y: yOf(p.v), v: p.v })); let actualPath = ""; if (actualCoords.length === 1) { const c = actualCoords[0]; actualPath = `M${c.x.toFixed(2)},${c.y.toFixed(2)} L${(W - padR).toFixed(2)},${c.y.toFixed(2)}`; } else if (actualCoords.length > 1) { const parts: string[] = [ `M${actualCoords[0].x.toFixed(2)},${actualCoords[0].y.toFixed(2)}`, ]; for (let i = 1; i < actualCoords.length; i++) { parts.push(`L${actualCoords[i].x.toFixed(2)},${actualCoords[i - 1].y.toFixed(2)}`); parts.push(`L${actualCoords[i].x.toFixed(2)},${actualCoords[i].y.toFixed(2)}`); } const last = actualCoords[actualCoords.length - 1]; parts.push(`L${(W - padR).toFixed(2)},${last.y.toFixed(2)}`); actualPath = parts.join(" "); } const actualArea = actualPath ? `${actualPath} L${(W - padR).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 (

Membership — goal vs. actual

Member-owner count tracked over time against the goal set for the org's current stage.

{latestActual !== null && (

{numberFmt.format(latestActual)} {latestGoal !== null && ( <> {" of "} {numberFmt.format(latestGoal)} {gapText && ( · {gapText} )} )}

)}
{/* Y-axis gridlines + value labels */} {yTicks.map((v, i) => { const y = yOf(v); return ( {numberFmt.format(v)} ); })} {/* Today guide */} {todayInRange && ( )} {/* Goal step line */} {goalPath && ( )} {goalCoords.map((c, i) => ( ))} {/* Actual line with faint area fill */} {actualArea && ( )} {actualPath && ( )} {actualCoords.map((c, i) => ( ))} {/* Most-recent point value labels */} {actualCoords.length > 0 && ( {numberFmt.format(actualCoords[actualCoords.length - 1].v)} )} {goalCoords.length > 0 && ( {numberFmt.format(goalCoords[goalCoords.length - 1].v)} )} {/* X-axis baseline + ticks */} {xTicks.map((tk, i) => { const x = xOf(tk.t); return ( {tk.label} ); })} {/* Legend */}
  • Actual{membersField?.label && membersField.label !== "Member-Owners (current)" ? ` (${membersField.label})` : ""}
  • Goal{goalField?.label && goalField.label !== "Member-Owner Goal for current Stage" ? ` (${goalField.label})` : ""}
); } /** * Choose 3–5 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; options: Record; 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 (

); } /** * Mirrors the form's FieldGroupCard: a leaf-tinted left rule + small * uppercase mini-label, with the grouped history rows stacked beneath * and separated by the same divide-y as the standalone rows. Eats only * ~14px of horizontal space (vs ~40px for a fully-boxed treatment). */ function StageRankMark({ rank, isCurrent }: { rank: number; isCurrent: boolean }) { return ( {rank} ); } function RailMarker({ rank, state, nextState, }: { rank: number; state: PathwayState; nextState?: PathwayState; }) { return (
{nextState && ( )}
); } function MarkerCircle({ rank, state }: { rank: number; state: PathwayState }) { if (state === "past") { return ( Stage {rank} (past) ); } if (state === "current") { return ( {rank} Stage {rank} (current) ); } return ( {rank} Stage {rank} (no entries) ); } function MobileStem({ show, state }: { show: boolean; state: PathwayState }) { if (!show) return null; return (
); } function LoadingState() { return (

Loading your activity report…

); } function EmptyState() { return (

No entries on file yet.

Your co-op's first survey will appear here once it's submitted.

); } function ErrorState({ message }: { message: string }) { return (

We couldn't open your report.

{message}

If this keeps happening, please contact your Chris @ FCI.

); }