Field groups: tighter style + apply to report

Form: dropped the boxed card treatment for FieldGroupCard in favor
of a leaf-tinted left rule + small uppercase mini-label. Eats only
~14px of horizontal space (border + pl-3/sm:pl-4) instead of
~32-40px for the previous bg-tinted card with px-4/sm:px-5 on both
sides, so the inner 2-col grid keeps more breathing room for the
fields themselves.

Report: same field-group concept now applies to ReportSection.
Grouped FieldHistoryRows render together inside a leaf-tinted left
rule with a small label above. Walk preserves the declared field
order — a group is emitted at its first member's position; the
other members are skipped when the loop later reaches them. Mixes
cleanly with the existing MembershipChart inline insertion and the
divide-y rhythm of standalone rows.
This commit is contained in:
Joel Brock
2026-05-21 16:51:00 -07:00
parent 8766de5ed0
commit 9bc4bbb732
2 changed files with 110 additions and 21 deletions
+97 -14
View File
@@ -1,6 +1,6 @@
"use client";
import { Fragment, useEffect, useId, useMemo, useState } from "react";
import { useEffect, useId, useMemo, useState } from "react";
import type {
FieldConfig,
FieldHistoryEntry,
@@ -1006,25 +1006,70 @@ function ReportSection({
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
field={f}
entries={history[f.name] ?? []}
options={options}
/>
{i === chartIdx && chartIdx >= 0 && (
<div className="bg-paper-2/20 px-5 py-5 sm:px-7 sm:py-6">
// Build a map from field name → its group config (if any). A
// field referenced in multiple groups belongs to the first one.
const groupByField = new Map<string, { id: string; label?: string }>();
for (const g of section.fieldGroups ?? []) {
for (const fname of g.fields) {
if (!groupByField.has(fname)) {
groupByField.set(fname, { id: g.id, label: g.label });
}
}
}
// Walk fields in declared order; whenever we hit one that
// belongs to a group we haven't emitted yet, collect every
// visible field from that group (those with history) and emit
// them as one bordered cluster. Other group members get
// skipped when we encounter them later in the loop.
const fieldsWithHistorySet = new Set(fields.map((f) => f.name));
const emittedGroups = new Set<string>();
const out: React.ReactNode[] = [];
fields.forEach((f, i) => {
const grp = groupByField.get(f.name);
if (grp && !emittedGroups.has(grp.id)) {
emittedGroups.add(grp.id);
const sectionGroup = (section.fieldGroups ?? []).find((g) => g.id === grp.id)!;
const groupedFields = sectionGroup.fields
.map((name) => fields.find((ff) => ff.name === name))
.filter((ff): ff is FieldConfig => !!ff && fieldsWithHistorySet.has(ff.name));
if (groupedFields.length === 0) return;
out.push(
<FieldHistoryGroup
key={`group-${grp.id}`}
label={grp.label}
fields={groupedFields}
history={history}
options={options}
/>,
);
} else if (!grp) {
out.push(
<FieldHistoryRow
key={f.name}
field={f}
entries={history[f.name] ?? []}
options={options}
/>,
);
}
if (i === chartIdx && chartIdx >= 0) {
out.push(
<div key={`chart-${f.name}`} 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>,
);
}
});
return out;
})()}
</div>
</div>
@@ -1032,6 +1077,44 @@ function ReportSection({
);
}
/**
* Mirrors the form's FieldGroupCard: a leaf-tinted left rule + small
* uppercase mini-label, with the grouped history rows stacked beneath
* and separated by the same divide-y as the standalone rows. Eats only
* ~14px of horizontal space (vs ~40px for a fully-boxed treatment).
*/
function FieldHistoryGroup({
label,
fields,
history,
options,
}: {
label?: string;
fields: FieldConfig[];
history: Record<string, FieldHistoryEntry[]>;
options: Record<number, SelectOption[]>;
}) {
return (
<div className="border-l-2 border-leaf-300/60">
{label && (
<p className="px-5 pt-3 pb-1 pl-7 text-[10px] uppercase tracking-[0.1em] font-medium text-ink-soft sm:px-7 sm:pl-9">
{label}
</p>
)}
<div className="divide-y divide-rule-soft">
{fields.map((f) => (
<FieldHistoryRow
key={f.name}
field={f}
entries={history[f.name] ?? []}
options={options}
/>
))}
</div>
</div>
);
}
function StageRankMark({ rank, isCurrent }: { rank: number; isCurrent: boolean }) {
return (
<span aria-hidden className="relative inline-flex h-12 w-12 flex-shrink-0 items-center justify-center">