Report: sparkline charts on numeric history + stage-grouped date timeline
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.
This commit is contained in:
@@ -41,6 +41,10 @@ const STUB_PAYLOAD: ReportPayload = (() => {
|
|||||||
const today = new Date();
|
const today = new Date();
|
||||||
const daysAgo = (n: number) =>
|
const daysAgo = (n: number) =>
|
||||||
new Date(today.getTime() - n * 24 * 3600 * 1000).toISOString();
|
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 {
|
return {
|
||||||
orgName: "Sample Co-op (stub)",
|
orgName: "Sample Co-op (stub)",
|
||||||
currentStage: "Organizing",
|
currentStage: "Organizing",
|
||||||
@@ -69,6 +73,20 @@ const STUB_PAYLOAD: ReportPayload = (() => {
|
|||||||
{ activityId: 9012, date: daysAgo(3), value: "Strong" },
|
{ activityId: 9012, date: daysAgo(3), value: "Strong" },
|
||||||
{ activityId: 8995, date: daysAgo(95), value: "Moderate" },
|
{ 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: {
|
options: {
|
||||||
140: [{ value: "Yes", label: "Yes" }, { value: "No", label: "No" }, { value: "Considering", label: "Considering" }],
|
140: [{ value: "Yes", label: "Yes" }, { value: "No", label: "No" }, { value: "Considering", label: "Considering" }],
|
||||||
|
|||||||
+287
-1
@@ -112,6 +112,8 @@ export function ReportView({ config, cid, cs }: ReportViewProps) {
|
|||||||
dateRange={dateRange}
|
dateRange={dateRange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DateTimeline sections={config.sections} fieldHistory={data.fieldHistory} />
|
||||||
|
|
||||||
{sectionsToRender.length === 0 ? (
|
{sectionsToRender.length === 0 ? (
|
||||||
<EmptyState />
|
<EmptyState />
|
||||||
) : (
|
) : (
|
||||||
@@ -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<string, FieldHistoryEntry[]>;
|
||||||
|
}) {
|
||||||
|
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 (
|
||||||
|
<section
|
||||||
|
aria-labelledby="timeline-heading"
|
||||||
|
className="rounded-lg border border-rule bg-paper-2/30 px-6 py-5 sm:px-7 sm:py-6"
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline justify-between gap-4">
|
||||||
|
<h2
|
||||||
|
id="timeline-heading"
|
||||||
|
className="font-display text-base font-medium leading-tight text-ink"
|
||||||
|
>
|
||||||
|
Timeline
|
||||||
|
</h2>
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.12em] text-ink-mute tabular-nums">
|
||||||
|
{formatShortDate(new Date(minT).toISOString())}
|
||||||
|
{" → "}
|
||||||
|
{formatShortDate(new Date(maxT).toISOString())}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ol className="mt-4 space-y-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((rank) => {
|
||||||
|
const rowEvents = events.filter((e) => e.rank === rank);
|
||||||
|
const isEmpty = rowEvents.length === 0;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={rank}
|
||||||
|
className="grid grid-cols-[3.5rem_1fr] items-center gap-3 sm:grid-cols-[4.5rem_1fr]"
|
||||||
|
>
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.14em] font-medium text-ink-mute">
|
||||||
|
Stage {rank}
|
||||||
|
</span>
|
||||||
|
<div className="relative h-5">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={
|
||||||
|
"absolute inset-x-0 top-1/2 -translate-y-1/2 h-px " +
|
||||||
|
(isEmpty ? "bg-rule-soft/60" : "bg-rule-soft")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{todayPct !== null && (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
title="Today"
|
||||||
|
style={{ left: `${todayPct}%` }}
|
||||||
|
className="absolute top-0 bottom-0 w-px -translate-x-1/2 border-l border-dashed border-clay-300/70"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{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 (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
title={`${e.fieldLabel} — ${formatShortDate(e.date)}`}
|
||||||
|
style={{ left: `${x}%` }}
|
||||||
|
className={
|
||||||
|
"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full shadow-sm " +
|
||||||
|
`${ring} ring-paper ${size} ${bg}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
{/* Accessible event list — invisible to sighted users but readable by SR */}
|
||||||
|
<ul className="sr-only">
|
||||||
|
{events.map((e, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
Stage {e.rank}: {e.fieldLabel} — {formatShortDate(e.date)}
|
||||||
|
{e.isOpened ? " (opened)" : ""}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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({
|
function ReportSection({
|
||||||
section,
|
section,
|
||||||
fields,
|
fields,
|
||||||
@@ -383,7 +541,16 @@ function FieldHistoryRow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expanded && priorEntries.length > 0 && (
|
{expanded && priorEntries.length > 0 && (
|
||||||
<ol className="mt-3 space-y-1.5 border-l-2 border-rule-soft pl-4 sm:ml-auto sm:max-w-[24rem]">
|
<div className="mt-3 sm:ml-auto sm:max-w-[24rem]">
|
||||||
|
{isNumericField(field) && (
|
||||||
|
<Sparkline field={field} entries={entries} />
|
||||||
|
)}
|
||||||
|
<ol
|
||||||
|
className={
|
||||||
|
"space-y-1.5 border-l-2 border-rule-soft pl-4 " +
|
||||||
|
(isNumericField(field) ? "mt-3" : "")
|
||||||
|
}
|
||||||
|
>
|
||||||
{priorEntries.map((e) => (
|
{priorEntries.map((e) => (
|
||||||
<li
|
<li
|
||||||
key={e.activityId}
|
key={e.activityId}
|
||||||
@@ -398,11 +565,130 @@ function FieldHistoryRow({
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<figure className="mb-3 rounded-md border border-rule-soft bg-paper-2/40 px-3 py-2.5">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${w} ${h}`}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
role="img"
|
||||||
|
aria-label={`${field.label} trend across ${points.length} entries`}
|
||||||
|
className="block h-14 w-full text-leaf-600"
|
||||||
|
>
|
||||||
|
<path d={area} fill="currentColor" opacity="0.10" />
|
||||||
|
<path
|
||||||
|
d={path}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
{coords.map((c, i) => (
|
||||||
|
<circle
|
||||||
|
key={i}
|
||||||
|
cx={c.x}
|
||||||
|
cy={c.y}
|
||||||
|
r={i === coords.length - 1 ? 3 : 1.75}
|
||||||
|
className={i === coords.length - 1 ? "fill-leaf-800" : "fill-leaf-600"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<circle
|
||||||
|
cx={last.x}
|
||||||
|
cy={last.y}
|
||||||
|
r="5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="0.75"
|
||||||
|
opacity="0.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<figcaption className="mt-1 flex items-baseline justify-between text-[10px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
|
||||||
|
<span>
|
||||||
|
low{" "}
|
||||||
|
<span className="font-medium text-ink-soft normal-case tracking-normal">
|
||||||
|
{formatScalarText(minV, field)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
high{" "}
|
||||||
|
<span className="font-medium text-ink-soft normal-case tracking-normal">
|
||||||
|
{formatScalarText(maxV, field)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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", {
|
const currencyFmt = new Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "USD",
|
currency: "USD",
|
||||||
|
|||||||
Reference in New Issue
Block a user