From 5cc60467a9e8aead1f4dd8e4739ece17d28e8286 Mon Sep 17 00:00:00 2001 From: Joel Brock Date: Fri, 5 Jun 2026 14:21:36 -0700 Subject: [PATCH] Report: extract MembershipChart to components/report/ --- components/ReportView.tsx | 389 +------------------------- components/report/MembershipChart.tsx | 386 +++++++++++++++++++++++++ 2 files changed, 392 insertions(+), 383 deletions(-) create mode 100644 components/report/MembershipChart.tsx diff --git a/components/ReportView.tsx b/components/ReportView.tsx index a3777a2..ed920c2 100644 --- a/components/ReportView.tsx +++ b/components/ReportView.tsx @@ -17,9 +17,13 @@ import { formatShortDate, computeDateRange, Chevron, - numberFmt, } from "./report/FieldHistory"; -import { DateTimeline, generateAxisTicks } from "./report/DateTimeline"; +import { DateTimeline } from "./report/DateTimeline"; +import { + MembershipChart, + MEMBERS_ACTUAL_NAME, + MEMBERS_GOAL_NAME, +} from "./report/MembershipChart"; interface ReportViewProps { config: FormConfig; @@ -227,387 +231,6 @@ function StageProgress({ currentRank }: { currentRank: number }) { ); } -const MEMBERS_ACTUAL_NAME = "Members__current_"; -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. - */ -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; -} - function ReportSection({ section, fields, diff --git a/components/report/MembershipChart.tsx b/components/report/MembershipChart.tsx new file mode 100644 index 0000000..1aae037 --- /dev/null +++ b/components/report/MembershipChart.tsx @@ -0,0 +1,386 @@ +"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; +}