Files
WebForm-mw/components/report/MembershipChart.tsx
T
Joel Brock 8dfbbea0ba Report: drop Stage 0 lane, flip 5→1 axis, fix Actual label clipping
DateTimeline now skips section rank 0 (intake) and renders stage lanes
top-to-bottom 5→1, matching how the framework actually numbers stages.

MembershipChart's latest-Actual label was placed at the circle's
x + 6, which overflowed the SVG when the most-recent point landed near
the right padding. Anchor it to xOf(maxT) - 4 with textAnchor="end",
mirroring the Goal label so both endpoint labels stay inside the chart.
2026-06-11 13:39:42 -07:00

390 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import type { FieldConfig, FieldHistoryEntry } from "@/types/form";
import { numberFmt } from "./FieldHistory";
import { generateAxisTicks } from "./DateTimeline";
export const MEMBERS_ACTUAL_NAME = "Members__current_";
export const MEMBERS_GOAL_NAME = "Member_Goal_for_current_Stage";
/**
* Dedicated comparison chart for the org's actual member-owner count vs the
* goal it set for the current stage. Renders when at least one of the
* 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.
*/
export function MembershipChart({
membersField,
goalField,
membersHistory,
goalHistory,
}: {
membersField?: FieldConfig;
goalField?: FieldConfig;
membersHistory?: FieldHistoryEntry[];
goalHistory?: FieldHistoryEntry[];
}) {
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(membersHistory);
const goalPoints = toPoints(goalHistory);
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;
// 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 }));
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`
: "";
// 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-owner 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. Both anchor to the chart's right
edge so the carry-forward endpoint label stays inside the viewBox
instead of overflowing past the right padding. */}
{actualCoords.length > 0 && (
<text
x={xOf(maxT) - 4}
y={actualCoords[actualCoords.length - 1].y - 6}
textAnchor="end"
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 !== "Member-Owners (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-Owner 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;
}