From ae4e47025c8674adcd37cafb4fe575bc843bf29d Mon Sep 17 00:00:00 2001 From: Joel Brock Date: Tue, 19 May 2026 16:37:01 -0700 Subject: [PATCH] Report: dedicated Membership chart (goal vs actual over time) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- components/ReportView.tsx | 380 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 380 insertions(+) diff --git a/components/ReportView.tsx b/components/ReportView.tsx index 7819655..b420598 100644 --- a/components/ReportView.tsx +++ b/components/ReportView.tsx @@ -114,6 +114,11 @@ export function ReportView({ config, cid, cs }: ReportViewProps) { + + {sectionsToRender.length === 0 ? ( ) : ( @@ -545,6 +550,381 @@ function generateAxisTicks(minT: number, maxT: number): Array<{ t: number; label 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; +}) { + // 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 ( +
+
+
+

+ Membership — goal vs. actual +

+

+ Member 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 */} +
    +
  • + + + + + Actual{membersField?.label && membersField.label !== "Members (current)" ? ` (${membersField.label})` : ""} + +
  • +
  • + + + + + Goal{goalField?.label && goalField.label !== "Member Goal for current Stage" ? ` (${goalField.label})` : ""} + +
  • +
+
+ ); +} + +/** + * 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,