From d88f1229aef03dfb6260dce3897a83c3deb3f969 Mon Sep 17 00:00:00 2001 From: Joel Brock Date: Tue, 19 May 2026 16:45:53 -0700 Subject: [PATCH] Report: stair-step actual line, drop sparklines, move chart into Stage 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actual member line now uses the same carry-forward step pattern as the goal line — between measurements the chart holds the prior value instead of interpolating diagonally, and the final value extends flat to the right edge. Eliminates the apparent dips that arose when diagonal interpolation crossed missing periods or low intermediate values. - Per-field Sparkline (and its isNumericField / formatScalarText helpers) removed entirely. Expanding 'earlier entries' now just shows the chronological list. Curated multi-metric charts (like the Membership chart) are the path forward for trend visualization. - Membership chart relocated from the top-of-report band into the Stage 0 ('Check-in (organizing)') section, rendered inline after whichever of Members__current_ / Member_Goal_for_current_Stage appears last in the section's fields-with-history list. Naturally scopes the chart to wherever those questions live (no double-render if config later moves them). Chart props refactored to take field + history pairs directly instead of walking the full sections array. --- components/ReportView.tsx | 275 ++++++++++++-------------------------- 1 file changed, 86 insertions(+), 189 deletions(-) diff --git a/components/ReportView.tsx b/components/ReportView.tsx index b420598..331bc11 100644 --- a/components/ReportView.tsx +++ b/components/ReportView.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useId, useMemo, useState } from "react"; +import { Fragment, useEffect, useId, useMemo, useState } from "react"; import type { FieldConfig, FieldHistoryEntry, @@ -114,11 +114,6 @@ export function ReportView({ config, cid, cs }: ReportViewProps) { - - {sectionsToRender.length === 0 ? ( ) : ( @@ -556,31 +551,22 @@ 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. + * 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({ - sections, - fieldHistory, + membersField, + goalField, + membersHistory, + goalHistory, }: { - sections: StageSectionConfig[]; - fieldHistory: Record; + membersField?: FieldConfig; + goalField?: FieldConfig; + membersHistory?: FieldHistoryEntry[]; + goalHistory?: 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() @@ -591,8 +577,8 @@ function MembershipChart({ })) .filter((p) => Number.isFinite(p.t) && Number.isFinite(p.v)); - const actualPoints = toPoints(fieldHistory[MEMBERS_ACTUAL_NAME]); - const goalPoints = toPoints(fieldHistory[MEMBERS_GOAL_NAME]); + const actualPoints = toPoints(membersHistory); + const goalPoints = toPoints(goalHistory); if (actualPoints.length === 0 && goalPoints.length === 0) return null; // Combined axes @@ -630,17 +616,32 @@ function MembershipChart({ 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. + // 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 })); - const actualPath = actualCoords.length - ? actualCoords - .map((c, i) => `${i === 0 ? "M" : "L"}${c.x.toFixed(2)},${c.y.toFixed(2)}`) - .join(" ") + 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` : ""; - 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. @@ -993,14 +994,38 @@ function ReportSection({ className="border-t border-rule-soft" >
- {fields.map((f) => ( - - ))} + {(() => { + // Compute the slot for the inline Membership chart: render it + // immediately after whichever of (Members current, Member Goal) + // appears last among this section's fields-with-history. If + // neither field is in this section, the index is -1 and the + // chart is skipped — naturally scoping the chart to whichever + // section those questions live in (Stage 0 today). + const memberIdx = fields.findIndex((f) => f.name === MEMBERS_ACTUAL_NAME); + const goalIdx = fields.findIndex((f) => f.name === MEMBERS_GOAL_NAME); + const chartIdx = Math.max(memberIdx, goalIdx); + const membersFieldInSection = memberIdx >= 0 ? fields[memberIdx] : undefined; + const goalFieldInSection = goalIdx >= 0 ? fields[goalIdx] : undefined; + return fields.map((f, i) => ( + + + {i === chartIdx && chartIdx >= 0 && ( +
+ +
+ )} +
+ )); + })()}
@@ -1092,154 +1117,26 @@ function FieldHistoryRow({ {expanded && priorEntries.length > 0 && ( -
- {isNumericField(field) && ( - - )} -
    - {priorEntries.map((e) => ( -
  1. - - {formatShortDate(e.date)} - - - - -
  2. - ))} -
-
+
    + {priorEntries.map((e) => ( +
  1. + + {formatShortDate(e.date)} + + + + +
  2. + ))} +
)} ); } -function isNumericField(field: FieldConfig): boolean { - return field.type === "number" || field.type === "currency" || field.type === "percent"; -} - -/** - * Tiny inline trend chart for numeric field history. Plots entries in - * chronological order as a single leaf-toned polyline with small dots - * at each measurement; the most-recent dot is emphasised. Renders nothing - * unless there are at least two finite numeric values to connect. - */ -function Sparkline({ - field, - entries, -}: { - field: FieldConfig; - entries: FieldHistoryEntry[]; -}) { - // entries arrive DESC. Reverse for chronological X-axis. - const sorted = [...entries].reverse(); - const points = sorted - .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)); - if (points.length < 2) return null; - - const values = points.map((p) => p.v); - const times = points.map((p) => p.t); - const minV = Math.min(...values); - const maxV = Math.max(...values); - const minT = Math.min(...times); - const maxT = Math.max(...times); - const vRange = maxV - minV || 1; - const tRange = maxT - minT || 1; - - const w = 240; - const h = 56; - const pad = 6; - const coords = points.map((p) => ({ - x: pad + ((p.t - minT) / tRange) * (w - pad * 2), - y: h - pad - ((p.v - minV) / vRange) * (h - pad * 2), - v: p.v, - })); - const path = coords.map((c, i) => `${i === 0 ? "M" : "L"}${c.x},${c.y}`).join(" "); - // Subtle area fill beneath the line: extend the path down to the baseline - // and close. - const area = `${path} L${coords[coords.length - 1].x},${h - pad} L${coords[0].x},${h - pad} Z`; - const last = coords[coords.length - 1]; - - return ( -
- - - - {coords.map((c, i) => ( - - ))} - - -
- - low{" "} - - {formatScalarText(minV, field)} - - - - high{" "} - - {formatScalarText(maxV, field)} - - -
-
- ); -} - -function formatScalarText(value: number, field: FieldConfig): string { - switch (field.type) { - case "currency": - return currencyFmt.format(value); - case "percent": - return `${value}%`; - case "number": - return numberFmt.format(value); - default: - return String(value); - } -} - const currencyFmt = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD",