From c638e59f20514df9629f9809fa29076979b4e5ce Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 8 Jul 2026 11:41:56 +0000 Subject: [PATCH] feat/polish(reporting): compare-column picker + consistency fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../reporting/ReportingClientArea.tsx | 13 +++ .../reporting/compare/CompareClientArea.tsx | 97 ++++++++++++++++--- .../reporting/components/DrillDownShelf.tsx | 5 +- .../components/MeasureAllocation.tsx | 12 +-- 4 files changed, 106 insertions(+), 21 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx index ae2154ce..7eda7231 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx @@ -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 ─────────────────────────────────────────────────────────── diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/compare/CompareClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/compare/CompareClientArea.tsx index 03b2f41e..8890ba7e 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/compare/CompareClientArea.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/compare/CompareClientArea.tsx @@ -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 - - ← Back to reporting - +
+ + + + + +
+ {available.map((s) => ( + + ))} + {available.length === 0 && ( +

+ All scenarios are already shown. +

+ )} +
+
+
+ + ← Back to reporting + +
@@ -194,8 +257,20 @@ export function CompareClientArea({ {columns.map((s) => ( - - {s.name} + + + {s.name} + + {columns.length > 1 && ( + + )} {s.goal === "Increasing EPC" && s.goalValue diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/components/DrillDownShelf.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/components/DrillDownShelf.tsx index fe70ac08..43fd10ab 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/components/DrillDownShelf.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/components/DrillDownShelf.tsx @@ -93,7 +93,10 @@ export function DrillDownShelf({ const tableHref = `/portfolio/${portfolioId}`; return ( -
+
{target.chipBand && } diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/components/MeasureAllocation.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/components/MeasureAllocation.tsx index 6f7de8c9..2ae6b2b2 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/components/MeasureAllocation.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/components/MeasureAllocation.tsx @@ -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) => ( {g.category}{" "} - {moneyShort(g.costTotal)} + {moneyCompact(g.costTotal)} {" "} · {g.homesTotal} homes