Add /report — read-only activity history view

Mirrors the form's IA and Field Almanac aesthetic; same auth (cid/cs
checksum) so the org owner who can fill the form can also view its
history.

New routes:
- GET /api/report — verifies checksum, resolves the org via the
  Primary Contact relationship, fires Contact.get + Activity.get +
  OptionValue.get in parallel. For every form field with a civiField,
  walks all the org's Check-in (organizing) activities and collects
  every non-empty value into a sorted-DESC history list. Returns
  ReportPayload (orgName, currentStage, activities, fieldHistory,
  options). Has stub-mode payload for env-less local dev.
- /report — page entry; same layout shell (SiteHeader + SiteFooter,
  3xl page width). Eyebrow "Activity report - Co-op organizing".

ReportView component:
- ReportContextHeader: large org name, progress dots + uppercase
  "Current stage" eyebrow + the Civi option *label* on its own line
  at display-font xl/2xl leaf-800 (matches the form's header). Below
  it a 3-up stat band: total check-ins, fields tracked, date span.
- One accordion card per stage section, in stage-rank order. Only
  sections that have at least one field-with-entries render — past,
  current, or "future-with-data" all welcome; truly empty stages stay
  hidden so the page is calm.
- Same journey rail (md+) and mobile stem (md-) with past =
  check-filled-leaf, current = filled-leaf-with-ring, future = dashed
  hollow ring; solid leaf line vs dashed muted between markers.
- Within each card: divide-y rows. Field label and help on the left,
  most-recent value on the right in display-font lg leaf-800, dated
  beneath with an "{N} earlier entries" disclosure that expands a
  small vertical timeline (date on left, value on right).
- FormattedValue handles currency (Intl), percent, number (tabular
  nums), date (long, timezone-safe for YYYY-MM-DD), boolean (Yes/No),
  select/readonly (resolved via option group), multiselect (handles
  array or delimited string), file (filename), text-like (as-is).
- Loading / empty / error states match the form's treatments.

types/form.ts: new FieldHistoryEntry, ActivitySummary, ReportPayload.
The fieldHistory map keys by FieldConfig.name and only includes fields
that have at least one non-empty entry.
This commit is contained in:
Joel Brock
2026-05-13 12:33:26 -07:00
parent 04e69ca04c
commit ba88eb0165
4 changed files with 978 additions and 0 deletions

247
app/api/report/route.ts Normal file
View File

@@ -0,0 +1,247 @@
/**
* GET /api/report?cid=<cid>&cs=<cs>
*
* Verifies the checksum, resolves the org from the contact via the
* Primary Contact relationship, then walks every Check-in (organizing)
* activity for the org and assembles a per-field history of non-empty
* values plus a summary list of the activities themselves.
*
* Returns ReportPayload (see types/form.ts).
*
* STUB MODE: if CiviCRM env vars are unset, returns synthesized history
* shaped from the same stub used by /api/data so the report UI is
* exercisable without a live CRM.
*/
import { NextRequest, NextResponse } from "next/server";
import { civi, verifyChecksum } from "@/lib/civicrm";
import {
allFields,
optionGroupIds,
ACTIVITY_TYPE_NAME,
ACTIVITY_STAGE_FIELD,
FORM_CONTACT_RELATIONSHIP,
} from "@/config/form";
import type {
ReportPayload,
SelectOption,
FieldHistoryEntry,
ActivitySummary,
} from "@/types/form";
function isStubMode(): boolean {
return !(
process.env.CIVI_BASE_URL &&
process.env.CIVI_API_KEY &&
process.env.CIVI_SITE_KEY
);
}
const STUB_PAYLOAD: ReportPayload = (() => {
const today = new Date();
const daysAgo = (n: number) =>
new Date(today.getTime() - n * 24 * 3600 * 1000).toISOString();
return {
orgName: "Sample Co-op (stub)",
currentStage: "Organizing",
activities: [
{ id: 9012, date: daysAgo(3), subject: "Co-op Check-in (form submission)" },
{ id: 9008, date: daysAgo(34), subject: "Co-op Check-in (form submission)" },
{ id: 9001, date: daysAgo(62), stage: "Organizing", subject: "Stage transition (staff)" },
{ id: 8995, date: daysAgo(95), subject: "Co-op Check-in (form submission)" },
{ id: 8980, date: daysAgo(180), stage: "Inquiry", subject: "Initial check-in (staff)" },
],
fieldHistory: {
Peer_Group_Participation: [
{ activityId: 9012, date: daysAgo(3), value: "Yes" },
{ activityId: 9008, date: daysAgo(34), value: "Considering" },
{ activityId: 8995, date: daysAgo(95), value: "No" },
],
Members__current_: [
{ activityId: 9012, date: daysAgo(3), value: 124 },
{ activityId: 9008, date: daysAgo(34), value: 109 },
{ activityId: 8995, date: daysAgo(95), value: 87 },
],
Member_Goal_for_current_Stage: [
{ activityId: 9008, date: daysAgo(34), value: 200 },
],
Internal_Startup_Assessment: [
{ activityId: 9012, date: daysAgo(3), value: "Strong" },
{ activityId: 8995, date: daysAgo(95), value: "Moderate" },
],
},
options: {
140: [{ value: "Yes", label: "Yes" }, { value: "No", label: "No" }, { value: "Considering", label: "Considering" }],
132: [{ value: "Strong", label: "Strong" }, { value: "Moderate", label: "Moderate" }, { value: "Needs Work", label: "Needs work" }],
75: [
{ value: "Inquiry", label: "Inquiry" },
{ value: "Organizing", label: "Stage 1 — Convene & Prepare" },
{ value: "Feasibility", label: "Stage 2 — Grow & Plan" },
{ value: "Business feasibility", label: "Stage 3 — Connect & Gather" },
{ value: "Store Implementation", label: "Stage 4 — Excite & Build" },
{ value: "Stabilize newly opened co-op", label: "Stage 5 — Fulfill & Stabilize" },
],
},
};
})();
interface OptionValueRow {
value: string;
label: string;
option_group_id: number;
is_active: boolean;
}
async function fetchOptionGroups(): Promise<Record<number, SelectOption[]>> {
if (optionGroupIds.length === 0) return {};
const res = await civi<OptionValueRow>("OptionValue", "get", {
select: ["value", "label", "option_group_id", "is_active"],
where: [
["option_group_id", "IN", optionGroupIds],
["is_active", "=", true],
],
orderBy: { weight: "ASC" },
limit: 500,
});
const out: Record<number, SelectOption[]> = {};
for (const row of res.values ?? []) {
if (!out[row.option_group_id]) out[row.option_group_id] = [];
out[row.option_group_id].push({ value: row.value, label: row.label });
}
return out;
}
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const cid = url.searchParams.get("cid");
const cs = url.searchParams.get("cs");
if (!cid || !cs) {
return NextResponse.json({ error: "Missing cid or cs parameter." }, { status: 400 });
}
if (isStubMode()) {
return NextResponse.json(STUB_PAYLOAD);
}
const ok = await verifyChecksum(cid, cs);
if (!ok) {
return NextResponse.json(
{ error: "Link is invalid or has expired. Please request a fresh one." },
{ status: 401 },
);
}
// Resolve org (mirror of /api/data).
const relRes = await civi<{ contact_id_b: number }>("Relationship", "get", {
select: ["contact_id_b"],
where: [
["contact_id_a", "=", Number(cid)],
["relationship_type_id.name_a_b", "=", FORM_CONTACT_RELATIONSHIP],
["is_active", "=", true],
],
limit: 2,
});
const orgs = relRes.values ?? [];
if (orgs.length === 0) {
return NextResponse.json(
{ error: `No active "${FORM_CONTACT_RELATIONSHIP}" relationship found for your contact.` },
{ status: 404 },
);
}
if (orgs.length > 1) {
return NextResponse.json(
{ error: `Your contact has multiple active "${FORM_CONTACT_RELATIONSHIP}" relationships; staff must resolve before this link will work.` },
{ status: 409 },
);
}
const orgId = orgs[0].contact_id_b;
// Org name + activity walk + option groups, in parallel.
const civiFieldNames = Array.from(
new Set(allFields.map((f) => f.civiField).filter((f): f is string => !!f)),
);
const select = [
"id",
"activity_date_time",
"subject",
ACTIVITY_STAGE_FIELD,
...civiFieldNames,
];
const [orgRes, activityRes, options] = await Promise.all([
civi<{ id: number; display_name: string }>("Contact", "get", {
select: ["id", "display_name"],
where: [["id", "=", orgId]],
}),
civi<Record<string, unknown> & { id: number; activity_date_time: string }>(
"Activity",
"get",
{
select,
where: [
["activity_type_id:name", "=", ACTIVITY_TYPE_NAME],
["target_contact_id", "=", orgId],
],
orderBy: { activity_date_time: "DESC", id: "DESC" },
limit: 500,
},
),
fetchOptionGroups(),
]);
const org = orgRes.values?.[0];
if (!org) {
return NextResponse.json({ error: "Organization not found." }, { status: 404 });
}
const rows = activityRes.values ?? [];
// Activity summaries — ordered DESC already.
const activities: ActivitySummary[] = rows.map((r) => ({
id: r.id,
date: r.activity_date_time,
stage: (r[ACTIVITY_STAGE_FIELD] as string | null | undefined) ?? null,
subject: (r.subject as string | null | undefined) ?? null,
}));
// Derive current stage from most recent stage-bearing activity.
const stageRow = rows.find(
(r) => {
const v = r[ACTIVITY_STAGE_FIELD];
return typeof v === "string" && v.length > 0;
},
);
const currentStage =
(typeof stageRow?.[ACTIVITY_STAGE_FIELD] === "string"
? (stageRow[ACTIVITY_STAGE_FIELD] as string)
: "") || "Inquiry";
// Per-field history. For every form-side field that has a civiField,
// walk activities and collect non-empty values.
const fieldHistory: Record<string, FieldHistoryEntry[]> = {};
for (const f of allFields) {
if (!f.civiField) continue;
if (f.type === "readonly") continue; // readonly displays don't have history
const entries: FieldHistoryEntry[] = [];
for (const row of rows) {
const v = row[f.civiField];
if (v === null || v === undefined || v === "") continue;
entries.push({
activityId: row.id,
date: row.activity_date_time,
value: v,
});
}
if (entries.length > 0) fieldHistory[f.name] = entries;
}
const payload: ReportPayload = {
orgName: org.display_name,
currentStage,
activities,
fieldHistory,
options,
};
return NextResponse.json(payload);
}

