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:
Joel Brock
2026-06-11 13:56:27 -07:00
parent ade938eaaa
commit d3b88d92c2
3 changed files with 80 additions and 19 deletions
+6 -10
View File
@@ -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<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) {
const [load, setLoad] = useState<LoadState>({ kind: "loading" });
@@ -126,7 +118,11 @@ export function ReportView({ config, cid, cs }: ReportViewProps) {
dateRange={dateRange}
/>
<DateTimeline sections={config.sections} fieldHistory={data.fieldHistory} />
<DateTimeline
sections={config.sections}
fieldHistory={data.fieldHistory}
stageRankAtDate={buildStageRankAtDate(data.activities)}
/>
{sectionsToRender.length === 0 ? (
<EmptyState />
+24 -9
View File
@@ -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 15, 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<string, FieldHistoryEntry[]>;
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,
});
}
}