diff --git a/components/ReportView.tsx b/components/ReportView.tsx
index ef9a699..eab9470 100644
--- a/components/ReportView.tsx
+++ b/components/ReportView.tsx
@@ -10,7 +10,7 @@ import type {
StageSectionConfig,
} from "@/types/form";
import { STAGE_OPTION_GROUP_ID } from "@/config/form";
-import { STAGE_RANK, buildStageRankAtDate } from "@/lib/stageRank";
+import { STAGE_RANK } from "@/lib/stageRank";
import { StageIcon } from "./StageIcon";
import {
FieldHistoryGroup,
@@ -121,7 +121,7 @@ export function ReportView({ config, cid, cs }: ReportViewProps) {
{sectionsToRender.length === 0 ? (
diff --git a/components/StaffReportView.tsx b/components/StaffReportView.tsx
index dae58a0..df48e5f 100644
--- a/components/StaffReportView.tsx
+++ b/components/StaffReportView.tsx
@@ -757,7 +757,13 @@ function StaffDateTimeline({ data }: { data: StaffReportPayload }) {
{ rank: 0, id: "all", label: "All dated events", fields: fieldConfigs },
];
- return ;
+ return (
+
+ );
}
// formatLongDate retained for symmetry with other report views; unused here
diff --git a/components/report/DateTimeline.tsx b/components/report/DateTimeline.tsx
index a270c5a..2e113f7 100644
--- a/components/report/DateTimeline.tsx
+++ b/components/report/DateTimeline.tsx
@@ -1,41 +1,48 @@
"use client";
-import type { FieldHistoryEntry, StageSectionConfig } from "@/types/form";
+import type { ActivitySummary, FieldHistoryEntry, StageSectionConfig } from "@/types/form";
import { formatShortDate } from "./FieldHistory";
+import { buildStageRankAtDate, computeStageRanges, type StageRange } from "@/lib/stageRank";
/**
- * Date-grouped timeline strip at the top of the report. Walks every
- * 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.
+ * Date-grouped timeline strip at the top of the report. Renders five
+ * horizontal swim lanes (Stage 5 on top down to Stage 1 at the bottom)
+ * and overlays two kinds of events:
*
- * 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.
+ * 1. Stage-period ranges, one per stage-transition activity, drawn
+ * as a soft bar from the activity date to the date of the next
+ * activity that moved the co-op to a higher stage. The latest
+ * open stage extends to today.
+ * 2. Milestone dots from date-type custom fields. The dot is placed
+ * on the lane corresponding to the stage the co-op was in on the
+ * field's stored date (resolved from the activity stream). When
+ * that's unknowable (date precedes any transition), it falls back
+ * to the field's own section rank. Stage 5's `Date_Opened` is
+ * pinned to Stage 5 as the journey anchor.
+ *
+ * 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,
+ activities,
}: {
sections: StageSectionConfig[];
fieldHistory: Record;
- stageRankAtDate?: (isoDate: string) => number | null;
+ activities: ActivitySummary[];
}) {
- type Event = {
+ const stageRankAtDate = buildStageRankAtDate(activities);
+ const ranges = computeStageRanges(activities);
+
+ type MilestoneEvent = {
rank: number;
fieldLabel: string;
- date: string; // YYYY-MM-DD or full ISO
+ date: string;
isOpened: boolean;
};
- const events: Event[] = [];
+ const milestones: MilestoneEvent[] = [];
for (const section of sections) {
for (const f of section.fields) {
if (f.type !== "date") continue;
@@ -44,39 +51,56 @@ export function DateTimeline({
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.
+ const resolved = stageRankAtDate(v);
let rank = isOpened ? 5 : (resolved ?? section.rank);
if (rank < 1 || rank > 5) rank = section.rank;
if (rank < 1 || rank > 5) continue;
- events.push({
- rank,
- fieldLabel: f.label,
- date: v,
- isOpened,
- });
+ milestones.push({ rank, fieldLabel: f.label, date: v, isOpened });
}
}
- if (events.length === 0) return null;
- const times = events.map((e) => new Date(e.date).getTime()).filter(Number.isFinite);
- if (times.length === 0) return null;
-
- const openedEvent = events.find((e) => e.isOpened);
- const openedTime = openedEvent ? new Date(openedEvent.date).getTime() : undefined;
- const minT = Math.min(...times);
- // If Date_Opened is set, anchor the right edge there; if any other event
- // is later, extend so nothing falls off the chart.
- const maxT = Math.max(...times, openedTime ?? -Infinity);
- const tRange = maxT - minT || 1;
+ // Collect every time value that should influence the time axis: range
+ // starts, range ends (excluding open-ended), milestone dates, and today
+ // if any range is open (so the open range visibly extends to "now").
+ const timeBag: number[] = [];
+ let hasOpenRange = false;
+ for (const r of ranges) {
+ const ts = new Date(r.startDate).getTime();
+ if (Number.isFinite(ts)) timeBag.push(ts);
+ if (r.endDate === null) {
+ hasOpenRange = true;
+ } else {
+ const te = new Date(r.endDate).getTime();
+ if (Number.isFinite(te)) timeBag.push(te);
+ }
+ }
+ for (const m of milestones) {
+ const t = new Date(m.date).getTime();
+ if (Number.isFinite(t)) timeBag.push(t);
+ }
const todayT = Date.now();
+ if (hasOpenRange) timeBag.push(todayT);
+ if (timeBag.length === 0) return null;
+
+ let minT = Math.min(...timeBag);
+ let maxT = Math.max(...timeBag);
+ if (minT === maxT) {
+ const pad = 30 * 24 * 3600 * 1000;
+ minT -= pad;
+ maxT += pad;
+ }
+ const tRange = maxT - minT || 1;
const todayPct =
todayT >= minT && todayT <= maxT ? ((todayT - minT) / tRange) * 100 : null;
-
const ticks = generateAxisTicks(minT, maxT);
+ const rangesByRank = new Map();
+ for (const r of ranges) {
+ const list = rangesByRank.get(r.rank) ?? [];
+ list.push(r);
+ rangesByRank.set(r.rank, list);
+ }
+
return (
{[5, 4, 3, 2, 1].map((rank) => {
- const rowEvents = events.filter((e) => e.rank === rank);
- const isEmpty = rowEvents.length === 0;
+ const rowRanges = rangesByRank.get(rank) ?? [];
+ const rowMilestones = milestones.filter((e) => e.rank === rank);
+ const isEmpty = rowRanges.length === 0 && rowMilestones.length === 0;
return (
+ {/* Stage-period ranges: soft bar from activity start to next
+ higher-stage activity (or to today if still open). */}
+ {rowRanges.map((r, i) => {
+ const ts = new Date(r.startDate).getTime();
+ if (!Number.isFinite(ts)) return null;
+ const endT =
+ r.endDate === null
+ ? Math.max(todayT, ts)
+ : new Date(r.endDate).getTime();
+ if (!Number.isFinite(endT)) return null;
+ const startPct = ((ts - minT) / tRange) * 100;
+ const endPct = ((endT - minT) / tRange) * 100;
+ const widthPct = Math.max(endPct - startPct, 0.5);
+ return (
+
+ );
+ })}
{todayPct !== null && (
)}
- {rowEvents.map((e, i) => {
- const t = new Date(e.date).getTime();
+ {/* Activity start dots — placed at each range start for
+ keyboard focus + tooltip with the activity subject. */}
+ {rowRanges.map((r, i) => {
+ const t = new Date(r.startDate).getTime();
if (!Number.isFinite(t)) return null;
const x = ((t - minT) / tRange) * 100;
return (
-
+
);
})}
+ {/* Milestone date-field dots layered on top. */}
+ {rowMilestones.map((e, i) => {
+ const t = new Date(e.date).getTime();
+ if (!Number.isFinite(t)) return null;
+ const x = ((t - minT) / tRange) * 100;
+ return ;
+ })}
);
@@ -154,7 +207,6 @@ export function DateTimeline({
/>
{ticks.map((tk, i) => {
const x = ((tk.t - minT) / tRange) * 100;
- // Edge labels shift so they don't overflow the row.
const alignClass =
x < 6
? "left-0 origin-top-left"
@@ -201,8 +253,14 @@ export function DateTimeline({
)}
{/* Accessible event list — invisible to sighted users but readable by SR */}
- {events.map((e, i) => (
- -
+ {ranges.map((r, i) => (
+
-
+ Stage {r.rank}: {formatShortDate(r.startDate)}
+ {r.endDate ? ` to ${formatShortDate(r.endDate)}` : " (current)"}
+
+ ))}
+ {milestones.map((e, i) => (
+ -
Stage {e.rank}: {e.fieldLabel} — {formatShortDate(e.date)}
{e.isOpened ? " (opened)" : ""}
@@ -214,8 +272,6 @@ export function DateTimeline({
function stageDotBg(rank: number): string {
switch (rank) {
- case 0:
- return "bg-leaf-200";
case 1:
return "bg-leaf-300";
case 2:
@@ -231,14 +287,108 @@ function stageDotBg(rank: number): string {
}
}
+function stageRangeBg(rank: number): string {
+ switch (rank) {
+ case 1:
+ return "bg-leaf-300/30";
+ case 2:
+ return "bg-leaf-500/25";
+ case 3:
+ return "bg-leaf-600/25";
+ case 4:
+ return "bg-leaf-700/25";
+ case 5:
+ return "bg-clay-700/25";
+ default:
+ return "bg-leaf-500/25";
+ }
+}
+
+/** Soft horizontal bar marking a stage period on a lane. */
+function StageRangeBar({
+ rank,
+ range,
+ startPct,
+ widthPct,
+ isOpen,
+}: {
+ rank: number;
+ range: StageRange;
+ startPct: number;
+ widthPct: number;
+ isOpen: boolean;
+}) {
+ return (
+
+ );
+}
+
+/** Dot at the start of a stage period; carries the activity tooltip. */
+function ActivityDot({
+ rank,
+ range,
+ x,
+}: {
+ rank: number;
+ range: StageRange;
+ x: number;
+}) {
+ const tipAlign =
+ x < 18 ? "left-0" : x > 82 ? "right-0" : "left-1/2 -translate-x-1/2";
+ return (
+
+
+
+ {range.subject ?? `Stage ${rank} check-in`}
+
+
+ {formatShortDate(range.startDate)}
+ {range.endDate ? (
+ <> → {formatShortDate(range.endDate)}>
+ ) : (
+ → current
+ )}
+
+
+
+ );
+}
+
/**
- * A single dot on the timeline plus its hover/focus tooltip. Keyboard
+ * A single milestone date-field dot plus its hover/focus tooltip. Keyboard
* users can Tab to each dot and the tooltip will appear via
* `group-focus-within`. The tooltip is anchored above the dot; for dots
* near the left or right edge of the row, anchor flips so the card
* doesn't overflow.
*/
-function TimelineDot({
+function MilestoneDot({
rank,
event,
x,
@@ -252,11 +402,7 @@ function TimelineDot({
const size = isOpened ? "h-3.5 w-3.5" : "h-2.5 w-2.5";
const ring = isOpened ? "ring-2" : "ring-1";
const tipAlign =
- x < 18
- ? "left-0"
- : x > 82
- ? "right-0"
- : "left-1/2 -translate-x-1/2";
+ x < 18 ? "left-0" : x > 82 ? "right-0" : "left-1/2 -translate-x-1/2";
return (
maxT) break;
diff --git a/lib/stageRank.ts b/lib/stageRank.ts
index 436ff3b..c362128 100644
--- a/lib/stageRank.ts
+++ b/lib/stageRank.ts
@@ -48,3 +48,74 @@ export function buildStageRankAtDate(
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;
+}