77
app/report/page.tsx Normal file
View File

@@ -0,0 +1,77 @@
import { Suspense } from "react";
import { ReportView } from "@/components/ReportView";
import { SiteHeader, SiteFooter } from "@/components/SiteChrome";
import { formConfig } from "@/config/form";
interface PageProps {
searchParams: Promise<{ cid?: string; cs?: string }>;
}
export const metadata = {
title: "Activity report — Food Co-op Initiative",
robots: { index: false, follow: false },
};
export default async function ReportPage({ searchParams }: PageProps) {
const { cid, cs } = await searchParams;
return (
<>
<a
href="#main"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded focus:bg-paper focus:px-3 focus:py-2 focus:text-ink focus:shadow"
>
Skip to content
</a>
<SiteHeader />
<main id="main" className="flex-1">
<div className="mx-auto max-w-3xl px-4 py-10 sm:px-6 sm:py-14">
<ReportIntro />
{!cid || !cs ? (
<MissingLinkParams />
) : (
<Suspense fallback={null}>
<ReportView config={formConfig} cid={cid} cs={cs} />
</Suspense>
)}
</div>
</main>
<SiteFooter />
</>
);
}
function ReportIntro() {
return (
<header className="mb-10 max-w-2xl">
<p className="text-[11px] uppercase tracking-[0.18em] text-leaf-700">
Activity report · Co-op organizing
</p>
<h1 className="mt-2 font-display text-[40px] font-normal leading-[1.05] tracking-tight text-ink sm:text-[52px]">
What we know so far
</h1>
<p className="mt-4 max-w-prose text-[17px] leading-relaxed text-ink-soft">
A read-only summary of every value your co-op has shared through past
check-ins, grouped by stage. The most recent entry sits at the top of
each row; expand a row to see how a number or note has changed over
time.
</p>
<div className="mt-6 h-px bg-rule" />
</header>
);
}
function MissingLinkParams() {
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">
This link is missing required information.
</h2>
<p className="mt-3 leading-relaxed text-ink-soft">
Open the report using the personalized link from your email. If you no
longer have it, please contact your engagement coordinator and ask for
a fresh link.
</p>
</div>
);
}

