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 (
  1. + {/* 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 ; + })}
  2. ); @@ -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 ( + + + + ); +} + /** - * 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; +}