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