608
components/ReportView.tsx Normal file
View File

@@ -0,0 +1,608 @@
"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}
/>
{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>
);
}
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 && (
<ol className="mt-3 space-y-1.5 border-l-2 border-rule-soft pl-4 sm:ml-auto sm:max-w-[24rem]">
{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>
);
}
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":
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>
);
}

View File

@@ -197,3 +197,49 @@ export interface SubmitPayload {
cs: string;
values: Record<string, unknown>;
}
/**
* One historical entry for a field. The report walks all Check-in
* (organizing) activities for an org and collects every non-empty value
* each field has ever held, alongside the activity it came from.
*/
export interface FieldHistoryEntry {
activityId: number;
/** ISO timestamp of activity_date_time. */
date: string;
value: unknown;
}
/**
* A trimmed view of one activity, used to head the report and to label
* staff stage-change activities distinctly from form-driven submissions.
*/
export interface ActivitySummary {
id: number;
date: string;
/** Stage value carried on this activity (non-empty → staff transition). */
stage?: string | null;
subject?: string | null;
}
/**
* The shape returned by /api/report. Activities ordered DESC; each
* fieldHistory entry list is also DESC.
*/
export interface ReportPayload {
orgName: string;
/** Current Framework Stage as text value (e.g. "Organizing"). */
currentStage: string;
/**
* Activity summary list, ordered DESC by activity_date_time.
* Includes both staff stage-change and form-driven submission activities.
*/
activities: ActivitySummary[];
/**
* Per-field history of non-empty values, keyed by FieldConfig.name. Each
* list is sorted DESC by activity_date_time. Fields that have never been
* filled in are absent from this map.
*/
fieldHistory: Record<string, FieldHistoryEntry[]>;
options?: Record<number, SelectOption[]>;
}