Files
Joel Brock 13d7f6e93b Timeline: draw stage-period ranges from activities, milestones on top
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.
2026-06-11 14:14:36 -07:00

122 lines
3.8 KiB
TypeScript

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<string, number> = {
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;
}