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; }; } export interface StageRange { rank: number; /** ISO date this stage period begins (the stage-transition activity). */ startDate: string; /** * ISO date the period ends — the next chronologically-later activity with * a higher stage rank. `null` means the period is open-ended (still in * effect today); the chart should extend it to the right edge. */ endDate: string | null; activityId: number; subject: string | null; } /** * Derive per-activity stage ranges from the activity stream. Each * stage-transition activity (one with a non-empty `stage`) produces one * range: start = the activity's date, end = the date of the next * chronologically-following activity whose stage rank is *strictly higher*. * If no later activity has a higher rank, the range is open-ended. * * Multiple activities at the same stage each yield their own range; they'll * share the same end date if they sit between the same two higher-rank * boundaries. */ export function computeStageRanges(activities: ActivitySummary[]): StageRange[] { type Item = { t: number; rank: number; activityId: number; isoDate: string; subject: string | null; }; const items: Item[] = []; for (const a of activities) { if (typeof a.stage !== "string" || a.stage.length === 0) continue; const r = STAGE_RANK[a.stage]; if (typeof r !== "number" || r < 1 || r > 5) continue; const t = new Date(a.date).getTime(); if (!Number.isFinite(t)) continue; items.push({ t, rank: r, activityId: a.id, isoDate: a.date, subject: a.subject ?? null, }); } items.sort((x, y) => x.t - y.t); const ranges: StageRange[] = []; for (let i = 0; i < items.length; i++) { const cur = items[i]; let endIso: string | null = null; for (let j = i + 1; j < items.length; j++) { if (items[j].rank > cur.rank) { endIso = items[j].isoDate; break; } } ranges.push({ rank: cur.rank, startDate: cur.isoDate, endDate: endIso, activityId: cur.activityId, subject: cur.subject, }); } return ranges; }