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.
122 lines
3.8 KiB
TypeScript
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;
|
|
}
|