From e80332fa641d42d3f200a52ea78d68daaf49041a Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 19 Jul 2026 00:44:34 +0100 Subject: [PATCH] feat(reporting): apply the tag filter across the data layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Threads the tag filter through every reporting query so the whole view reflects the tagged subset: baseline aggregate, age-band + property-type counts, likely downgrades, both scenario-metrics routes (all three property scans each), the drill-down list, and the measures allocation. Each query ANDs tagFilterCondition() into its WHERE; routes parse the filter from the URL via the shared parseReportingViewState so they read exactly what the client wrote. Defaults to the empty (no-op) filter, so existing callers/tests are unchanged. Verified on #796/scenario 1292: include tag → n_units = the tag's membership (607), exclude → the complement (30705), and the two EPC distributions reconcile to the full portfolio total. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../reporting/properties/route.ts | 3 +++ .../scenario/[scenarioId]/metrics/route.ts | 16 +++++++++--- .../scenario/default/metrics/route.ts | 14 ++++++++--- .../[slug]/(portfolio)/reporting/actions.ts | 7 ++++-- .../reporting/databaseFunctions.ts | 25 +++++++++++++------ .../(portfolio)/reporting/measuresQuery.ts | 4 +++ 6 files changed, 53 insertions(+), 16 deletions(-) diff --git a/src/app/api/portfolio/[portfolioId]/reporting/properties/route.ts b/src/app/api/portfolio/[portfolioId]/reporting/properties/route.ts index 15bc23df..320d31b1 100644 --- a/src/app/api/portfolio/[portfolioId]/reporting/properties/route.ts +++ b/src/app/api/portfolio/[portfolioId]/reporting/properties/route.ts @@ -13,6 +13,8 @@ import { provenanceSignalSql, } from "@/lib/services/epcSources"; import { EPC_BANDS } from "@/lib/scenarios/model"; +import { tagFilterCondition } from "@/lib/reporting/tagFilterSql"; +import { parseReportingViewState } from "@/lib/reporting/viewState"; /** * Drill-down: the homes behind a reporting figure (PRD #370, Screen C). @@ -117,6 +119,7 @@ export async function GET( LEFT JOIN property_details_epc e ON e.property_id = p.id ${newApproachJoins} WHERE p.portfolio_id = ${pid} + AND ${tagFilterCondition(parseReportingViewState(params).tags)} AND ${filterSql} ORDER BY address NULLS LAST, p.id LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}; diff --git a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts index 792bcd39..c009be7a 100644 --- a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts +++ b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts @@ -12,6 +12,8 @@ import { } from "@/lib/services/epcSources"; import { computeLedger, DEFAULT_GOAL_BAND } from "@/lib/reporting/model"; import { EPC_BANDS } from "@/lib/scenarios/model"; +import { tagFilterCondition } from "@/lib/reporting/tagFilterSql"; +import { parseReportingViewState } from "@/lib/reporting/viewState"; /* ======================= Types @@ -109,6 +111,12 @@ export async function GET( const useLodgedBaseline = request.nextUrl.searchParams.get("useLodgedBaseline") === "true"; + // Tag filter (view-wide): scope every property scan below to the tagged + // subset. Parsed via the shared view-state parser so the route reads the URL + // exactly as the client serialised it. + const tags = parseReportingViewState(request.nextUrl.searchParams).tags; + const tagScope = tagFilterCondition(tags); + /* ---------------------------------------------------------- Compliance window — ADR-0010. complianceDate activates the filter; complianceBand defaults to C. @@ -250,7 +258,8 @@ export async function GET( -- string in application code (which would require string concatenation and risks -- SQL injection). The OR short-circuits left-to-right: if the first or second -- condition is true, the third is never evaluated, so all rows pass through. - WHERE ( + WHERE ${tagScope} + AND ( ${useLodgedBaseline} = false -- toggle off → include everything OR ${minSap}::float IS NULL -- no EPC target → nothing to filter on OR COALESCE((${lodgedSapSql}) < ${minSap}::float, true) -- actual filter; unknown lodged → keep @@ -292,7 +301,8 @@ export async function GET( -- Portfolio-scoped legacy join (see Query 1) — index lookup, not seq scan. LEFT JOIN property_details_epc e ON e.property_id = p.id AND e.portfolio_id = ${pid} LEFT JOIN funding_package fp ON fp.plan_id = lp.id - WHERE lp.cost_of_works > 0 + WHERE ${tagScope} + AND lp.cost_of_works > 0 -- TEMP (demo): exclude plans whose post SAP is below the effective baseline -- (target-level post_sap on already-compliant homes) — not real upgrades. -- COALESCE keeps rows we can't compare. See ADR-0002. @@ -382,7 +392,7 @@ export async function GET( LIMIT 1 ) lp ON true - WHERE p.portfolio_id = ${pid} + WHERE p.portfolio_id = ${pid} AND ${tagScope} ) q; `; diff --git a/src/app/api/portfolio/[portfolioId]/scenario/default/metrics/route.ts b/src/app/api/portfolio/[portfolioId]/scenario/default/metrics/route.ts index b61487e0..b7649d20 100644 --- a/src/app/api/portfolio/[portfolioId]/scenario/default/metrics/route.ts +++ b/src/app/api/portfolio/[portfolioId]/scenario/default/metrics/route.ts @@ -8,6 +8,8 @@ import { billsSql, effectiveSapSql, } from "@/lib/services/epcSources"; +import { tagFilterCondition } from "@/lib/reporting/tagFilterSql"; +import { parseReportingViewState } from "@/lib/reporting/viewState"; /* ======================= Types @@ -96,6 +98,10 @@ export async function GET( const hideNonCompliant = request.nextUrl.searchParams.get("hideNonCompliant") === "true"; + // Tag filter (view-wide): scope every property scan to the tagged subset. + const tags = parseReportingViewState(request.nextUrl.searchParams).tags; + const tagScope = tagFilterCondition(tags); + /* ---------------------------------------------------------- The three data queries below are independent (they share no results), so they run in one parallel wave. Query 2 folds the old per-property @@ -135,7 +141,8 @@ export async function GET( )::float AS total_sap_uplift FROM latest_plans lp JOIN property p ON p.id = lp.property_id - LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id; + LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id + WHERE ${tagScope}; `; /* ---------------------------------------------------------- @@ -162,7 +169,8 @@ export async function GET( JOIN property p ON p.id = lp.property_id LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id LEFT JOIN funding_package fp ON fp.plan_id = lp.id - WHERE lp.cost_of_works > 0 + WHERE ${tagScope} + AND lp.cost_of_works > 0 -- TEMP (demo): a plan whose post SAP is below the effective baseline isn't -- a real upgrade (target-level post_sap on already-compliant homes), so -- exclude it from the count + cost. COALESCE keeps rows we can't compare @@ -235,7 +243,7 @@ export async function GET( LIMIT 1 ) lp ON true - WHERE p.portfolio_id = ${pid} + WHERE p.portfolio_id = ${pid} AND ${tagScope} ) q; `; diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/actions.ts b/src/app/portfolio/[slug]/(portfolio)/reporting/actions.ts index 693e5a56..1b1bea45 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/actions.ts +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/actions.ts @@ -2,15 +2,18 @@ import { queryScenarioMeasures } from "./measuresQuery"; import type { Measure } from "@/lib/reporting/measures"; +import { emptyTagFilter, type TagFilter } from "@/lib/reporting/viewState"; /** * Server action behind the "where the money goes" panel. Runs the grouped * measures query directly on the server — no API round-trip — so the panel - * background-loads while the rest of the page is interactive. + * background-loads while the rest of the page is interactive. Honours the + * view-wide tag filter so the spend breakdown matches the filtered figures. */ export async function loadScenarioMeasures( portfolioId: number, scenarioId: number | "default", + tags: TagFilter = emptyTagFilter(), ): Promise { - return queryScenarioMeasures(portfolioId, scenarioId); + return queryScenarioMeasures(portfolioId, scenarioId, tags); } diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts b/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts index 185277d1..07378968 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts @@ -28,6 +28,8 @@ import { toReportingScenario, type ReportingScenario, } from "./reportingScenarios"; +import { tagFilterCondition } from "@/lib/reporting/tagFilterSql"; +import { emptyTagFilter, type TagFilter } from "@/lib/reporting/viewState"; export type { ReportingScenario }; @@ -52,7 +54,10 @@ const BASELINE_EPC_BANDS = [ * load time on large portfolios. Age bands (correlated subqueries) and likely * downgrades (a different, legacy-only join) stay separate and run in parallel. */ -export async function getBaselineAggregates(portfolioId: number): Promise<{ +export async function getBaselineAggregates( + portfolioId: number, + tags: TagFilter = emptyTagFilter(), +): Promise<{ total: number; averages: AverageMetrics; totals: TotalMetrics; @@ -100,7 +105,7 @@ export async function getBaselineAggregates(portfolioId: number): Promise<{ -- the whole 600k-row table; migrated portfolios have ~0 rows here. LEFT JOIN property_details_epc e ON e.property_id = p.id AND e.portfolio_id = ${portfolioId} ${newApproachJoins} - WHERE p.portfolio_id = ${portfolioId}; + WHERE p.portfolio_id = ${portfolioId} AND ${tagFilterCondition(tags)}; `); const row = result.rows[0]; @@ -139,6 +144,7 @@ export async function getBaselineAggregates(portfolioId: number): Promise<{ export async function getCountByAgeBand( portfolioId: number, + tags: TagFilter = emptyTagFilter(), ): Promise { const result = await db.execute(sql` SELECT @@ -156,7 +162,7 @@ export async function getCountByAgeBand( SELECT ${constructionYearSql} AS built_year FROM property p ${newApproachJoins} - WHERE p.portfolio_id = ${portfolioId} + WHERE p.portfolio_id = ${portfolioId} AND ${tagFilterCondition(tags)} ) q GROUP BY age_band ORDER BY age_band; @@ -167,12 +173,13 @@ export async function getCountByAgeBand( export async function getCountByPropertyType( portfolioId: number, + tags: TagFilter = emptyTagFilter(), ): Promise { const result = await db.execute(sql` SELECT ${propertyTypeSql} AS type, COUNT(*)::int AS count FROM property p ${newApproachJoins} - WHERE p.portfolio_id = ${portfolioId} + WHERE p.portfolio_id = ${portfolioId} AND ${tagFilterCondition(tags)} GROUP BY type ORDER BY count DESC; `); @@ -182,6 +189,7 @@ export async function getCountByPropertyType( export async function getLikelyDowngrades( portfolioId: number, + tags: TagFilter = emptyTagFilter(), ): Promise { const result = await db.execute<{ downgrades: number }>(sql` SELECT @@ -189,7 +197,7 @@ export async function getLikelyDowngrades( FROM property p JOIN property_details_epc e ON e.property_id = p.id - WHERE p.portfolio_id = ${portfolioId} + WHERE p.portfolio_id = ${portfolioId} AND ${tagFilterCondition(tags)} -- GAP: the SAP-05 "likely downgrade" signal has no equivalent in the new -- pipeline (handled there via Rebaseline). Restrict to legacy properties. AND p.updated_at < ${NEW_APPROACH_CUTOFF}::date @@ -204,14 +212,15 @@ export async function getLikelyDowngrades( export async function loadBaselineMetrics( portfolioId: number, + tags: TagFilter = emptyTagFilter(), ): Promise { // Three parallel scans, not eight: the single-pass aggregate carries total, // averages, totals, EPC bands, estimated split and expired count; age bands // and likely downgrades keep their own (differently-shaped) queries. const [aggregates, ageBands, likelyDowngrades] = await Promise.all([ - getBaselineAggregates(portfolioId), - getCountByAgeBand(portfolioId), - getLikelyDowngrades(portfolioId), + getBaselineAggregates(portfolioId, tags), + getCountByAgeBand(portfolioId, tags), + getLikelyDowngrades(portfolioId, tags), ]); return { diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/measuresQuery.ts b/src/app/portfolio/[slug]/(portfolio)/reporting/measuresQuery.ts index febe7d6d..0a11282f 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/measuresQuery.ts +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/measuresQuery.ts @@ -2,6 +2,8 @@ import "server-only"; import { db } from "@/app/db/db"; import { sql } from "drizzle-orm"; import type { Measure } from "@/lib/reporting/measures"; +import { tagFilterCondition } from "@/lib/reporting/tagFilterSql"; +import { emptyTagFilter, type TagFilter } from "@/lib/reporting/viewState"; /** * Grouped measure spend for a scenario's latest plans — the data behind the @@ -17,6 +19,7 @@ import type { Measure } from "@/lib/reporting/measures"; export async function queryScenarioMeasures( portfolioId: number, scenarioId: number | "default", + tags: TagFilter = emptyTagFilter(), ): Promise { const pid = BigInt(portfolioId); @@ -44,6 +47,7 @@ export async function queryScenarioMeasures( FROM plan WHERE portfolio_id = ${pid} AND ${planScope} + AND ${tagFilterCondition(tags, sql`plan.property_id`)} ORDER BY property_id, created_at DESC ) lp JOIN recommendation r