"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 (

Membership — goal vs. actual

Member-owner count tracked over time against the goal set for the org's current stage.

{latestActual !== null && (

{numberFmt.format(latestActual)} {latestGoal !== null && ( <> {" of "} {numberFmt.format(latestGoal)} {gapText && ( · {gapText} )} )}

)}
{/* Y-axis gridlines + value labels */} {yTicks.map((v, i) => { const y = yOf(v); return ( {numberFmt.format(v)} ); })} {/* Today guide */} {todayInRange && ( )} {/* Goal step line */} {goalPath && ( )} {goalCoords.map((c, i) => ( ))} {/* Actual line with faint area fill */} {actualArea && ( )} {actualPath && ( )} {actualCoords.map((c, i) => ( ))} {/* Most-recent point value labels */} {actualCoords.length > 0 && ( {numberFmt.format(actualCoords[actualCoords.length - 1].v)} )} {goalCoords.length > 0 && ( {numberFmt.format(goalCoords[goalCoords.length - 1].v)} )} {/* X-axis baseline + ticks */} {xTicks.map((tk, i) => { const x = xOf(tk.t); return ( {tk.label} ); })} {/* Legend */}
); } /** * Choose 3–5 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; }