feat(reporting): filter the view by tags (URL-driven)
Some checks are pending
Test Suite / unit-tests (push) Waiting to run

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) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-19 00:59:33 +01:00
parent e80332fa64
commit 376627b2d0
7 changed files with 292 additions and 26 deletions

View file

@ -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<ScenarioSummary[]>;
portfolioGoalPromise: Promise<string>;
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<ReportView>("current-stock");
const [filters, setFilters] = useState<ScenarioFilters>({
@ -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 (
<div className="space-y-4" data-reporting>
{/* Header — static, paints on first byte */}
@ -102,6 +122,11 @@ export function ReportingClientArea({
</h1>
</div>
<div className="flex gap-2">
<TagFilterControl
portfolioId={portfolioId}
value={tags}
onChange={onTagsChange}
/>
{isScenario && (
<a
href={`/portfolio/${portfolioId}/reporting/compare`}
@ -142,6 +167,7 @@ export function ReportingClientArea({
portfolioId={portfolioId}
view={view}
filters={filters}
tags={tags}
/>
</Suspense>
</div>
@ -257,6 +283,7 @@ function MetricsBody({
portfolioId,
view,
filters,
tags,
}: {
baselinePromise: Promise<BaselineMetrics>;
propertyTypesPromise: Promise<PropertyTypeCount[]>;
@ -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({
<MeasureAllocation
portfolioId={portfolioId}
scenarioId={segment}
tags={tags}
onDrillMeasure={
typeof view === "number"
? (t) => openDrill({ ...t, scenarioId: view })
@ -605,6 +635,7 @@ function MetricsBody({
page={drillPage}
onPage={setDrillPage}
onClose={() => setDrill(null)}
tags={tags}
/>
)}
</div>

View file

@ -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<DrillResponse>({
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({
</div>
)}
{isError && (
<div role="alert" className="px-5 py-8 text-center text-sm text-red-600">
<div
role="alert"
className="px-5 py-8 text-center text-sm text-red-600"
>
Could not load these homes.
</div>
)}

View file

@ -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<Measure[]>({
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({
</div>
)}
{requested && isError && (
<div role="alert" className="flex items-center gap-3 text-sm text-red-600">
<div
role="alert"
className="flex items-center gap-3 text-sm text-red-600"
>
Could not load the spend breakdown.
<button
onClick={() => refetch()}
@ -277,9 +284,15 @@ function downloadCsv(groups: MeasureGroup[]) {
].join(","),
);
}
lines.push([g.category, "Subtotal", g.homesTotal, g.costTotal.toFixed(0), ""].join(","));
lines.push(
[g.category, "Subtotal", g.homesTotal, g.costTotal.toFixed(0), ""].join(
",",
),
);
}
const blob = new Blob([lines.join("\n")], { type: "text/csv;charset=utf-8;" });
const blob = new Blob([lines.join("\n")], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;

View file

@ -0,0 +1,177 @@
"use client";
import { Tag as TagIcon, Check, Ban } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/app/shadcn_components/ui/popover";
import { usePortfolioTags } from "@/app/portfolio/[slug]/components/useTags";
import { TagChip } from "@/app/portfolio/[slug]/components/TagChip";
import type { TagFilter, IncludeMode } from "@/lib/reporting/viewState";
/**
* The reporting tag filter (view-wide). Each tag can be Included (home must
* carry it) or Excluded (home must not) mutually exclusive per tag. When two
* or more tags are included, an Any/All switch chooses union vs intersection.
* Purely presentational + local wiring: it hands a new {@link TagFilter} up to
* the parent, which pushes it into the URL.
*/
export function TagFilterControl({
portfolioId,
value,
onChange,
}: {
portfolioId: number;
value: TagFilter;
onChange: (next: TagFilter) => void;
}) {
const { data: tags = [], isLoading } = usePortfolioTags(String(portfolioId));
const activeCount = value.include.length + value.exclude.length;
const toggleInclude = (id: string) =>
onChange({
...value,
include: value.include.includes(id)
? value.include.filter((x) => x !== id)
: [...value.include, id],
exclude: value.exclude.filter((x) => x !== id),
});
const toggleExclude = (id: string) =>
onChange({
...value,
exclude: value.exclude.includes(id)
? value.exclude.filter((x) => x !== id)
: [...value.exclude, id],
include: value.include.filter((x) => x !== id),
});
const setMode = (includeMode: IncludeMode) =>
onChange({ ...value, includeMode });
const clear = () =>
onChange({ include: [], exclude: [], includeMode: "any" });
const trigger = (
<button
type="button"
aria-haspopup="dialog"
className={`inline-flex h-8 items-center gap-1.5 rounded-lg border px-3 text-sm font-medium ${
activeCount > 0
? "border-brandblue bg-[#f2f3f9] text-brandblue"
: "border-gray-200 bg-white text-gray-600 hover:border-gray-300"
}`}
>
<TagIcon className="h-3.5 w-3.5" />
Tags
{activeCount > 0 && (
<span className="ml-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-brandblue px-1 text-[0.62rem] font-bold text-white">
{activeCount}
</span>
)}
</button>
);
return (
<Popover>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent align="end" className="w-[22rem] p-0">
<div className="flex items-center justify-between border-b border-gray-100 px-3.5 py-2.5">
<span className="text-[0.8rem] font-semibold text-brandblue">
Filter by tag
</span>
{activeCount > 0 && (
<button
type="button"
onClick={clear}
className="text-[0.72rem] font-medium text-midblue hover:underline"
>
Clear
</button>
)}
</div>
{value.include.length >= 2 && (
<div className="flex items-center gap-2 border-b border-gray-100 px-3.5 py-2 text-[0.74rem]">
<span className="text-gray-500">Match</span>
<div className="inline-flex overflow-hidden rounded-md border border-gray-200">
{(["any", "all"] as const).map((m) => (
<button
key={m}
type="button"
aria-pressed={value.includeMode === m}
onClick={() => setMode(m)}
className={`px-2.5 py-1 font-medium ${
value.includeMode === m
? "bg-brandblue text-white"
: "bg-white text-gray-600 hover:bg-gray-50"
}`}
>
{m === "any" ? "any" : "all"}
</button>
))}
</div>
<span className="text-gray-500">of the included tags</span>
</div>
)}
<div className="max-h-72 overflow-y-auto py-1">
{isLoading ? (
<p className="px-3.5 py-3 text-[0.78rem] text-gray-500">
Loading tags
</p>
) : tags.length === 0 ? (
<p className="px-3.5 py-3 text-[0.78rem] text-gray-500">
This portfolio has no tags yet.
</p>
) : (
tags.map((t) => {
const included = value.include.includes(t.id);
const excluded = value.exclude.includes(t.id);
return (
<div
key={t.id}
className="flex items-center gap-2 px-3.5 py-1.5 hover:bg-gray-50"
>
<span className="min-w-0 flex-1">
<TagChip name={t.name} colour={t.colour} />
<span className="ml-1.5 text-[0.68rem] tabular-nums text-gray-400">
{t.propertyCount}
</span>
</span>
<button
type="button"
aria-pressed={included}
onClick={() => toggleInclude(t.id)}
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[0.72rem] font-medium ${
included
? "border-[#0c6b4a] bg-[#e9f4ef] text-[#0c6b4a]"
: "border-gray-200 bg-white text-gray-500 hover:border-gray-300"
}`}
>
<Check className="h-3 w-3" />
Include
</button>
<button
type="button"
aria-pressed={excluded}
onClick={() => toggleExclude(t.id)}
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[0.72rem] font-medium ${
excluded
? "border-[#b91c1c] bg-[#fbeaea] text-[#b91c1c]"
: "border-gray-200 bg-white text-gray-500 hover:border-gray-300"
}`}
>
<Ban className="h-3 w-3" />
Exclude
</button>
</div>
);
})
)}
</div>
</PopoverContent>
</Popover>
);
}

View file

@ -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<Record<string, string | string[] | undefined>>;
}) {
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}
/>
</div>
);

View file

@ -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("");

View file

@ -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;
}