feat(reporting): apply the tag filter across the data layer

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) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-19 00:44:34 +01:00
parent 77e5fcde20
commit e80332fa64
6 changed files with 53 additions and 16 deletions

View file

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

View file

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

View file

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

View file

@ -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<Measure[]> {
return queryScenarioMeasures(portfolioId, scenarioId);
return queryScenarioMeasures(portfolioId, scenarioId, tags);
}

View file

@ -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<AgeBandCount[]> {
const result = await db.execute<AgeBandCount>(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<PropertyTypeCount[]> {
const result = await db.execute<PropertyTypeCount>(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<number> {
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<BaselineMetrics> {
// 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 {

View file

@ -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<Measure[]> {
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