Report: stair-step actual line, drop sparklines, move chart into Stage 0

- Actual member line now uses the same carry-forward step pattern as
  the goal line — between measurements the chart holds the prior value
  instead of interpolating diagonally, and the final value extends flat
  to the right edge. Eliminates the apparent dips that arose when
  diagonal interpolation crossed missing periods or low intermediate
  values.
- Per-field Sparkline (and its isNumericField / formatScalarText
  helpers) removed entirely. Expanding 'earlier entries' now just shows
  the chronological list. Curated multi-metric charts (like the
  Membership chart) are the path forward for trend visualization.
- Membership chart relocated from the top-of-report band into the
  Stage 0 ('Check-in (organizing)') section, rendered inline after
  whichever of Members__current_ / Member_Goal_for_current_Stage
  appears last in the section's fields-with-history list. Naturally
  scopes the chart to wherever those questions live (no double-render
  if config later moves them). Chart props refactored to take field
  + history pairs directly instead of walking the full sections array.
This commit is contained in:
Joel Brock
2026-05-19 16:45:53 -07:00
parent ae4e47025c
commit d88f1229ae
+86 -189
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useId, useMemo, useState } from "react";
import { Fragment, useEffect, useId, useMemo, useState } from "react";
import type {
FieldConfig,
FieldHistoryEntry,
@@ -114,11 +114,6 @@ export function ReportView({ config, cid, cs }: ReportViewProps) {
<DateTimeline sections={config.sections} fieldHistory={data.fieldHistory} />
<MembershipChart
sections={config.sections}
fieldHistory={data.fieldHistory}
/>
{sectionsToRender.length === 0 ? (
<EmptyState />
) : (
@@ -556,31 +551,22 @@ const MEMBERS_GOAL_NAME = "Member_Goal_for_current_Stage";
/**
* Dedicated comparison chart for the org's actual member count vs the
* goal it set for the current stage. Renders when at least one of the
* two fields has historical data. Actual is drawn as a smooth leaf-toned
* line with a faint area fill; the goal is drawn as a dashed clay step
* line (each goal value is a target that holds until the next update).
* Header carries a current/goal summary with the gap; legend sits below
* the chart.
* two fields has historical data. Both series are drawn as step lines
* (carry-forward semantics — a measurement holds until the next one
* updates it, then extends to the right edge). Header carries a
* current/goal summary with the gap; legend sits below the chart.
*/
function MembershipChart({
sections,
fieldHistory,
membersField,
goalField,
membersHistory,
goalHistory,
}: {
sections: StageSectionConfig[];
fieldHistory: Record<string, FieldHistoryEntry[]>;
membersField?: FieldConfig;
goalField?: FieldConfig;
membersHistory?: FieldHistoryEntry[];
goalHistory?: FieldHistoryEntry[];
}) {
// Find the two field configs by name across all sections (both are
// typically in Stage 0 today, but we look broadly so reorganizations
// don't break the chart).
let membersField: FieldConfig | undefined;
let goalField: FieldConfig | undefined;
for (const s of sections) {
for (const f of s.fields) {
if (f.name === MEMBERS_ACTUAL_NAME) membersField = f;
if (f.name === MEMBERS_GOAL_NAME) goalField = f;
}
}
const toPoints = (entries: FieldHistoryEntry[] | undefined) =>
(entries ?? [])
.slice()
@@ -591,8 +577,8 @@ function MembershipChart({
}))
.filter((p) => Number.isFinite(p.t) && Number.isFinite(p.v));
const actualPoints = toPoints(fieldHistory[MEMBERS_ACTUAL_NAME]);
const goalPoints = toPoints(fieldHistory[MEMBERS_GOAL_NAME]);
const actualPoints = toPoints(membersHistory);
const goalPoints = toPoints(goalHistory);
if (actualPoints.length === 0 && goalPoints.length === 0) return null;
// Combined axes
@@ -630,17 +616,32 @@ function MembershipChart({
const xOf = (t: number) => padL + ((t - minT) / tRange) * plotW;
const yOf = (v: number) => padT + plotH - ((v - yMin) / yRange) * plotH;
// Smooth path for Actual; step path for Goal.
// Both Actual and Goal use a carry-forward step line: between measurements
// the chart holds the prior value rather than interpolating diagonally,
// and the final value extends flat to the right edge of the chart. This
// way periods with no fresh measurement read as "unchanged since last
// reported" instead of suggesting a smooth dip or rise that we don't
// actually have evidence for.
const actualCoords = actualPoints.map((p) => ({ x: xOf(p.t), y: yOf(p.v), v: p.v }));
const actualPath = actualCoords.length
? actualCoords
.map((c, i) => `${i === 0 ? "M" : "L"}${c.x.toFixed(2)},${c.y.toFixed(2)}`)
.join(" ")
let actualPath = "";
if (actualCoords.length === 1) {
const c = actualCoords[0];
actualPath = `M${c.x.toFixed(2)},${c.y.toFixed(2)} L${(W - padR).toFixed(2)},${c.y.toFixed(2)}`;
} else if (actualCoords.length > 1) {
const parts: string[] = [
`M${actualCoords[0].x.toFixed(2)},${actualCoords[0].y.toFixed(2)}`,
];
for (let i = 1; i < actualCoords.length; i++) {
parts.push(`L${actualCoords[i].x.toFixed(2)},${actualCoords[i - 1].y.toFixed(2)}`);
parts.push(`L${actualCoords[i].x.toFixed(2)},${actualCoords[i].y.toFixed(2)}`);
}
const last = actualCoords[actualCoords.length - 1];
parts.push(`L${(W - padR).toFixed(2)},${last.y.toFixed(2)}`);
actualPath = parts.join(" ");
}
const actualArea = actualPath
? `${actualPath} L${(W - padR).toFixed(2)},${yOf(yMin).toFixed(2)} L${actualCoords[0].x.toFixed(2)},${yOf(yMin).toFixed(2)} Z`
: "";
const actualArea =
actualCoords.length >= 2
? `${actualPath} L${actualCoords[actualCoords.length - 1].x.toFixed(2)},${yOf(yMin).toFixed(2)} L${actualCoords[0].x.toFixed(2)},${yOf(yMin).toFixed(2)} Z`
: "";
// Step the goal: hold each value until the next change, then extend
// the final value to the right edge of the chart.
@@ -993,14 +994,38 @@ function ReportSection({
className="border-t border-rule-soft"
>
<div className="divide-y divide-rule-soft">
{fields.map((f) => (
<FieldHistoryRow
key={f.name}
field={f}
entries={history[f.name] ?? []}
options={options}
/>
))}
{(() => {
// Compute the slot for the inline Membership chart: render it
// immediately after whichever of (Members current, Member Goal)
// appears last among this section's fields-with-history. If
// neither field is in this section, the index is -1 and the
// chart is skipped — naturally scoping the chart to whichever
// section those questions live in (Stage 0 today).
const memberIdx = fields.findIndex((f) => f.name === MEMBERS_ACTUAL_NAME);
const goalIdx = fields.findIndex((f) => f.name === MEMBERS_GOAL_NAME);
const chartIdx = Math.max(memberIdx, goalIdx);
const membersFieldInSection = memberIdx >= 0 ? fields[memberIdx] : undefined;
const goalFieldInSection = goalIdx >= 0 ? fields[goalIdx] : undefined;
return fields.map((f, i) => (
<Fragment key={f.name}>
<FieldHistoryRow
field={f}
entries={history[f.name] ?? []}
options={options}
/>
{i === chartIdx && chartIdx >= 0 && (
<div className="bg-paper-2/20 px-5 py-5 sm:px-7 sm:py-6">
<MembershipChart
membersField={membersFieldInSection}
goalField={goalFieldInSection}
membersHistory={history[MEMBERS_ACTUAL_NAME]}
goalHistory={history[MEMBERS_GOAL_NAME]}
/>
</div>
)}
</Fragment>
));
})()}
</div>
</div>
</section>
@@ -1092,154 +1117,26 @@ function FieldHistoryRow({
</div>
{expanded && priorEntries.length > 0 && (
<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) => (
<li
key={e.activityId}
className="flex items-baseline justify-between gap-4 text-sm"
>
<span className="text-[11px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
{formatShortDate(e.date)}
</span>
<span className="text-right text-ink-soft tabular-nums">
<FormattedValue value={e.value} field={field} options={options} />
</span>
</li>
))}
</ol>
</div>
<ol className="mt-3 space-y-1.5 border-l-2 border-rule-soft pl-4 sm:ml-auto sm:max-w-[24rem]">
{priorEntries.map((e) => (
<li
key={e.activityId}
className="flex items-baseline justify-between gap-4 text-sm"
>
<span className="text-[11px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
{formatShortDate(e.date)}
</span>
<span className="text-right text-ink-soft tabular-nums">
<FormattedValue value={e.value} field={field} options={options} />
</span>
</li>
))}
</ol>
)}
</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", {
style: "currency",
currency: "USD",