Report: stair-step actual line, drop sparklines, move chart into Stage 0
- 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.
This commit is contained in:
+67
-170
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useId, useMemo, useState } from "react";
|
import { Fragment, useEffect, useId, useMemo, useState } from "react";
|
||||||
import type {
|
import type {
|
||||||
FieldConfig,
|
FieldConfig,
|
||||||
FieldHistoryEntry,
|
FieldHistoryEntry,
|
||||||
@@ -114,11 +114,6 @@ export function ReportView({ config, cid, cs }: ReportViewProps) {
|
|||||||
|
|
||||||
<DateTimeline sections={config.sections} fieldHistory={data.fieldHistory} />
|
<DateTimeline sections={config.sections} fieldHistory={data.fieldHistory} />
|
||||||
|
|
||||||
<MembershipChart
|
|
||||||
sections={config.sections}
|
|
||||||
fieldHistory={data.fieldHistory}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{sectionsToRender.length === 0 ? (
|
{sectionsToRender.length === 0 ? (
|
||||||
<EmptyState />
|
<EmptyState />
|
||||||
) : (
|
) : (
|
||||||
@@ -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
|
* 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
|
* 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
|
* two fields has historical data. Both series are drawn as step lines
|
||||||
* line with a faint area fill; the goal is drawn as a dashed clay step
|
* (carry-forward semantics — a measurement holds until the next one
|
||||||
* line (each goal value is a target that holds until the next update).
|
* updates it, then extends to the right edge). Header carries a
|
||||||
* Header carries a current/goal summary with the gap; legend sits below
|
* current/goal summary with the gap; legend sits below the chart.
|
||||||
* the chart.
|
|
||||||
*/
|
*/
|
||||||
function MembershipChart({
|
function MembershipChart({
|
||||||
sections,
|
membersField,
|
||||||
fieldHistory,
|
goalField,
|
||||||
|
membersHistory,
|
||||||
|
goalHistory,
|
||||||
}: {
|
}: {
|
||||||
sections: StageSectionConfig[];
|
membersField?: FieldConfig;
|
||||||
fieldHistory: Record<string, FieldHistoryEntry[]>;
|
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) =>
|
const toPoints = (entries: FieldHistoryEntry[] | undefined) =>
|
||||||
(entries ?? [])
|
(entries ?? [])
|
||||||
.slice()
|
.slice()
|
||||||
@@ -591,8 +577,8 @@ function MembershipChart({
|
|||||||
}))
|
}))
|
||||||
.filter((p) => Number.isFinite(p.t) && Number.isFinite(p.v));
|
.filter((p) => Number.isFinite(p.t) && Number.isFinite(p.v));
|
||||||
|
|
||||||
const actualPoints = toPoints(fieldHistory[MEMBERS_ACTUAL_NAME]);
|
const actualPoints = toPoints(membersHistory);
|
||||||
const goalPoints = toPoints(fieldHistory[MEMBERS_GOAL_NAME]);
|
const goalPoints = toPoints(goalHistory);
|
||||||
if (actualPoints.length === 0 && goalPoints.length === 0) return null;
|
if (actualPoints.length === 0 && goalPoints.length === 0) return null;
|
||||||
|
|
||||||
// Combined axes
|
// Combined axes
|
||||||
@@ -630,16 +616,31 @@ function MembershipChart({
|
|||||||
const xOf = (t: number) => padL + ((t - minT) / tRange) * plotW;
|
const xOf = (t: number) => padL + ((t - minT) / tRange) * plotW;
|
||||||
const yOf = (v: number) => padT + plotH - ((v - yMin) / yRange) * plotH;
|
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 actualCoords = actualPoints.map((p) => ({ x: xOf(p.t), y: yOf(p.v), v: p.v }));
|
||||||
const actualPath = actualCoords.length
|
let actualPath = "";
|
||||||
? actualCoords
|
if (actualCoords.length === 1) {
|
||||||
.map((c, i) => `${i === 0 ? "M" : "L"}${c.x.toFixed(2)},${c.y.toFixed(2)}`)
|
const c = actualCoords[0];
|
||||||
.join(" ")
|
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 actualArea =
|
const parts: string[] = [
|
||||||
actualCoords.length >= 2
|
`M${actualCoords[0].x.toFixed(2)},${actualCoords[0].y.toFixed(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`
|
];
|
||||||
|
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
|
// Step the goal: hold each value until the next change, then extend
|
||||||
@@ -993,14 +994,38 @@ function ReportSection({
|
|||||||
className="border-t border-rule-soft"
|
className="border-t border-rule-soft"
|
||||||
>
|
>
|
||||||
<div className="divide-y divide-rule-soft">
|
<div className="divide-y divide-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) => (
|
||||||
|
<Fragment key={f.name}>
|
||||||
<FieldHistoryRow
|
<FieldHistoryRow
|
||||||
key={f.name}
|
|
||||||
field={f}
|
field={f}
|
||||||
entries={history[f.name] ?? []}
|
entries={history[f.name] ?? []}
|
||||||
options={options}
|
options={options}
|
||||||
/>
|
/>
|
||||||
))}
|
{i === chartIdx && chartIdx >= 0 && (
|
||||||
|
<div className="bg-paper-2/20 px-5 py-5 sm:px-7 sm:py-6">
|
||||||
|
<MembershipChart
|
||||||
|
membersField={membersFieldInSection}
|
||||||
|
goalField={goalFieldInSection}
|
||||||
|
membersHistory={history[MEMBERS_ACTUAL_NAME]}
|
||||||
|
goalHistory={history[MEMBERS_GOAL_NAME]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1092,16 +1117,7 @@ function FieldHistoryRow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expanded && priorEntries.length > 0 && (
|
{expanded && priorEntries.length > 0 && (
|
||||||
<div className="mt-3 sm:ml-auto sm:max-w-[24rem]">
|
<ol className="mt-3 space-y-1.5 border-l-2 border-rule-soft pl-4 sm:ml-auto sm:max-w-[24rem]">
|
||||||
{isNumericField(field) && (
|
|
||||||
<Sparkline field={field} entries={entries} />
|
|
||||||
)}
|
|
||||||
<ol
|
|
||||||
className={
|
|
||||||
"space-y-1.5 border-l-2 border-rule-soft pl-4 " +
|
|
||||||
(isNumericField(field) ? "mt-3" : "")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{priorEntries.map((e) => (
|
{priorEntries.map((e) => (
|
||||||
<li
|
<li
|
||||||
key={e.activityId}
|
key={e.activityId}
|
||||||
@@ -1116,130 +1132,11 @@ function FieldHistoryRow({
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
|
||||||
<figure className="mb-3 rounded-md border border-rule-soft bg-paper-2/40 px-3 py-2.5">
|
|
||||||
<svg
|
|
||||||
viewBox={`0 0 ${w} ${h}`}
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
role="img"
|
|
||||||
aria-label={`${field.label} trend across ${points.length} entries`}
|
|
||||||
className="block h-14 w-full text-leaf-600"
|
|
||||||
>
|
|
||||||
<path d={area} fill="currentColor" opacity="0.10" />
|
|
||||||
<path
|
|
||||||
d={path}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
{coords.map((c, i) => (
|
|
||||||
<circle
|
|
||||||
key={i}
|
|
||||||
cx={c.x}
|
|
||||||
cy={c.y}
|
|
||||||
r={i === coords.length - 1 ? 3 : 1.75}
|
|
||||||
className={i === coords.length - 1 ? "fill-leaf-800" : "fill-leaf-600"}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<circle
|
|
||||||
cx={last.x}
|
|
||||||
cy={last.y}
|
|
||||||
r="5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="0.75"
|
|
||||||
opacity="0.5"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<figcaption className="mt-1 flex items-baseline justify-between text-[10px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
|
|
||||||
<span>
|
|
||||||
low{" "}
|
|
||||||
<span className="font-medium text-ink-soft normal-case tracking-normal">
|
|
||||||
{formatScalarText(minV, field)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
high{" "}
|
|
||||||
<span className="font-medium text-ink-soft normal-case tracking-normal">
|
|
||||||
{formatScalarText(maxV, field)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</figcaption>
|
|
||||||
</figure>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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", {
|
const currencyFmt = new Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "USD",
|
currency: "USD",
|
||||||
|
|||||||
Reference in New Issue
Block a user