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"; "use client";
import { useEffect, useId, useMemo, useState } from "react"; import { Fragment, useEffect, useId, useMemo, useState } from "react";
import type { import type {
FieldConfig, FieldConfig,
FieldHistoryEntry, FieldHistoryEntry,
@@ -114,11 +114,6 @@ export function ReportView({ config, cid, cs }: ReportViewProps) {
<DateTimeline sections={config.sections} fieldHistory={data.fieldHistory} /> <DateTimeline sections={config.sections} fieldHistory={data.fieldHistory} />
<MembershipChart
sections={config.sections}
fieldHistory={data.fieldHistory}
/>
{sectionsToRender.length === 0 ? ( {sectionsToRender.length === 0 ? (
<EmptyState /> <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 * 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 * 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 * two fields has historical data. Both series are drawn as step lines
* line with a faint area fill; the goal is drawn as a dashed clay step * (carry-forward semantics — a measurement holds until the next one
* line (each goal value is a target that holds until the next update). * updates it, then extends to the right edge). Header carries a
* Header carries a current/goal summary with the gap; legend sits below * current/goal summary with the gap; legend sits below the chart.
* the chart.
*/ */
function MembershipChart({ function MembershipChart({
sections, membersField,
fieldHistory, goalField,
membersHistory,
goalHistory,
}: { }: {
sections: StageSectionConfig[]; membersField?: FieldConfig;
fieldHistory: Record<string, FieldHistoryEntry[]>; 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) => const toPoints = (entries: FieldHistoryEntry[] | undefined) =>
(entries ?? []) (entries ?? [])
.slice() .slice()
@@ -591,8 +577,8 @@ function MembershipChart({
})) }))
.filter((p) => Number.isFinite(p.t) && Number.isFinite(p.v)); .filter((p) => Number.isFinite(p.t) && Number.isFinite(p.v));
const actualPoints = toPoints(fieldHistory[MEMBERS_ACTUAL_NAME]); const actualPoints = toPoints(membersHistory);
const goalPoints = toPoints(fieldHistory[MEMBERS_GOAL_NAME]); const goalPoints = toPoints(goalHistory);
if (actualPoints.length === 0 && goalPoints.length === 0) return null; if (actualPoints.length === 0 && goalPoints.length === 0) return null;
// Combined axes // Combined axes
@@ -630,17 +616,32 @@ function MembershipChart({
const xOf = (t: number) => padL + ((t - minT) / tRange) * plotW; const xOf = (t: number) => padL + ((t - minT) / tRange) * plotW;
const yOf = (v: number) => padT + plotH - ((v - yMin) / yRange) * plotH; 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 actualCoords = actualPoints.map((p) => ({ x: xOf(p.t), y: yOf(p.v), v: p.v }));
const actualPath = actualCoords.length let actualPath = "";
? actualCoords if (actualCoords.length === 1) {
.map((c, i) => `${i === 0 ? "M" : "L"}${c.x.toFixed(2)},${c.y.toFixed(2)}`) const c = actualCoords[0];
.join(" ") 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 // Step the goal: hold each value until the next change, then extend
// the final value to the right edge of the chart. // the final value to the right edge of the chart.
@@ -993,14 +994,38 @@ function ReportSection({
className="border-t border-rule-soft" className="border-t border-rule-soft"
> >
<div className="divide-y divide-rule-soft"> <div className="divide-y divide-rule-soft">
{fields.map((f) => ( {(() => {
<FieldHistoryRow // Compute the slot for the inline Membership chart: render it
key={f.name} // immediately after whichever of (Members current, Member Goal)
field={f} // appears last among this section's fields-with-history. If
entries={history[f.name] ?? []} // neither field is in this section, the index is -1 and the
options={options} // 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>
</div> </div>
</section> </section>
@@ -1092,154 +1117,26 @@ function FieldHistoryRow({
</div> </div>
{expanded && priorEntries.length > 0 && ( {expanded && priorEntries.length > 0 && (
<div className="mt-3 sm:ml-auto sm:max-w-[24rem]"> <ol className="mt-3 space-y-1.5 border-l-2 border-rule-soft pl-4 sm:ml-auto sm:max-w-[24rem]">
{isNumericField(field) && ( {priorEntries.map((e) => (
<Sparkline field={field} entries={entries} /> <li
)} key={e.activityId}
<ol className="flex items-baseline justify-between gap-4 text-sm"
className={ >
"space-y-1.5 border-l-2 border-rule-soft pl-4 " + <span className="text-[11px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
(isNumericField(field) ? "mt-3" : "") {formatShortDate(e.date)}
} </span>
> <span className="text-right text-ink-soft tabular-nums">
{priorEntries.map((e) => ( <FormattedValue value={e.value} field={field} options={options} />
<li </span>
key={e.activityId} </li>
className="flex items-baseline justify-between gap-4 text-sm" ))}
> </ol>
<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>
)} )}
</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",