From 814b560363535882964143b0632861506e1ef69a Mon Sep 17 00:00:00 2001 From: Joel Brock Date: Tue, 19 May 2026 16:16:57 -0700 Subject: [PATCH] Report: sparkline charts on numeric history + stage-grouped date timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two visual additions to the read-only activity report. Sparkline: when the user expands earlier-entries on a numeric field (number/currency/percent) with two or more numeric points, the expansion now leads with a 240x56 inline SVG trend chart — chronological polyline, faint area fill, small dots at every measurement, a slightly larger emphasized dot on the most recent point. Min and max captions sit beneath in tabular-nums, formatted in the field's native style (currency uses Intl, percent appends %, etc.). Non-numeric fields are unchanged. DateTimeline: a new card between the context header and the section accordions. Walks every date-type field in stage sections 1-5 (Stage 0 omitted as it isn't a stage in the journey sense), pulls each field's most-recent entered date, and lays the events out in five horizontal swim lanes — one per stage rank, labeled at the left. Time axis spans from the earliest event to max(latest event, Date_Opened). Stage 5's Date_Opened is rendered as a larger clay-700 dot with a heavier ring so it reads as the journey's anchor at the right end. A faint clay-300 dashed vertical line marks 'today' if it falls within the range. Color scale across stages is leaf-300 / leaf-500 / leaf-600 / leaf-700 / clay-700 — a sprout-to-fruit gradient that matches the existing palette. Empty stage rows still draw their lane line at half opacity so the structure stays readable. SR-only event list provides screen-reader access to all plotted dates with their labels. Stub payload enriched with four cross-stage date entries so the timeline has content in dev preview. --- app/api/report/route.ts | 18 +++ components/ReportView.tsx | 316 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 319 insertions(+), 15 deletions(-) diff --git a/app/api/report/route.ts b/app/api/report/route.ts index bd01aca..86c6ab1 100644 --- a/app/api/report/route.ts +++ b/app/api/report/route.ts @@ -41,6 +41,10 @@ const STUB_PAYLOAD: ReportPayload = (() => { const today = new Date(); const daysAgo = (n: number) => new Date(today.getTime() - n * 24 * 3600 * 1000).toISOString(); + const ymd = (n: number) => { + const d = new Date(today.getTime() - n * 24 * 3600 * 1000); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + }; return { orgName: "Sample Co-op (stub)", currentStage: "Organizing", @@ -69,6 +73,20 @@ const STUB_PAYLOAD: ReportPayload = (() => { { activityId: 9012, date: daysAgo(3), value: "Strong" }, { activityId: 8995, date: daysAgo(95), value: "Moderate" }, ], + // Stage-spanning dates so the timeline strip has events across the + // whole journey in dev preview. + Preliminary_Market_Assessment: [ + { activityId: 8995, date: daysAgo(95), value: ymd(180) }, + ], + Market_Study_Date: [ + { activityId: 9008, date: daysAgo(34), value: ymd(60) }, + ], + Projected_Opening_Date: [ + { activityId: 9012, date: daysAgo(3), value: ymd(-365) }, + ], + Date_Opened: [ + { activityId: 9012, date: daysAgo(3), value: ymd(-450) }, + ], }, options: { 140: [{ value: "Yes", label: "Yes" }, { value: "No", label: "No" }, { value: "Considering", label: "Considering" }], diff --git a/components/ReportView.tsx b/components/ReportView.tsx index 83a6123..8a647d6 100644 --- a/components/ReportView.tsx +++ b/components/ReportView.tsx @@ -112,6 +112,8 @@ export function ReportView({ config, cid, cs }: ReportViewProps) { dateRange={dateRange} /> + + {sectionsToRender.length === 0 ? ( ) : ( @@ -216,6 +218,162 @@ function StageProgress({ currentRank }: { currentRank: number }) { ); } +/** + * Date-grouped timeline strip at the top of the report. Walks every + * date-type field in stage sections 1–5 (Stage 0 omitted intentionally), + * pulls each field's most-recent entered date, and plots events into + * five 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's date + * falls within the plotted range. + */ +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 < 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; + 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 openedTime = events.find((e) => e.isOpened)?.date + ? new Date(events.find((e) => e.isOpened)!.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; + + return ( +
+
+

+ Timeline +

+

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

+
+
    + {[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; + const bg = e.isOpened ? "bg-clay-700" : stageDotBg(rank); + const size = e.isOpened ? "h-3.5 w-3.5" : "h-2.5 w-2.5"; + const ring = e.isOpened ? "ring-2" : "ring-1"; + return ( + + ); + })} +
    +
  2. + ); + })} +
+ {/* 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 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"; + } +} + function ReportSection({ section, fields, @@ -383,26 +541,154 @@ function FieldHistoryRow({ {expanded && priorEntries.length > 0 && ( -
    - {priorEntries.map((e) => ( -
  1. - - {formatShortDate(e.date)} - - - - -
  2. - ))} -
+
+ {isNumericField(field) && ( + + )} +
    + {priorEntries.map((e) => ( +
  1. + + {formatShortDate(e.date)} + + + + +
  2. + ))} +
+
)} ); } +function isNumericField(field: FieldConfig): boolean { + return field.type === "number" || field.type === "currency" || field.type === "percent"; +} + +/** + * Tiny inline trend chart for numeric field history. Plots entries in + * chronological order as a single leaf-toned polyline with small dots + * at each measurement; the most-recent dot is emphasised. Renders nothing + * unless there are at least two finite numeric values to connect. + */ +function Sparkline({ + field, + entries, +}: { + field: FieldConfig; + entries: FieldHistoryEntry[]; +}) { + // entries arrive DESC. Reverse for chronological X-axis. + const sorted = [...entries].reverse(); + const points = sorted + .map((e) => ({ + t: new Date(e.date).getTime(), + v: typeof e.value === "number" ? e.value : Number(e.value), + })) + .filter((p) => Number.isFinite(p.t) && Number.isFinite(p.v)); + if (points.length < 2) return null; + + const values = points.map((p) => p.v); + const times = points.map((p) => p.t); + const minV = Math.min(...values); + const maxV = Math.max(...values); + const minT = Math.min(...times); + const maxT = Math.max(...times); + const vRange = maxV - minV || 1; + const tRange = maxT - minT || 1; + + const w = 240; + const h = 56; + const pad = 6; + const coords = points.map((p) => ({ + x: pad + ((p.t - minT) / tRange) * (w - pad * 2), + y: h - pad - ((p.v - minV) / vRange) * (h - pad * 2), + v: p.v, + })); + const path = coords.map((c, i) => `${i === 0 ? "M" : "L"}${c.x},${c.y}`).join(" "); + // Subtle area fill beneath the line: extend the path down to the baseline + // and close. + const area = `${path} L${coords[coords.length - 1].x},${h - pad} L${coords[0].x},${h - pad} Z`; + const last = coords[coords.length - 1]; + + return ( +
+ + + + {coords.map((c, i) => ( + + ))} + + +
+ + low{" "} + + {formatScalarText(minV, field)} + + + + high{" "} + + {formatScalarText(maxV, field)} + + +
+
+ ); +} + +function formatScalarText(value: number, field: FieldConfig): string { + switch (field.type) { + case "currency": + return currencyFmt.format(value); + case "percent": + return `${value}%`; + case "number": + return numberFmt.format(value); + default: + return String(value); + } +} + const currencyFmt = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD",