feat/polish(reporting): compare-column picker + consistency fixes

- Compare view: columns are no longer frozen to the first 4 scenarios.
  Add/remove columns (up to 4) via an 'Add scenario' popover and a per-column
  remove button — the compare page's core action now works. (Critique P1.)
- Money formatting: deleted the local moneyShort, routed the allocation bar
  through the shared moneyCompact so '£2.48m' and the ledger's full figures
  come from one rule. (Critique P2.)
- Drill shelf scrolls into view on open (in the handler, not an effect;
  reduced-motion aware) so a click below the fold isn't inert.

tsc clean, 446 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-08 11:41:56 +00:00
parent 178b63e7c5
commit c638e59f20
4 changed files with 106 additions and 21 deletions

View file

@ -97,6 +97,19 @@ export function ReportingClientArea({
function openDrill(t: DrillTarget) {
setDrill(t);
setDrillPage(1);
// Bring the shelf into view so a click below the fold doesn't look inert.
// Done in the handler (not an effect) per project convention.
if (typeof window !== "undefined") {
const reduce = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
requestAnimationFrame(() =>
document.getElementById("reporting-drill-shelf")?.scrollIntoView({
behavior: reduce ? "auto" : "smooth",
block: "nearest",
}),
);
}
}
// ── KPI band ───────────────────────────────────────────────────────────

View file

@ -2,6 +2,12 @@
import { useState } from "react";
import { useQueries } from "@tanstack/react-query";
import { X } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/app/shadcn_components/ui/popover";
import { sapToEpc } from "@/app/utils";
import { pickBestIndex, countBelowBand } from "@/lib/reporting/model";
import { EpcChip, moneyFull } from "../components/primitives";
@ -138,9 +144,24 @@ export function CompareClientArea({
scenarios: ScenarioMeta[];
baseline: Baseline;
}) {
// Up to four columns stay readable at desk widths.
const [selected] = useState(() => scenarios.slice(0, 4).map((s) => s.id));
const columns = scenarios.filter((s) => selected.includes(s.id));
// Up to four columns stay readable at desk widths; the user picks which.
const MAX_COLUMNS = 4;
const [selected, setSelected] = useState(() =>
scenarios.slice(0, MAX_COLUMNS).map((s) => s.id),
);
// Column order follows selection order, not the scenario list order.
const columns = selected
.map((id) => scenarios.find((s) => s.id === id))
.filter((s): s is ScenarioMeta => Boolean(s));
const available = scenarios.filter((s) => !selected.includes(s.id));
const canAdd = selected.length < MAX_COLUMNS && available.length > 0;
const addColumn = (id: number) =>
setSelected((ids) =>
ids.length < MAX_COLUMNS && !ids.includes(id) ? [...ids, id] : ids,
);
const removeColumn = (id: number) =>
setSelected((ids) => (ids.length > 1 ? ids.filter((x) => x !== id) : ids));
const results = useQueries({
queries: columns.map((s) => ({
@ -171,12 +192,54 @@ export function CompareClientArea({
Compare scenarios
</h1>
</div>
<a
href={`/portfolio/${portfolioId}/reporting`}
className="inline-flex h-8 items-center rounded-lg border border-gray-200 bg-white px-3.5 text-sm font-medium"
>
Back to reporting
</a>
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={!canAdd}
aria-haspopup="listbox"
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3.5 text-sm font-medium text-brandblue disabled:cursor-not-allowed disabled:text-gray-400"
>
+ Add scenario
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72 p-0">
<div role="listbox" aria-label="Add a scenario to compare" className="max-h-80 overflow-y-auto">
{available.map((s) => (
<button
key={s.id}
type="button"
role="option"
aria-selected={false}
onClick={() => addColumn(s.id)}
className="flex w-full flex-col border-t border-gray-100 px-3.5 py-2.5 text-left first:border-t-0 hover:bg-gray-50"
>
<span className="text-sm font-semibold text-brandblue">
{s.name}
</span>
<span className="text-[0.7rem] text-gray-600">
{s.goal === "Increasing EPC" && s.goalValue
? `Target EPC ${s.goalValue}`
: s.goal}
</span>
</button>
))}
{available.length === 0 && (
<p className="px-3.5 py-3 text-[0.78rem] text-gray-600">
All scenarios are already shown.
</p>
)}
</div>
</PopoverContent>
</Popover>
<a
href={`/portfolio/${portfolioId}/reporting`}
className="inline-flex h-8 items-center rounded-lg border border-gray-200 bg-white px-3.5 text-sm font-medium"
>
Back to reporting
</a>
</div>
</div>
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white">
@ -194,8 +257,20 @@ export function CompareClientArea({
</th>
{columns.map((s) => (
<th key={s.id} className="px-4 py-4 text-right align-bottom">
<span className="text-[0.86rem] font-semibold text-brandblue">
{s.name}
<span className="flex items-start justify-end gap-1.5">
<span className="text-[0.86rem] font-semibold text-brandblue">
{s.name}
</span>
{columns.length > 1 && (
<button
type="button"
onClick={() => removeColumn(s.id)}
aria-label={`Remove ${s.name} from comparison`}
className="mt-0.5 text-gray-400 hover:text-gray-700"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</span>
<span className="block text-[0.68rem] font-normal text-gray-600">
{s.goal === "Increasing EPC" && s.goalValue

View file

@ -93,7 +93,10 @@ export function DrillDownShelf({
const tableHref = `/portfolio/${portfolioId}`;
return (
<div className="mt-4 overflow-hidden rounded-lg border border-midblue bg-white">
<div
id="reporting-drill-shelf"
className="mt-4 overflow-hidden rounded-lg border border-midblue bg-white"
>
<header className="flex items-center gap-3 border-b border-gray-100 px-5 py-3.5">
<span className="flex items-center gap-2 text-[0.95rem] font-semibold text-brandblue">
{target.chipBand && <EpcChip band={target.chipBand} size="md" />}

View file

@ -8,7 +8,7 @@ import {
type Measure,
type MeasureGroup,
} from "@/lib/reporting/measures";
import { moneyFull } from "./primitives";
import { moneyCompact, moneyFull } from "./primitives";
import { loadScenarioMeasures } from "../actions";
import type { DrillTarget } from "./DrillDownShelf";
@ -22,12 +22,6 @@ const SEGMENT_COLORS = [
"#c5cad8",
];
function moneyShort(n: number) {
if (n >= 1_000_000) return `£${(n / 1_000_000).toFixed(2)}m`;
if (n >= 1_000) return `£${Math.round(n / 1_000)}k`;
return `£${Math.round(n)}`;
}
/**
* "Where the money goes" (Screen B lower). A single allocation bar by
* measure category answers the shape of the spend; the full grouped table
@ -114,7 +108,7 @@ export function MeasureAllocation({
{groups.map((g, i) => (
<span
key={g.category}
title={`${g.category} · ${moneyShort(g.costTotal)}`}
title={`${g.category} · ${moneyCompact(g.costTotal)}`}
style={{
width: `${(g.costTotal / grandTotal) * 100}%`,
background: SEGMENT_COLORS[i % SEGMENT_COLORS.length],
@ -136,7 +130,7 @@ export function MeasureAllocation({
/>
{g.category}{" "}
<b className="font-semibold text-gray-900">
{moneyShort(g.costTotal)}
{moneyCompact(g.costTotal)}
</b>{" "}
· {g.homesTotal} homes
</span>