Timeline: place dots by co-op stage at the event date
Each date-field event is now plotted on the lane corresponding to the Framework Stage the co-op was in on the field's stored date, resolved from the activity stream's stage transitions. Date_Opened is pinned to Stage 5 as the journey anchor. Events that pre-date any known transition fall back to the field's section rank so they still surface somewhere. Extracts the STAGE_RANK map (previously inline in ReportView) into lib/stageRank.ts alongside the new buildStageRankAtDate resolver factory.
This commit is contained in:
@@ -10,6 +10,7 @@ import type {
|
|||||||
StageSectionConfig,
|
StageSectionConfig,
|
||||||
} from "@/types/form";
|
} from "@/types/form";
|
||||||
import { STAGE_OPTION_GROUP_ID } from "@/config/form";
|
import { STAGE_OPTION_GROUP_ID } from "@/config/form";
|
||||||
|
import { STAGE_RANK, buildStageRankAtDate } from "@/lib/stageRank";
|
||||||
import { StageIcon } from "./StageIcon";
|
import { StageIcon } from "./StageIcon";
|
||||||
import {
|
import {
|
||||||
FieldHistoryGroup,
|
FieldHistoryGroup,
|
||||||
@@ -39,15 +40,6 @@ type LoadState =
|
|||||||
|
|
||||||
type PathwayState = "past" | "current" | "future";
|
type PathwayState = "past" | "current" | "future";
|
||||||
|
|
||||||
const STAGE_RANK: Record<string, number> = {
|
|
||||||
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) {
|
export function ReportView({ config, cid, cs }: ReportViewProps) {
|
||||||
const [load, setLoad] = useState<LoadState>({ kind: "loading" });
|
const [load, setLoad] = useState<LoadState>({ kind: "loading" });
|
||||||
|
|
||||||
@@ -126,7 +118,11 @@ export function ReportView({ config, cid, cs }: ReportViewProps) {
|
|||||||
dateRange={dateRange}
|
dateRange={dateRange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DateTimeline sections={config.sections} fieldHistory={data.fieldHistory} />
|
<DateTimeline
|
||||||
|
sections={config.sections}
|
||||||
|
fieldHistory={data.fieldHistory}
|
||||||
|
stageRankAtDate={buildStageRankAtDate(data.activities)}
|
||||||
|
/>
|
||||||
|
|
||||||
{sectionsToRender.length === 0 ? (
|
{sectionsToRender.length === 0 ? (
|
||||||
<EmptyState />
|
<EmptyState />
|
||||||
|
|||||||
@@ -5,21 +5,29 @@ import { formatShortDate } from "./FieldHistory";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Date-grouped timeline strip at the top of the report. Walks every
|
* 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
|
* 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.
|
* 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
|
* Each dot is placed on the lane corresponding to the stage the co-op
|
||||||
* reader if today falls within the range, and an adaptive month/year
|
* was in on the field's stored date, resolved via `stageRankAtDate`
|
||||||
* axis runs beneath the lanes. Each dot exposes a tooltip on hover or
|
* over the activity stage-transition history. When the date precedes
|
||||||
* keyboard focus.
|
* 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({
|
export function DateTimeline({
|
||||||
sections,
|
sections,
|
||||||
fieldHistory,
|
fieldHistory,
|
||||||
|
stageRankAtDate,
|
||||||
}: {
|
}: {
|
||||||
sections: StageSectionConfig[];
|
sections: StageSectionConfig[];
|
||||||
fieldHistory: Record<string, FieldHistoryEntry[]>;
|
fieldHistory: Record<string, FieldHistoryEntry[]>;
|
||||||
|
stageRankAtDate?: (isoDate: string) => number | null;
|
||||||
}) {
|
}) {
|
||||||
type Event = {
|
type Event = {
|
||||||
rank: number;
|
rank: number;
|
||||||
@@ -29,18 +37,25 @@ export function DateTimeline({
|
|||||||
};
|
};
|
||||||
const events: Event[] = [];
|
const events: Event[] = [];
|
||||||
for (const section of sections) {
|
for (const section of sections) {
|
||||||
if (section.rank < 1 || section.rank > 5) continue;
|
|
||||||
for (const f of section.fields) {
|
for (const f of section.fields) {
|
||||||
if (f.type !== "date") continue;
|
if (f.type !== "date") continue;
|
||||||
const history = fieldHistory[f.name];
|
const history = fieldHistory[f.name];
|
||||||
if (!history || history.length === 0) continue;
|
if (!history || history.length === 0) continue;
|
||||||
const v = history[0].value;
|
const v = history[0].value;
|
||||||
if (typeof v !== "string" || v.length === 0) continue;
|
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({
|
events.push({
|
||||||
rank: section.rank,
|
rank,
|
||||||
fieldLabel: f.label,
|
fieldLabel: f.label,
|
||||||
date: v,
|
date: v,
|
||||||
isOpened: f.name === "Date_Opened",
|
isOpened,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<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;
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user