diff --git a/components/ReportView.tsx b/components/ReportView.tsx index a14c997..a3777a2 100644 --- a/components/ReportView.tsx +++ b/components/ReportView.tsx @@ -19,6 +19,7 @@ import { Chevron, numberFmt, } from "./report/FieldHistory"; +import { DateTimeline, generateAxisTicks } from "./report/DateTimeline"; interface ReportViewProps { config: FormConfig; @@ -226,333 +227,6 @@ function StageProgress({ currentRank }: { currentRank: number }) { ); } -/** - * Date-grouped timeline strip at the top of the report. Walks every - * date-type field across all stage sections (0–5), pulls each field's - * most-recent entered date, and plots events into six horizontal swim - * lanes — one per stage. 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. - */ -function DateTimeline({ - sections, - fieldHistory, -}: { - sections: StageSectionConfig[]; - fieldHistory: Record; -}) { - type Event = { - rank: number; - fieldLabel: string; - date: string; // YYYY-MM-DD or full ISO - isOpened: boolean; - }; - const events: Event[] = []; - for (const section of sections) { - if (section.rank < 0 || 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; - events.push({ - rank: section.rank, - fieldLabel: f.label, - date: v, - isOpened: f.name === "Date_Opened", - }); - } - } - 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; - const todayT = Date.now(); - const todayPct = - todayT >= minT && todayT <= maxT ? ((todayT - minT) / tRange) * 100 : null; - - const ticks = generateAxisTicks(minT, maxT); - - return ( -
-
-

- Timeline -

-

- {formatShortDate(new Date(minT).toISOString())} - {" → "} - {formatShortDate(new Date(maxT).toISOString())} -

-
-
    - {[0, 1, 2, 3, 4, 5].map((rank) => { - const rowEvents = events.filter((e) => e.rank === rank); - const isEmpty = rowEvents.length === 0; - return ( -
  1. - - Stage {rank} - -
    - - {todayPct !== null && ( - - )} - {rowEvents.map((e, i) => { - const t = new Date(e.date).getTime(); - if (!Number.isFinite(t)) return null; - const x = ((t - minT) / tRange) * 100; - return ( - - ); - })} -
    -
  2. - ); - })} -
- - {/* Adaptive month/year axis. Render only when we have ≥2 ticks so a - single tick doesn't dangle. Today gets its own labeled mark when - it falls within range. */} - {ticks.length >= 2 && ( -
- -
- - {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" - : x > 94 - ? "right-0 origin-top-right text-right" - : "left-1/2 -translate-x-1/2 text-center"; - return ( - - - - {tk.label} - - - ); - })} - {todayPct !== null && ( - - - - Today - - - )} -
-
- )} - {/* Accessible event list — invisible to sighted users but readable by SR */} - -
- ); -} - -function stageDotBg(rank: number): string { - switch (rank) { - case 0: - return "bg-leaf-200"; - case 1: - return "bg-leaf-300"; - case 2: - return "bg-leaf-500"; - case 3: - return "bg-leaf-600"; - case 4: - return "bg-leaf-700"; - case 5: - return "bg-clay-700"; - default: - return "bg-leaf-600"; - } -} - -/** - * A single dot on the timeline 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({ - rank, - event, - x, -}: { - rank: number; - event: { fieldLabel: string; date: string; isOpened: boolean }; - x: number; -}) { - const isOpened = event.isOpened; - const bg = isOpened ? "bg-clay-700" : stageDotBg(rank); - 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"; - return ( - - - - ); -} - -/** - * Pick a "nice" tick interval for the time axis based on the visible - * span, then walk that interval from min to max producing labeled ticks. - * Uses months for shorter spans, years for longer ones; January-bordered - * month ticks include the year so the reader has an anchor. - */ -function generateAxisTicks(minT: number, maxT: number): Array<{ t: number; label: string }> { - const range = maxT - minT; - const monthMs = 30.4375 * 24 * 3600 * 1000; - const months = range / monthMs; - - type Step = { unit: "month" | "year"; step: number }; - let plan: Step; - if (months < 4) plan = { unit: "month", step: 1 }; - else if (months < 12) plan = { unit: "month", step: 2 }; - else if (months < 24) plan = { unit: "month", step: 3 }; - else if (months < 48) plan = { unit: "month", step: 6 }; - else if (months / 12 < 12) plan = { unit: "year", step: 1 }; - else plan = { unit: "year", step: 2 }; - - const ticks: Array<{ t: number; label: string }> = []; - const start = new Date(minT); - let y = start.getFullYear(); - let m = - plan.unit === "year" - ? 0 - : Math.floor(start.getMonth() / plan.step) * plan.step; - // Don't skip a tick that sits right at minT — start from minT-aligned step. - while (true) { - const t = new Date(y, m, 1).getTime(); - if (t > maxT) break; - if (t >= minT) { - const d = new Date(y, m, 1); - const label = - plan.unit === "year" - ? String(y) - : m === 0 - ? d.toLocaleDateString("en-US", { month: "short", year: "numeric" }) - : d.toLocaleDateString("en-US", { month: "short" }); - ticks.push({ t, label }); - } - if (plan.unit === "year") { - y += plan.step; - } else { - m += plan.step; - while (m > 11) { - m -= 12; - y += 1; - } - } - } - return ticks; -} - const MEMBERS_ACTUAL_NAME = "Members__current_"; const MEMBERS_GOAL_NAME = "Member_Goal_for_current_Stage"; diff --git a/components/report/DateTimeline.tsx b/components/report/DateTimeline.tsx new file mode 100644 index 0000000..14e5299 --- /dev/null +++ b/components/report/DateTimeline.tsx @@ -0,0 +1,331 @@ +"use client"; + +import type { FieldHistoryEntry, StageSectionConfig } from "@/types/form"; +import { formatShortDate } from "./FieldHistory"; + +/** + * Date-grouped timeline strip at the top of the report. Walks every + * date-type field across all stage sections (0–5), pulls each field's + * most-recent entered date, and plots events into six horizontal swim + * lanes — one per stage. 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. + */ +export function DateTimeline({ + sections, + fieldHistory, +}: { + sections: StageSectionConfig[]; + fieldHistory: Record; +}) { + type Event = { + rank: number; + fieldLabel: string; + date: string; // YYYY-MM-DD or full ISO + isOpened: boolean; + }; + const events: Event[] = []; + for (const section of sections) { + if (section.rank < 0 || 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; + events.push({ + rank: section.rank, + fieldLabel: f.label, + date: v, + isOpened: f.name === "Date_Opened", + }); + } + } + 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; + const todayT = Date.now(); + const todayPct = + todayT >= minT && todayT <= maxT ? ((todayT - minT) / tRange) * 100 : null; + + const ticks = generateAxisTicks(minT, maxT); + + return ( +
+
+

+ Timeline +

+

+ {formatShortDate(new Date(minT).toISOString())} + {" → "} + {formatShortDate(new Date(maxT).toISOString())} +

+
+
    + {[0, 1, 2, 3, 4, 5].map((rank) => { + const rowEvents = events.filter((e) => e.rank === rank); + const isEmpty = rowEvents.length === 0; + return ( +
  1. + + Stage {rank} + +
    + + {todayPct !== null && ( + + )} + {rowEvents.map((e, i) => { + const t = new Date(e.date).getTime(); + if (!Number.isFinite(t)) return null; + const x = ((t - minT) / tRange) * 100; + return ( + + ); + })} +
    +
  2. + ); + })} +
+ + {/* Adaptive month/year axis. Render only when we have ≥2 ticks so a + single tick doesn't dangle. Today gets its own labeled mark when + it falls within range. */} + {ticks.length >= 2 && ( +
+ +
+ + {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" + : x > 94 + ? "right-0 origin-top-right text-right" + : "left-1/2 -translate-x-1/2 text-center"; + return ( + + + + {tk.label} + + + ); + })} + {todayPct !== null && ( + + + + Today + + + )} +
+
+ )} + {/* Accessible event list — invisible to sighted users but readable by SR */} +
    + {events.map((e, i) => ( +
  • + Stage {e.rank}: {e.fieldLabel} — {formatShortDate(e.date)} + {e.isOpened ? " (opened)" : ""} +
  • + ))} +
+
+ ); +} + +function stageDotBg(rank: number): string { + switch (rank) { + case 0: + return "bg-leaf-200"; + case 1: + return "bg-leaf-300"; + case 2: + return "bg-leaf-500"; + case 3: + return "bg-leaf-600"; + case 4: + return "bg-leaf-700"; + case 5: + return "bg-clay-700"; + default: + return "bg-leaf-600"; + } +} + +/** + * A single dot on the timeline 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({ + rank, + event, + x, +}: { + rank: number; + event: { fieldLabel: string; date: string; isOpened: boolean }; + x: number; +}) { + const isOpened = event.isOpened; + const bg = isOpened ? "bg-clay-700" : stageDotBg(rank); + 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"; + return ( + + + + ); +} + +/** + * Pick a "nice" tick interval for the time axis based on the visible + * span, then walk that interval from min to max producing labeled ticks. + * Uses months for shorter spans, years for longer ones; January-bordered + * month ticks include the year so the reader has an anchor. + */ +export function generateAxisTicks(minT: number, maxT: number): Array<{ t: number; label: string }> { + const range = maxT - minT; + const monthMs = 30.4375 * 24 * 3600 * 1000; + const months = range / monthMs; + + type Step = { unit: "month" | "year"; step: number }; + let plan: Step; + if (months < 4) plan = { unit: "month", step: 1 }; + else if (months < 12) plan = { unit: "month", step: 2 }; + else if (months < 24) plan = { unit: "month", step: 3 }; + else if (months < 48) plan = { unit: "month", step: 6 }; + else if (months / 12 < 12) plan = { unit: "year", step: 1 }; + else plan = { unit: "year", step: 2 }; + + const ticks: Array<{ t: number; label: string }> = []; + const start = new Date(minT); + let y = start.getFullYear(); + let m = + plan.unit === "year" + ? 0 + : Math.floor(start.getMonth() / plan.step) * plan.step; + // Don't skip a tick that sits right at minT — start from minT-aligned step. + while (true) { + const t = new Date(y, m, 1).getTime(); + if (t > maxT) break; + if (t >= minT) { + const d = new Date(y, m, 1); + const label = + plan.unit === "year" + ? String(y) + : m === 0 + ? d.toLocaleDateString("en-US", { month: "short", year: "numeric" }) + : d.toLocaleDateString("en-US", { month: "short" }); + ticks.push({ t, label }); + } + if (plan.unit === "year") { + y += plan.step; + } else { + m += plan.step; + while (m > 11) { + m -= 12; + y += 1; + } + } + } + return ticks; +}