Each stage-transition activity now becomes a horizontal range on its lane: start = the activity's date, end = the next activity with a higher stage rank, or extending to today if still in effect. Milestone date-field dots overlay on top of the ranges. Activity dots at each range start carry a tooltip with the activity subject. Added computeStageRanges in lib/stageRank.ts and switched DateTimeline to take activities directly (deriving both the rank resolver and the ranges internally). Wide year-spanning timelines now show their full extent even when no milestone dates have been entered.
483 lines
17 KiB
TypeScript
483 lines
17 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 { STAGE_RANK } from "@/lib/stageRank";
|
|
import { StageIcon } from "./StageIcon";
|
|
import {
|
|
FieldHistoryGroup,
|
|
FieldHistoryRow,
|
|
formatShortDate,
|
|
computeDateRange,
|
|
Chevron,
|
|
} from "./report/FieldHistory";
|
|
import { DateTimeline } from "./report/DateTimeline";
|
|
import {
|
|
MembershipChart,
|
|
MEMBERS_ACTUAL_NAME,
|
|
MEMBERS_GOAL_NAME,
|
|
} from "./report/MembershipChart";
|
|
import { LoadingState, EmptyState, ErrorState } from "./report/ReportStates";
|
|
|
|
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";
|
|
|
|
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}
|
|
activities={data.activities}
|
|
/>
|
|
|
|
{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="Surveys" 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">
|
|
{(() => {
|
|
// Compute the slot for the inline Membership chart: render it
|
|
// immediately after whichever of (Members current, Member Goal)
|
|
// appears last among this section's fields-with-history. If
|
|
// neither field is in this section, the index is -1 and the
|
|
// chart is skipped — naturally scoping the chart to whichever
|
|
// section those questions live in (Stage 0 today).
|
|
const memberIdx = fields.findIndex((f) => f.name === MEMBERS_ACTUAL_NAME);
|
|
const goalIdx = fields.findIndex((f) => f.name === MEMBERS_GOAL_NAME);
|
|
const chartIdx = Math.max(memberIdx, goalIdx);
|
|
const membersFieldInSection = memberIdx >= 0 ? fields[memberIdx] : undefined;
|
|
const goalFieldInSection = goalIdx >= 0 ? fields[goalIdx] : undefined;
|
|
|
|
// Build a map from field name → its group config (if any). A
|
|
// field referenced in multiple groups belongs to the first one.
|
|
const groupByField = new Map<string, { id: string; label?: string }>();
|
|
for (const g of section.fieldGroups ?? []) {
|
|
for (const fname of g.fields) {
|
|
if (!groupByField.has(fname)) {
|
|
groupByField.set(fname, { id: g.id, label: g.label });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Walk fields in declared order; whenever we hit one that
|
|
// belongs to a group we haven't emitted yet, collect every
|
|
// visible field from that group (those with history) and emit
|
|
// them as one bordered cluster. Other group members get
|
|
// skipped when we encounter them later in the loop.
|
|
const fieldsWithHistorySet = new Set(fields.map((f) => f.name));
|
|
const emittedGroups = new Set<string>();
|
|
const out: React.ReactNode[] = [];
|
|
|
|
fields.forEach((f, i) => {
|
|
const grp = groupByField.get(f.name);
|
|
if (grp && !emittedGroups.has(grp.id)) {
|
|
emittedGroups.add(grp.id);
|
|
const sectionGroup = (section.fieldGroups ?? []).find((g) => g.id === grp.id)!;
|
|
const groupedFields = sectionGroup.fields
|
|
.map((name) => fields.find((ff) => ff.name === name))
|
|
.filter((ff): ff is FieldConfig => !!ff && fieldsWithHistorySet.has(ff.name));
|
|
if (groupedFields.length === 0) return;
|
|
out.push(
|
|
<FieldHistoryGroup
|
|
key={`group-${grp.id}`}
|
|
label={grp.label}
|
|
fields={groupedFields}
|
|
history={history}
|
|
options={options}
|
|
/>,
|
|
);
|
|
} else if (!grp) {
|
|
out.push(
|
|
<FieldHistoryRow
|
|
key={f.name}
|
|
field={f}
|
|
entries={history[f.name] ?? []}
|
|
options={options}
|
|
/>,
|
|
);
|
|
}
|
|
if (i === chartIdx && chartIdx >= 0) {
|
|
out.push(
|
|
<div key={`chart-${f.name}`} className="bg-paper-2/20 px-5 py-5 sm:px-7 sm:py-6">
|
|
<MembershipChart
|
|
membersField={membersFieldInSection}
|
|
goalField={goalFieldInSection}
|
|
membersHistory={history[MEMBERS_ACTUAL_NAME]}
|
|
goalHistory={history[MEMBERS_GOAL_NAME]}
|
|
/>
|
|
</div>,
|
|
);
|
|
}
|
|
});
|
|
|
|
return out;
|
|
})()}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<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 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>
|
|
);
|
|
}
|
|
|