From 376627b2d05259cfad4e252cc5eacc227f8629b4 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 19 Jul 2026 00:59:33 +0100 Subject: [PATCH] feat(reporting): filter the view by tags (URL-driven) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a view-wide tag filter to the reporting page. A "Tags" control in the header lists the portfolio's tags; each can be Included (home must carry it) or Excluded (home must not) — mutually exclusive per tag — with an Any/All switch once two or more are included. The filter is URL-driven (?includeTags=…&excludeTags=…&includeMode=all): the server reads it and renders the baseline (current-stock figures) filtered, so it survives refresh and back/forward. Changing it navigates and streams the filtered figures back in via the Suspense boundaries. The scenario overlay, measure allocation and drill-down all send the same tag params (in their react-query keys), so the whole view stays consistent with the headline counts. Scenario selection + scenario filters remain client state for now (instant, no round-trip) — migrating those into the URL too is the documented next step. Verified in a headless browser on #796: including the tag drops the homes count 31,398 → 607 (its exact membership) and writes ?includeTags=13 to the URL. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../reporting/ReportingClientArea.tsx | 35 +++- .../reporting/components/DrillDownShelf.tsx | 27 ++- .../components/MeasureAllocation.tsx | 23 ++- .../reporting/components/TagFilterControl.tsx | 177 ++++++++++++++++++ .../[slug]/(portfolio)/reporting/page.tsx | 12 +- src/lib/reporting/viewState.test.ts | 15 ++ src/lib/reporting/viewState.ts | 29 +-- 7 files changed, 292 insertions(+), 26 deletions(-) create mode 100644 src/app/portfolio/[slug]/(portfolio)/reporting/components/TagFilterControl.tsx diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx index a9fb94c8..49c7994d 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx @@ -2,8 +2,10 @@ import { Suspense, use, useState } from "react"; import { useQuery, useIsFetching } from "@tanstack/react-query"; +import { usePathname, useRouter } from "next/navigation"; import { ArrowPathIcon } from "@heroicons/react/24/outline"; import { formatNumber, sapToEpc } from "@/app/utils"; +import { serializeTagFilter, type TagFilter } from "@/lib/reporting/viewState"; import { selectGoalCallout, shapeKpiDelta, @@ -25,6 +27,7 @@ import { FilterChips, type ScenarioFilters } from "./components/FilterChips"; import { DrillDownShelf, type DrillTarget } from "./components/DrillDownShelf"; import { StockBreakdown } from "./components/StockBreakdown"; import { MeasureAllocation } from "./components/MeasureAllocation"; +import { TagFilterControl } from "./components/TagFilterControl"; import type { BaselineMetrics, @@ -38,6 +41,8 @@ interface Props { scenariosPromise: Promise; portfolioGoalPromise: Promise; portfolioId: number; + /** The tag filter, parsed from the URL on the server (view-wide). */ + tags: TagFilter; } /** Maps the combobox view onto the scenario metrics route's id segment. */ @@ -51,6 +56,7 @@ async function fetchScenarioMetrics( portfolioId: number, segment: number | "default", filters: ScenarioFilters, + tags: TagFilter, ) { const p = new URLSearchParams({ hideNonCompliant: String(filters.hideNonCompliant), @@ -60,6 +66,9 @@ async function fetchScenarioMetrics( p.set("complianceDate", filters.complianceWindow.date); p.set("complianceBand", filters.complianceWindow.band); } + // View-wide tag filter — the overlay must reflect the same subset as the + // baseline it's compared against. + serializeTagFilter(tags, p); const res = await fetch( `/api/portfolio/${portfolioId}/scenario/${segment}/metrics?${p.toString()}`, ); @@ -81,6 +90,7 @@ export function ReportingClientArea({ scenariosPromise, portfolioGoalPromise, portfolioId, + tags, }: Props) { const [view, setView] = useState("current-stock"); const [filters, setFilters] = useState({ @@ -88,9 +98,19 @@ export function ReportingClientArea({ useLodgedBaseline: false, complianceWindow: null, }); + const router = useRouter(); + const pathname = usePathname(); const isScenario = scenarioIdSegment(view) !== null; + // The tag filter is URL-driven, so changing it navigates: the server + // re-renders the baseline filtered and the metrics stream back in. Scenario + // selection + filters stay client state (instant, no round-trip). + const onTagsChange = (next: TagFilter) => { + const qs = serializeTagFilter(next).toString(); + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); + }; + return (
{/* Header — static, paints on first byte */} @@ -102,6 +122,11 @@ export function ReportingClientArea({
+ {isScenario && (
@@ -257,6 +283,7 @@ function MetricsBody({ portfolioId, view, filters, + tags, }: { baselinePromise: Promise; propertyTypesPromise: Promise; @@ -265,6 +292,7 @@ function MetricsBody({ portfolioId: number; view: ReportView; filters: ScenarioFilters; + tags: TagFilter; }) { const baseline = use(baselinePromise); const propertyTypes = use(propertyTypesPromise); @@ -280,8 +308,9 @@ function MetricsBody({ typeof view === "number" ? scenarios.find((s) => s.id === view) : undefined; const { data: scenarioData, isFetching } = useQuery({ - queryKey: ["scenario-report", portfolioId, segment, filters], - queryFn: () => fetchScenarioMetrics(portfolioId, segment!, filters), + // `tags` is in the key so the overlay refetches when the tag filter changes. + queryKey: ["scenario-report", portfolioId, segment, filters, tags], + queryFn: () => fetchScenarioMetrics(portfolioId, segment!, filters, tags), enabled: isScenario, keepPreviousData: true, refetchOnWindowFocus: false, @@ -582,6 +611,7 @@ function MetricsBody({ openDrill({ ...t, scenarioId: view }) @@ -605,6 +635,7 @@ function MetricsBody({ page={drillPage} onPage={setDrillPage} onClose={() => setDrill(null)} + tags={tags} /> )} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/components/DrillDownShelf.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/components/DrillDownShelf.tsx index 43fd10ab..b71168ec 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/components/DrillDownShelf.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/components/DrillDownShelf.tsx @@ -4,6 +4,11 @@ import { useQuery } from "@tanstack/react-query"; import { X } from "lucide-react"; import { EpcChip } from "./primitives"; import { formatNumber } from "@/app/utils"; +import { + serializeTagFilter, + emptyTagFilter, + type TagFilter, +} from "@/lib/reporting/viewState"; /** * The drill-down shelf (Screen C) — one pattern for every count on the @@ -47,7 +52,12 @@ interface DrillResponse { // A compact preview — the shelf is a first look, not the full table. const DRILL_PAGE_SIZE = 8; -function buildQuery(portfolioId: number, t: DrillTarget, page: number) { +function buildQuery( + portfolioId: number, + t: DrillTarget, + page: number, + tags: TagFilter, +) { const p = new URLSearchParams({ filter: t.filter, page: String(page), @@ -56,6 +66,8 @@ function buildQuery(portfolioId: number, t: DrillTarget, page: number) { if (t.band) p.set("band", t.band); if (t.scenarioId != null) p.set("scenarioId", String(t.scenarioId)); if (t.measureType) p.set("measureType", t.measureType); + // The drill list must show the same tagged subset as the figure it opened. + serializeTagFilter(tags, p); return `/api/portfolio/${portfolioId}/reporting/properties?${p.toString()}`; } @@ -65,17 +77,21 @@ export function DrillDownShelf({ page, onPage, onClose, + tags, }: { portfolioId: number; target: DrillTarget; page: number; onPage: (p: number) => void; onClose: () => void; + /** View-wide tag filter; omitted (e.g. data-quality drill) → no tag scoping. */ + tags?: TagFilter; }) { + const tagFilter = tags ?? emptyTagFilter(); const { data, isLoading, isError } = useQuery({ - queryKey: ["reporting-drill", portfolioId, target, page], + queryKey: ["reporting-drill", portfolioId, target, page, tagFilter], queryFn: () => - fetch(buildQuery(portfolioId, target, page)).then((r) => { + fetch(buildQuery(portfolioId, target, page, tagFilter)).then((r) => { if (!r.ok) throw new Error("Failed to load homes"); return r.json(); }), @@ -120,7 +136,10 @@ export function DrillDownShelf({ )} {isError && ( -
+
Could not load these homes.
)} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/components/MeasureAllocation.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/components/MeasureAllocation.tsx index 3161a42d..6fe7a581 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/components/MeasureAllocation.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/components/MeasureAllocation.tsx @@ -11,6 +11,7 @@ import { import { moneyCompact, moneyFull } from "./primitives"; import { loadScenarioMeasures } from "../actions"; import type { DrillTarget } from "./DrillDownShelf"; +import type { TagFilter } from "@/lib/reporting/viewState"; /** Palette for the allocation bar — navy through tan, quiet greys for the tail. */ const SEGMENT_COLORS = [ @@ -31,10 +32,12 @@ const SEGMENT_COLORS = [ export function MeasureAllocation({ portfolioId, scenarioId, + tags, onDrillMeasure, }: { portfolioId: number; scenarioId: number | "default"; + tags: TagFilter; /** Undefined disables per-measure drill (e.g. the default pseudo-scenario has no numeric id). */ onDrillMeasure?: (t: DrillTarget) => void; }) { @@ -46,8 +49,9 @@ export function MeasureAllocation({ const [requested, setRequested] = useState(false); const { data, isLoading, isError, refetch } = useQuery({ - queryKey: ["scenario-measures", portfolioId, scenarioId], - queryFn: () => loadScenarioMeasures(portfolioId, scenarioId), + // `tags` in the key so the breakdown re-queries when the tag filter changes. + queryKey: ["scenario-measures", portfolioId, scenarioId, tags], + queryFn: () => loadScenarioMeasures(portfolioId, scenarioId, tags), enabled: requested, refetchOnWindowFocus: false, retry: false, @@ -119,7 +123,10 @@ export function MeasureAllocation({
)} {requested && isError && ( -
+
Could not load the spend breakdown. + ); + + return ( + + {trigger} + +
+ + Filter by tag + + {activeCount > 0 && ( + + )} +
+ + {value.include.length >= 2 && ( +
+ Match +
+ {(["any", "all"] as const).map((m) => ( + + ))} +
+ of the included tags +
+ )} + +
+ {isLoading ? ( +

+ Loading tags… +

+ ) : tags.length === 0 ? ( +

+ This portfolio has no tags yet. +

+ ) : ( + tags.map((t) => { + const included = value.include.includes(t.id); + const excluded = value.exclude.includes(t.id); + return ( +
+ + + + {t.propertyCount} + + + + +
+ ); + }) + )} +
+
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/page.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/page.tsx index 2e573b6d..df2be507 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/page.tsx @@ -4,21 +4,28 @@ import { getPortfolioGoal, getReportingScenarios, } from "@/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions"; +import { parseReportingViewState } from "@/lib/reporting/viewState"; import { ReportingClientArea } from "./ReportingClientArea"; export default async function ReportingPage(props: { params: Promise<{ slug: string }>; + searchParams: Promise>; }) { const params = await props.params; const portfolioId = Number(params.slug); + // The tag filter is URL-driven: read it here so the server-rendered baseline + // (current-stock figures) reflects the tagged subset. Changing tags navigates, + // re-running these queries and streaming the filtered figures back in. + const tags = parseReportingViewState(await props.searchParams).tags; + // Kick every query off in parallel but DON'T await here — the promises are // handed to the client area, which unwraps each with `use()` inside its own // Suspense boundary. So the page shell + scenario picker paint immediately and // each section streams in as its data lands, instead of the whole page // blocking on the slowest query (the old Promise.all). - const baselinePromise = loadBaselineMetrics(portfolioId); - const propertyTypesPromise = getCountByPropertyType(portfolioId); + const baselinePromise = loadBaselineMetrics(portfolioId, tags); + const propertyTypesPromise = getCountByPropertyType(portfolioId, tags); const portfolioGoalPromise = getPortfolioGoal(portfolioId); const scenariosPromise = getReportingScenarios(portfolioId); @@ -30,6 +37,7 @@ export default async function ReportingPage(props: { scenariosPromise={scenariosPromise} portfolioGoalPromise={portfolioGoalPromise} portfolioId={portfolioId} + tags={tags} />
); diff --git a/src/lib/reporting/viewState.test.ts b/src/lib/reporting/viewState.test.ts index 09923d6c..6967c1d1 100644 --- a/src/lib/reporting/viewState.test.ts +++ b/src/lib/reporting/viewState.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { parseReportingViewState, serializeReportingViewState, + serializeTagFilter, isTagFilterActive, emptyViewState, type ReportingViewState, @@ -134,6 +135,20 @@ describe("isTagFilterActive", () => { }); }); +describe("serializeTagFilter", () => { + it("is empty for an empty filter", () => { + expect(serializeTagFilter(emptyViewState().tags).toString()).toBe(""); + }); + it("emits include + exclude, and includeMode only when 'all' with 2+", () => { + expect( + serializeTagFilter({ include: ["1", "2"], exclude: ["9"], includeMode: "all" }).toString(), + ).toBe("includeTags=1%2C2&excludeTags=9&includeMode=all"); + expect( + serializeTagFilter({ include: ["1"], exclude: [], includeMode: "all" }).toString(), + ).toBe("includeTags=1"); + }); +}); + describe("serializeReportingViewState — round-trip + clean URLs", () => { it("omits every default (empty query string)", () => { expect(serializeReportingViewState(emptyViewState()).toString()).toBe(""); diff --git a/src/lib/reporting/viewState.ts b/src/lib/reporting/viewState.ts index 7f24e1ad..6fd0eaa3 100644 --- a/src/lib/reporting/viewState.ts +++ b/src/lib/reporting/viewState.ts @@ -83,9 +83,7 @@ export function isTagFilterActive(tags: TagFilter): boolean { return tags.include.length > 0 || tags.exclude.length > 0; } -export function parseReportingViewState( - src: ParamSource, -): ReportingViewState { +export function parseReportingViewState(src: ParamSource): ReportingViewState { const band = readParam(src, "complianceBand"); const date = readParam(src, "complianceDate"); const complianceWindow = @@ -117,6 +115,20 @@ export function parseReportingViewState( }; } +/** The tag-filter params only — the subset the reporting view currently drives via the URL. */ +export function serializeTagFilter( + tags: TagFilter, + into: URLSearchParams = new URLSearchParams(), +): URLSearchParams { + if (tags.include.length) into.set("includeTags", tags.include.join(",")); + if (tags.exclude.length) into.set("excludeTags", tags.exclude.join(",")); + // includeMode only bites with 2+ included tags; omit the default "any". + if (tags.includeMode === "all" && tags.include.length >= 2) { + into.set("includeMode", "all"); + } + return into; +} + export function serializeReportingViewState( state: ReportingViewState, ): URLSearchParams { @@ -128,15 +140,6 @@ export function serializeReportingViewState( p.set("complianceBand", state.complianceWindow.band); p.set("complianceDate", state.complianceWindow.date); } - if (state.tags.include.length) { - p.set("includeTags", state.tags.include.join(",")); - } - if (state.tags.exclude.length) { - p.set("excludeTags", state.tags.exclude.join(",")); - } - // includeMode only bites with 2+ included tags; omit the default "any". - if (state.tags.includeMode === "all" && state.tags.include.length >= 2) { - p.set("includeMode", "all"); - } + serializeTagFilter(state.tags, p); return p; }