diff --git a/components/ReportView.tsx b/components/ReportView.tsx index 71ecc29..ef9a699 100644 --- a/components/ReportView.tsx +++ b/components/ReportView.tsx @@ -10,6 +10,7 @@ import type { StageSectionConfig, } from "@/types/form"; import { STAGE_OPTION_GROUP_ID } from "@/config/form"; +import { STAGE_RANK, buildStageRankAtDate } from "@/lib/stageRank"; import { StageIcon } from "./StageIcon"; import { FieldHistoryGroup, @@ -39,15 +40,6 @@ type LoadState = 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" }); @@ -126,7 +118,11 @@ export function ReportView({ config, cid, cs }: ReportViewProps) { dateRange={dateRange} /> - + {sectionsToRender.length === 0 ? ( diff --git a/components/report/DateTimeline.tsx b/components/report/DateTimeline.tsx index a538175..a270c5a 100644 --- a/components/report/DateTimeline.tsx +++ b/components/report/DateTimeline.tsx @@ -5,21 +5,29 @@ import { formatShortDate } from "./FieldHistory"; /** * Date-grouped timeline strip at the top of the report. Walks every - * date-type field across stage sections 1–5, pulls each field's + * date-type field across all stage sections, pulls each field's * most-recent entered date, and plots events into five horizontal swim * lanes — one per stage, Stage 5 on top down to Stage 1 at the bottom. - * 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. + * + * Each dot is placed on the lane corresponding to the stage the co-op + * was in on the field's stored date, resolved via `stageRankAtDate` + * over the activity stage-transition history. When the date precedes + * any known transition (no evidence), we fall back to the field's own + * section rank so the dot still appears somewhere sensible. Stage 5's + * `Date_Opened` is forced to Stage 5 and 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. */ export function DateTimeline({ sections, fieldHistory, + stageRankAtDate, }: { sections: StageSectionConfig[]; fieldHistory: Record; + stageRankAtDate?: (isoDate: string) => number | null; }) { type Event = { rank: number; @@ -29,18 +37,25 @@ export function DateTimeline({ }; const events: Event[] = []; for (const section of sections) { - if (section.rank < 1 || 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; + const isOpened = f.name === "Date_Opened"; + const resolved = stageRankAtDate?.(v) ?? null; + // Date_Opened is the Stage-5 anchor by definition. Otherwise prefer + // the stage in effect on the event date; fall back to the field's + // section rank when no transition history covers that point. + let rank = isOpened ? 5 : (resolved ?? section.rank); + if (rank < 1 || rank > 5) rank = section.rank; + if (rank < 1 || rank > 5) continue; events.push({ - rank: section.rank, + rank, fieldLabel: f.label, date: v, - isOpened: f.name === "Date_Opened", + isOpened, }); } } diff --git a/lib/stageRank.ts b/lib/stageRank.ts new file mode 100644 index 0000000..436ff3b --- /dev/null +++ b/lib/stageRank.ts @@ -0,0 +1,50 @@ +import type { ActivitySummary } from "@/types/form"; + +/** + * CiviCRM activity stage values → Framework Stage rank (1..5). "Inquiry" + * predates Stage 1 and is conventionally rank 0 (pre-engagement); callers + * that render stage lanes 1..5 should treat 0 as "no rank in scope". + */ +export const STAGE_RANK: Record = { + Inquiry: 0, + Organizing: 1, + Feasibility: 2, + "Business feasibility": 3, + "Store Implementation": 4, + "Stabilize newly opened co-op": 5, +}; + +/** + * Build a resolver that returns the co-op's Framework Stage rank at a given + * point in time, derived from the activity stream. Only activities that + * carry a non-empty `stage` value are stage transitions; their stage holds + * from that activity's date forward until the next transition. + * + * Returns `null` when the date precedes any known transition (i.e. we have + * no evidence of which stage the co-op was in at that point) — callers can + * fall back to a field-section default in that case. + */ +export function buildStageRankAtDate( + activities: ActivitySummary[] +): (isoDate: string) => number | null { + const transitions = activities + .filter( + (a): a is ActivitySummary & { stage: string } => + typeof a.stage === "string" && a.stage.length > 0 + ) + .map((a) => ({ t: new Date(a.date).getTime(), stage: a.stage })) + .filter((tr) => Number.isFinite(tr.t)) + .sort((a, b) => a.t - b.t); + + return (isoDate: string) => { + const t = new Date(isoDate).getTime(); + if (!Number.isFinite(t)) return null; + let last: number | null = null; + for (const tr of transitions) { + if (tr.t > t) break; + const r = STAGE_RANK[tr.stage]; + if (typeof r === "number") last = r; + } + return last; + }; +}