Report: dedicated Membership chart (goal vs actual over time)

New MembershipChart card sits between the DateTimeline and the section
accordions, rendering whenever Members__current_ or
Member_Goal_for_current_Stage has any historical data.

- Actual member count: smooth leaf-700 polyline with a faint leaf-500
  area fill underneath, dots at every measurement, an emphasized dot
  on the most recent point with the value labeled inline.
- Goal: dashed clay-600 step line — each goal value is treated as a
  target that holds until the next update, then extends flat to the
  right edge of the chart. Dots at each update; the most-recent goal
  value labeled at the right.
- Y-axis: niceYTicks picks 3–5 round-number ticks (snapped to
  1/2/2.5/5/10 × 10^N) spanning [min(0, dataMin), dataMax]; faint
  gridlines + tabular-num labels on the left. Anchoring at 0 keeps
  growth-from-small-base readable.
- X-axis: reuses generateAxisTicks for adaptive month/year stepping,
  matching the timeline above. Today gets a dashed clay vertical
  guide when in range.
- Header: title + subtitle + an inline 'NNN of MMM target · X to go'
  callout in tabular-nums, color-coded (clay-700 if behind goal,
  leaf-700 if above).
- Legend at the bottom with line+dot chips for both series.
This commit is contained in:
Joel Brock
2026-05-19 16:37:01 -07:00
parent 00e10a992f
commit ae4e47025c
+380
View File
@@ -114,6 +114,11 @@ 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 />
) : ( ) : (
@@ -545,6 +550,381 @@ function generateAxisTicks(minT: number, maxT: number): Array<{ t: number; label
return ticks; return ticks;
} }
const MEMBERS_ACTUAL_NAME = "Members__current_";
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.
*/
function MembershipChart({
sections,
fieldHistory,
}: {
sections: StageSectionConfig[];
fieldHistory: Record<string, 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()
.reverse()
.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));
const actualPoints = toPoints(fieldHistory[MEMBERS_ACTUAL_NAME]);
const goalPoints = toPoints(fieldHistory[MEMBERS_GOAL_NAME]);
if (actualPoints.length === 0 && goalPoints.length === 0) return null;
// Combined axes
const all = [...actualPoints, ...goalPoints];
const times = all.map((p) => p.t);
const values = all.map((p) => p.v);
let minT = Math.min(...times);
let maxT = Math.max(...times);
if (minT === maxT) {
// Single-point chart — pad the axis ±15 days so the dot isn't on
// top of the y-axis line.
const pad = 15 * 24 * 3600 * 1000;
minT -= pad;
maxT += pad;
}
// Always include 0 in y so growth from a small starting count reads true.
const rawMin = Math.min(...values, 0);
const rawMax = Math.max(...values);
const yTicks = niceYTicks(rawMin, rawMax, 4);
const yMin = yTicks[0];
const yMax = yTicks[yTicks.length - 1];
const yRange = yMax - yMin || 1;
const tRange = maxT - minT || 1;
// Chart geometry (viewBox units).
const W = 580;
const H = 200;
const padL = 40;
const padR = 28;
const padT = 14;
const padB = 30;
const plotW = W - padL - padR;
const plotH = H - padT - padB;
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.
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(" ")
: "";
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.
const goalCoords = goalPoints.map((p) => ({ x: xOf(p.t), y: yOf(p.v), v: p.v }));
let goalPath = "";
if (goalCoords.length === 1) {
const c = goalCoords[0];
goalPath = `M${xOf(minT).toFixed(2)},${c.y.toFixed(2)} L${xOf(maxT).toFixed(2)},${c.y.toFixed(2)}`;
} else if (goalCoords.length > 1) {
const parts: string[] = [];
parts.push(`M${goalCoords[0].x.toFixed(2)},${goalCoords[0].y.toFixed(2)}`);
for (let i = 1; i < goalCoords.length; i++) {
// step: horizontal to next x at previous y, then vertical to new y
parts.push(`L${goalCoords[i].x.toFixed(2)},${goalCoords[i - 1].y.toFixed(2)}`);
parts.push(`L${goalCoords[i].x.toFixed(2)},${goalCoords[i].y.toFixed(2)}`);
}
// Extend the most-recent goal to the right edge as a flat line.
const lastG = goalCoords[goalCoords.length - 1];
parts.push(`L${xOf(maxT).toFixed(2)},${lastG.y.toFixed(2)}`);
goalPath = parts.join(" ");
}
// Summary stat: take most-recent of each series for the gap callout.
const latestActual = actualPoints.length ? actualPoints[actualPoints.length - 1].v : null;
const latestGoal = goalPoints.length ? goalPoints[goalPoints.length - 1].v : null;
const gap =
latestActual !== null && latestGoal !== null ? latestGoal - latestActual : null;
const gapText =
gap === null
? null
: gap > 0
? `${numberFmt.format(gap)} to go`
: gap < 0
? `${numberFmt.format(-gap)} above goal`
: "at goal";
const gapTone =
gap === null
? ""
: gap > 0
? "text-clay-700"
: gap < 0
? "text-leaf-700"
: "text-leaf-700";
const xTicks = generateAxisTicks(minT, maxT);
const todayT = Date.now();
const todayInRange = todayT >= minT && todayT <= maxT;
return (
<section
aria-labelledby="membership-chart-heading"
className="rounded-lg border border-rule bg-paper-2/30 px-6 py-5 sm:px-7 sm:py-6"
>
<div className="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:justify-between sm:gap-4">
<div>
<h2
id="membership-chart-heading"
className="font-display text-base font-medium leading-tight text-ink"
>
Membership goal vs. actual
</h2>
<p className="mt-0.5 text-xs text-ink-mute">
Member count tracked over time against the goal set for the org&apos;s
current stage.
</p>
</div>
{latestActual !== null && (
<p className="text-[11px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
<span className="font-medium text-leaf-800">
{numberFmt.format(latestActual)}
</span>
{latestGoal !== null && (
<>
{" of "}
<span className="font-medium text-clay-700">
{numberFmt.format(latestGoal)}
</span>
{gapText && (
<span className={"ml-1.5 normal-case tracking-normal " + gapTone}>
· {gapText}
</span>
)}
</>
)}
</p>
)}
</div>
<svg
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
role="img"
aria-label="Membership goal versus actual over time"
className="mt-4 block h-48 w-full sm:h-52"
>
{/* Y-axis gridlines + value labels */}
{yTicks.map((v, i) => {
const y = yOf(v);
return (
<g key={i}>
<line
x1={padL}
x2={W - padR}
y1={y}
y2={y}
className="stroke-rule-soft"
strokeWidth="0.5"
/>
<text
x={padL - 6}
y={y}
textAnchor="end"
dominantBaseline="middle"
className="fill-ink-mute text-[9px] tabular-nums"
>
{numberFmt.format(v)}
</text>
</g>
);
})}
{/* Today guide */}
{todayInRange && (
<line
x1={xOf(todayT)}
x2={xOf(todayT)}
y1={padT}
y2={padT + plotH}
className="stroke-clay-300"
strokeWidth="0.75"
strokeDasharray="2 3"
/>
)}
{/* Goal step line */}
{goalPath && (
<path
d={goalPath}
fill="none"
className="stroke-clay-600"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="miter"
strokeDasharray="5 3"
/>
)}
{goalCoords.map((c, i) => (
<circle
key={`g-${i}`}
cx={c.x}
cy={c.y}
r="2.5"
className="fill-clay-600"
/>
))}
{/* Actual line with faint area fill */}
{actualArea && (
<path d={actualArea} className="fill-leaf-500" opacity="0.10" />
)}
{actualPath && (
<path
d={actualPath}
fill="none"
className="stroke-leaf-700"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
{actualCoords.map((c, i) => (
<circle
key={`a-${i}`}
cx={c.x}
cy={c.y}
r={i === actualCoords.length - 1 ? 3.5 : 2}
className={i === actualCoords.length - 1 ? "fill-leaf-800" : "fill-leaf-700"}
/>
))}
{/* Most-recent point value labels */}
{actualCoords.length > 0 && (
<text
x={actualCoords[actualCoords.length - 1].x + 6}
y={actualCoords[actualCoords.length - 1].y - 6}
className="fill-leaf-800 text-[10px] font-medium tabular-nums"
>
{numberFmt.format(actualCoords[actualCoords.length - 1].v)}
</text>
)}
{goalCoords.length > 0 && (
<text
x={xOf(maxT) - 4}
y={goalCoords[goalCoords.length - 1].y - 6}
textAnchor="end"
className="fill-clay-700 text-[10px] font-medium tabular-nums"
>
{numberFmt.format(goalCoords[goalCoords.length - 1].v)}
</text>
)}
{/* X-axis baseline + ticks */}
<line
x1={padL}
x2={W - padR}
y1={padT + plotH}
y2={padT + plotH}
className="stroke-rule"
strokeWidth="0.75"
/>
{xTicks.map((tk, i) => {
const x = xOf(tk.t);
return (
<g key={i}>
<line
x1={x}
x2={x}
y1={padT + plotH}
y2={padT + plotH + 3}
className="stroke-rule"
strokeWidth="0.75"
/>
<text
x={x}
y={padT + plotH + 14}
textAnchor="middle"
className="fill-ink-mute text-[9px] uppercase tracking-[0.08em] tabular-nums"
>
{tk.label}
</text>
</g>
);
})}
</svg>
{/* Legend */}
<ul className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-1.5 text-[11px] text-ink-soft">
<li className="inline-flex items-center gap-2">
<span aria-hidden className="block h-px w-6 bg-leaf-700">
<span className="relative -top-[3px] left-1/2 -translate-x-1/2 inline-block h-1.5 w-1.5 rounded-full bg-leaf-700" />
</span>
<span>
Actual{membersField?.label && membersField.label !== "Members (current)" ? ` (${membersField.label})` : ""}
</span>
</li>
<li className="inline-flex items-center gap-2">
<span aria-hidden className="block h-px w-6 border-t border-dashed border-clay-600">
<span className="relative -top-[3px] left-1/2 -translate-x-1/2 inline-block h-1.5 w-1.5 rounded-full bg-clay-600" />
</span>
<span>
Goal{goalField?.label && goalField.label !== "Member Goal for current Stage" ? ` (${goalField.label})` : ""}
</span>
</li>
</ul>
</section>
);
}
/**
* Choose 35 round-number tick values that span [min, max]. Step is snapped
* to 1 / 2 / 2.5 / 5 / 10 × 10^N so labels read as Y-axis values normally do.
*/
function niceYTicks(min: number, max: number, target = 4): number[] {
if (!Number.isFinite(min) || !Number.isFinite(max)) return [0];
if (min === max) return [min - 1, min, min + 1];
const range = max - min;
const rawStep = range / target;
const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
const normalized = rawStep / magnitude;
let step: number;
if (normalized < 1.5) step = 1 * magnitude;
else if (normalized < 3) step = 2 * magnitude;
else if (normalized < 4) step = 2.5 * magnitude;
else if (normalized < 7) step = 5 * magnitude;
else step = 10 * magnitude;
const niceMin = Math.floor(min / step) * step;
const niceMax = Math.ceil(max / step) * step;
const ticks: number[] = [];
for (let v = niceMin; v <= niceMax + step * 0.0001; v += step) {
ticks.push(Math.round(v * 1e6) / 1e6); // de-jitter float arithmetic
}
return ticks;
}
function ReportSection({ function ReportSection({
section, section,
fields, fields,