From c3530e7e5c110936a124107f0a6617c4ce511c98 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 18 Jul 2026 21:36:57 +0100 Subject: [PATCH] perf(reporting): collapse baseline + scenario queries, parallelize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reporting was slow on large portfolios (e.g. #796) because every metric re-scanned property ⋈ property_details_epc ⋈ newApproachJoins, and the scenario overlay ran five queries strictly serially. Baseline (initial load): the six queries that were the identical scan — total, averages, totals, EPC-band distribution, estimated/actual split and expired count — collapse into one conditional-aggregation pass. Age bands (correlated subqueries) and likely downgrades (legacy-only join) stay separate and run in parallel. 8 queries → 3; ~1945ms → ~787ms on #796, results byte-identical. Scenario overlay: Query 2 (portfolio-after aggregates) and Query 3 (EPC distribution) were near-identical per-property LATERAL scans, and Q3 shipped one row per property to Node just to bucket bands in JS. Merged into a single scan that buckets bands in SQL (thresholds matched to sapToEpc exactly), and the three data queries now run in one Promise.all wave instead of serially. Applied to both the [scenarioId] and default routes. Verified identical aggregates + band counts on #796's ~31k-home scenario. All ADR-0002 / ADR-0010 semantics (effective baselines, compliance window, lodged toggle) preserved unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../[scenarioId]/metrics/route.test.ts | 38 ++- .../scenario/[scenarioId]/metrics/route.ts | 151 ++++++------ .../scenario/default/metrics/route.ts | 134 ++++++----- .../reporting/databaseFunctions.ts | 220 ++++++++---------- 4 files changed, 285 insertions(+), 258 deletions(-) diff --git a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.test.ts b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.test.ts index 29196740..1063de09 100644 --- a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.test.ts +++ b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.test.ts @@ -28,7 +28,13 @@ function makeProps(scenarioId = "12") { return { params: Promise.resolve({ portfolioId: "785", scenarioId }) }; } -/** Queue the five route queries: scenario def, plans metrics, upgraded, portfolio, epc rows. */ +/** + * Queue the route's queries. The scenario definition resolves first; the three + * data queries (plans metrics, upgraded, portfolio-after) then run in one + * Promise.all wave, so their mocks are consumed in that array order. The + * portfolio-after query now also carries the EPC-band buckets (band_*), folded + * in from the old separate distribution scan. + */ function queueHappyPath({ goal = "Increasing EPC", goalValue = "C", @@ -63,10 +69,17 @@ function queueHappyPath({ avg_bills: 941, total_carbon: 593, total_bills: 294_000, + band_a: 0, + band_b: 10, + band_c: 60, + band_d: 20, + band_e: 8, + band_f: 2, + band_g: 0, + band_unknown: 0, }, ], - }) - .mockResolvedValueOnce({ rows: [] }); + }); } beforeEach(() => { @@ -105,6 +118,25 @@ describe("GET scenario metrics", () => { expect(body.gross_per_unit).toBe(14_000); }); + it("passes the SQL-bucketed EPC-band counts straight through", async () => { + queueHappyPath(); + const res = await GET(makeRequest(), makeProps()); + const body = await res.json(); + + // The band_* columns from the merged portfolio-after query map onto the + // scenario_epc_counts the ladder consumes — no JS re-bucketing. + expect(body.scenario_epc_counts).toEqual({ + A: 0, + B: 10, + C: 60, + D: 20, + E: 8, + F: 2, + G: 0, + Unknown: 0, + }); + }); + // Compliance window — ADR-0010: report-view parameter, configurable band + date. it("rejects a compliance band outside A–G", async () => { 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 5e15cae2..48dbb8ad 100644 --- a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts +++ b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts @@ -1,7 +1,6 @@ import { db } from "@/app/db/db"; import { sql } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; -import { sapToEpc } from "@/app/utils"; import type { PortfolioGoalType } from "@/app/db/schema/portfolio"; import { newApproachJoins, @@ -41,11 +40,34 @@ type PortfolioAggregates = { avg_bills: number | null; total_carbon: number | null; total_bills: number | null; + // EPC-band buckets, computed in SQL (see BAND_BUCKETS_SQL) rather than shipped + // one row per property to Node. + band_a: number; + band_b: number; + band_c: number; + band_d: number; + band_e: number; + band_f: number; + band_g: number; + band_unknown: number; }; -type EpcRow = { - effective_sap: number | null; -}; +/** + * EPC-band buckets by SAP, matching sapToEpc's thresholds exactly (A≥92, B≥81, + * C≥69, D≥55, E≥39, F≥21, else G; NULL → Unknown). Bucketing in SQL keeps the + * scenario band distribution to a single aggregate row instead of returning + * every property's SAP for the client to loop over. + */ +const BAND_BUCKETS_SQL = sql` + COUNT(*) FILTER (WHERE band_sap >= 92)::int AS band_a, + COUNT(*) FILTER (WHERE band_sap >= 81 AND band_sap < 92)::int AS band_b, + COUNT(*) FILTER (WHERE band_sap >= 69 AND band_sap < 81)::int AS band_c, + COUNT(*) FILTER (WHERE band_sap >= 55 AND band_sap < 69)::int AS band_d, + COUNT(*) FILTER (WHERE band_sap >= 39 AND band_sap < 55)::int AS band_e, + COUNT(*) FILTER (WHERE band_sap >= 21 AND band_sap < 39)::int AS band_f, + COUNT(*) FILTER (WHERE band_sap IS NOT NULL AND band_sap < 21)::int AS band_g, + COUNT(*) FILTER (WHERE band_sap IS NULL)::int AS band_unknown +`; /* ======================= Constants @@ -169,10 +191,17 @@ export async function GET( ? EPC_MIN_SAP[scenario.goal_value] : null; + /* ---------------------------------------------------------- + The three data queries below are independent — they only need `minSap` + (from the scenario definition above), not each other's results — so they + run in one parallel wave instead of four serial round-trips. Query 2 folds + the old per-property EPC-distribution scan into its own aggregate. + ---------------------------------------------------------- */ + /* ---------------------------------------------------------- QUERY 1 — Scenario metrics (PLANS ONLY) ---------------------------------------------------------- */ - const scenarioMetricsResult = await db.execute(sql` + const scenarioMetricsSql = sql` WITH latest_plans AS ( SELECT DISTINCT ON (property_id) * @@ -223,14 +252,12 @@ export async function GET( OR COALESCE((${lodgedSapSql}) < ${minSap}::float, true) -- actual filter; unknown lodged → keep ) AND ${passesComplianceWindowSql}; - `); - - const scenarioAgg = scenarioMetricsResult.rows[0] as ScenarioAggregates; + `; /* ---------------------------------------------------------- QUERY 1b — Upgrade costs (PLANS ONLY) ---------------------------------------------------------- */ - const upgradedResult = await db.execute(sql` + const upgradedCostsSql = sql` WITH latest_plans AS ( SELECT DISTINCT ON (property_id) * @@ -271,20 +298,23 @@ export async function GET( OR COALESCE((${lodgedSapSql}) < ${minSap}::float, true) ) AND ${passesComplianceWindowSql}; - `); - - const upgraded = upgradedResult.rows[0] as UpgradedAggregates; + `; /* ---------------------------------------------------------- QUERY 2 — Portfolio AFTER scenario (ALL properties) + Folds the former EPC-distribution scan (Query 3) in: the same + per-property LATERAL pass yields the average/total aggregates AND the + band buckets (band_sap → BAND_BUCKETS_SQL), so it's one scan, not two, + and no per-property rows cross the wire. ---------------------------------------------------------- */ - const portfolioMetricsResult = await db.execute(sql` + const portfolioMetricsSql = sql` SELECT AVG(effective_sap)::float AS avg_sap, AVG(effective_carbon)::float AS avg_carbon, AVG(effective_bills)::float AS avg_bills, SUM(effective_carbon)::float AS total_carbon, - SUM(effective_bills)::float AS total_bills + SUM(effective_bills)::float AS total_bills, + ${BAND_BUCKETS_SQL} FROM ( SELECT /* ---------- SAP ---------- */ @@ -303,7 +333,20 @@ export async function GET( CASE WHEN lp.id IS NOT NULL THEN lp.post_energy_bill ELSE ${billsSql(sql`e`)} - END AS effective_bills + END AS effective_bills, + + /* ---------- Band SAP (EPC distribution) ---------- */ + CASE + -- A retrofit scenario can't make a property worse. The engine writes a + -- target-level post_sap (e.g. ~C) even for properties already above the + -- target, so post_sap can sit BELOW the baseline — which made the chart + -- show e.g. B properties "improving" down to C. Clamp to the baseline. + WHEN lp.id IS NOT NULL THEN GREATEST(${effectiveSapSql}, lp.post_sap_points) + -- No qualifying plan → unchanged property. Use the effective + -- (re-baselined) baseline to match the "before" distribution; NOT + -- p.current_sap_points (NULL for new-approach → "Unknown"). See ADR-0002. + ELSE ${effectiveSapSql} + END AS band_sap FROM property p LEFT JOIN property_details_epc e @@ -335,71 +378,31 @@ export async function GET( WHERE p.portfolio_id = ${pid} ) q; - `); + `; + // One parallel wave: scenario metrics, upgrade costs, portfolio-after. + const [scenarioMetricsResult, upgradedResult, portfolioMetricsResult] = + await Promise.all([ + db.execute(scenarioMetricsSql), + db.execute(upgradedCostsSql), + db.execute(portfolioMetricsSql), + ]); + + const scenarioAgg = scenarioMetricsResult.rows[0] as ScenarioAggregates; + const upgraded = upgradedResult.rows[0] as UpgradedAggregates; const portfolioAgg = portfolioMetricsResult.rows[0] as PortfolioAggregates; - /* ---------------------------------------------------------- - QUERY 3 — EPC band distribution (ALL properties) - ---------------------------------------------------------- */ - const epcRows = await db.execute(sql` - SELECT - CASE - -- A retrofit scenario can't make a property worse. The engine writes a - -- target-level post_sap (e.g. ~C) even for properties already above the - -- target, so post_sap can sit BELOW the baseline — which made the chart - -- show e.g. B properties "improving" down to C. Clamp to the baseline. - WHEN lp.id IS NOT NULL THEN GREATEST(${effectiveSapSql}, lp.post_sap_points) - -- No qualifying plan → unchanged property. Use the effective - -- (re-baselined) baseline to match the "before" distribution; NOT - -- p.current_sap_points (NULL for new-approach → "Unknown"). See ADR-0002. - ELSE ${effectiveSapSql} - END AS effective_sap - FROM property p - LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id - LEFT JOIN epc_property epl ON epl.property_id = p.id AND epl.source = 'lodged' - LEFT JOIN property_details_epc e ON e.property_id = p.id - LEFT JOIN LATERAL ( - SELECT * - FROM plan - WHERE plan.property_id = p.id - AND plan.portfolio_id = ${pid} - AND plan.scenario_id = ${sid} - AND ( - ${hideNonCompliant} = false - OR ( - ${minSap}::float IS NOT NULL - AND plan.post_sap_points >= ${minSap}::float - ) - ) - AND ( - ${useLodgedBaseline} = false - OR ${minSap}::float IS NULL - OR COALESCE((${lodgedSapSql}) < ${minSap}::float, true) - ) - AND ${passesComplianceWindowSql} - ORDER BY created_at DESC - LIMIT 1 - ) lp ON true - WHERE p.portfolio_id = ${pid}; - `); - const scenario_epc_counts: Record = { - A: 0, - B: 0, - C: 0, - D: 0, - E: 0, - F: 0, - G: 0, - Unknown: 0, + A: portfolioAgg.band_a, + B: portfolioAgg.band_b, + C: portfolioAgg.band_c, + D: portfolioAgg.band_d, + E: portfolioAgg.band_e, + F: portfolioAgg.band_f, + G: portfolioAgg.band_g, + Unknown: portfolioAgg.band_unknown, }; - for (const row of epcRows.rows as EpcRow[]) { - const band = sapToEpc(row.effective_sap); - scenario_epc_counts[band] += 1; - } - /* ---------------------------------------------------------- RESPONSE ---------------------------------------------------------- */ 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 b5f56284..e45743be 100644 --- a/src/app/api/portfolio/[portfolioId]/scenario/default/metrics/route.ts +++ b/src/app/api/portfolio/[portfolioId]/scenario/default/metrics/route.ts @@ -1,7 +1,6 @@ import { db } from "@/app/db/db"; import { sql } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; -import { sapToEpc } from "@/app/utils"; import type { PortfolioGoalType } from "@/app/db/schema/portfolio"; import { newApproachJoins, @@ -37,11 +36,33 @@ type PortfolioAggregates = { avg_bills: number | null; total_carbon: number | null; total_bills: number | null; + // EPC-band buckets, computed in SQL rather than shipped one row per property. + band_a: number; + band_b: number; + band_c: number; + band_d: number; + band_e: number; + band_f: number; + band_g: number; + band_unknown: number; }; -type EpcRow = { - effective_sap: number | null; -}; +/** + * EPC-band buckets by SAP, matching sapToEpc's thresholds exactly (A≥92, B≥81, + * C≥69, D≥55, E≥39, F≥21, else G; NULL → Unknown). Bucketing in SQL keeps the + * band distribution to a single aggregate row instead of returning every + * property's SAP for the client to loop over. + */ +const BAND_BUCKETS_SQL = sql` + COUNT(*) FILTER (WHERE band_sap >= 92)::int AS band_a, + COUNT(*) FILTER (WHERE band_sap >= 81 AND band_sap < 92)::int AS band_b, + COUNT(*) FILTER (WHERE band_sap >= 69 AND band_sap < 81)::int AS band_c, + COUNT(*) FILTER (WHERE band_sap >= 55 AND band_sap < 69)::int AS band_d, + COUNT(*) FILTER (WHERE band_sap >= 39 AND band_sap < 55)::int AS band_e, + COUNT(*) FILTER (WHERE band_sap >= 21 AND band_sap < 39)::int AS band_f, + COUNT(*) FILTER (WHERE band_sap IS NOT NULL AND band_sap < 21)::int AS band_g, + COUNT(*) FILTER (WHERE band_sap IS NULL)::int AS band_unknown +`; /* ======================= Constants @@ -75,10 +96,16 @@ export async function GET( const hideNonCompliant = request.nextUrl.searchParams.get("hideNonCompliant") === "true"; + /* ---------------------------------------------------------- + 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 + EPC-distribution scan into its own aggregate. + ---------------------------------------------------------- */ + /* ---------------------------------------------------------- QUERY 1 — Scenario metrics (PLANS ONLY) ---------------------------------------------------------- */ - const scenarioMetricsResult = await db.execute(sql` + const scenarioMetricsSql = sql` WITH latest_plans AS ( SELECT DISTINCT ON (property_id) * @@ -109,14 +136,12 @@ export async function GET( 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; - `); - - const scenarioAgg = scenarioMetricsResult.rows[0] as ScenarioAggregates; + `; /* ---------------------------------------------------------- QUERY 1b — Upgrade costs (PLANS ONLY) ---------------------------------------------------------- */ - const upgradedResult = await db.execute(sql` + const upgradedCostsSql = sql` WITH latest_plans AS ( SELECT DISTINCT ON (property_id) * @@ -143,20 +168,21 @@ export async function GET( -- exclude it from the count + cost. COALESCE keeps rows we can't compare -- (NULL post or NULL baseline). See ADR-0002. AND COALESCE(lp.post_sap_points >= (${effectiveSapSql}), true); - `); - - const upgraded = upgradedResult.rows[0] as UpgradedAggregates; + `; /* ---------------------------------------------------------- QUERY 2 — Portfolio AFTER scenario (ALL properties) + Folds the former EPC-distribution scan (Query 3) in: one per-property + LATERAL pass yields both the aggregates and the band buckets. ---------------------------------------------------------- */ - const portfolioMetricsResult = await db.execute(sql` + const portfolioMetricsSql = sql` SELECT AVG(effective_sap)::float AS avg_sap, AVG(effective_carbon)::float AS avg_carbon, AVG(effective_bills)::float AS avg_bills, SUM(effective_carbon)::float AS total_carbon, - SUM(effective_bills)::float AS total_bills + SUM(effective_bills)::float AS total_bills, + ${BAND_BUCKETS_SQL} FROM ( SELECT /* ---------- SAP ---------- */ @@ -175,7 +201,21 @@ export async function GET( CASE WHEN lp.id IS NOT NULL THEN lp.post_energy_bill ELSE ${billsSql(sql`e`)} - END AS effective_bills + END AS effective_bills, + + /* ---------- Band SAP (EPC distribution) ---------- */ + CASE + -- A retrofit scenario can't make a property worse. The engine writes a + -- target-level post_sap (e.g. ~C) even for properties already above the + -- target, so post_sap can sit BELOW the baseline — which made the chart + -- show e.g. B properties "improving" down to C. Clamp to the baseline so + -- the post-scenario band is never worse than before. + WHEN lp.id IS NOT NULL THEN GREATEST(${effectiveSapSql}, lp.post_sap_points) + -- No plan → unchanged property. Use the effective (re-baselined) baseline + -- so this matches the "before" distribution — NOT p.current_sap_points, + -- which is NULL for new-approach properties. See ADR-0002. + ELSE ${effectiveSapSql} + END AS band_sap FROM property p LEFT JOIN property_details_epc e @@ -194,57 +234,31 @@ export async function GET( WHERE p.portfolio_id = ${pid} ) q; - `); + `; + // One parallel wave: scenario metrics, upgrade costs, portfolio-after. + const [scenarioMetricsResult, upgradedResult, portfolioMetricsResult] = + await Promise.all([ + db.execute(scenarioMetricsSql), + db.execute(upgradedCostsSql), + db.execute(portfolioMetricsSql), + ]); + + const scenarioAgg = scenarioMetricsResult.rows[0] as ScenarioAggregates; + const upgraded = upgradedResult.rows[0] as UpgradedAggregates; const portfolioAgg = portfolioMetricsResult.rows[0] as PortfolioAggregates; - /* ---------------------------------------------------------- - QUERY 3 — EPC band distribution (ALL properties) - ---------------------------------------------------------- */ - const epcRows = await db.execute(sql` - SELECT - CASE - -- A retrofit scenario can't make a property worse. The engine writes a - -- target-level post_sap (e.g. ~C) even for properties already above the - -- target, so post_sap can sit BELOW the baseline — which made the chart - -- show e.g. B properties "improving" down to C. Clamp to the baseline so - -- the post-scenario band is never worse than before. - WHEN lp.id IS NOT NULL THEN GREATEST(${effectiveSapSql}, lp.post_sap_points) - -- No plan → unchanged property. Use the effective (re-baselined) baseline - -- so this matches the "before" distribution — NOT p.current_sap_points, - -- which is NULL for new-approach properties. See ADR-0002. - ELSE ${effectiveSapSql} - END AS effective_sap - FROM property p - LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id - LEFT JOIN LATERAL ( - SELECT * - FROM plan - WHERE plan.property_id = p.id - AND plan.portfolio_id = ${pid} - AND plan.is_default = true - ORDER BY created_at DESC - LIMIT 1 - ) lp ON true - WHERE p.portfolio_id = ${pid}; - `); - const scenario_epc_counts: Record = { - A: 0, - B: 0, - C: 0, - D: 0, - E: 0, - F: 0, - G: 0, - Unknown: 0, + A: portfolioAgg.band_a, + B: portfolioAgg.band_b, + C: portfolioAgg.band_c, + D: portfolioAgg.band_d, + E: portfolioAgg.band_e, + F: portfolioAgg.band_f, + G: portfolioAgg.band_g, + Unknown: portfolioAgg.band_unknown, }; - for (const row of epcRows.rows as EpcRow[]) { - const band = sapToEpc(row.effective_sap); - scenario_epc_counts[band] += 1; - } - /* ---------------------------------------------------------- RESPONSE ---------------------------------------------------------- */ diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts b/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts index f4155a09..ea980b2b 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts @@ -31,46 +31,107 @@ import { export type { ReportingScenario }; -export async function getPortfolioCounts(portfolioId: number): Promise { - const result = await db.execute<{ total: number }>(sql` - SELECT COUNT(*)::int AS total - FROM property - WHERE portfolio_id = ${portfolioId}; - `); +/** Bands the single-pass baseline query buckets into, in display order. */ +const BASELINE_EPC_BANDS = [ + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "Unknown", +] as const; - return result.rows[0].total; -} +/** + * One-pass baseline aggregate. Total, averages, totals, EPC-band distribution, + * estimated/actual split and expired count all share the *identical* + * `property ⋈ property_details_epc ⋈ newApproachJoins` scan, so they collapse + * into a single conditional-aggregation query rather than six separate full + * scans of the same portfolio — which was the bulk of the reporting page's + * 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<{ + total: number; + averages: AverageMetrics; + totals: TotalMetrics; + epcBands: EpcBandCount[]; + estimatedCounts: EstimatedCounts; + expiredEpcs: number; +}> { + const est = estimatedSql(sql`e`); + const bandExpr = sql`COALESCE((${effectiveEpcBandSql})::text, 'Unknown')`; -export async function getAverages( - portfolioId: number, -): Promise { - const result = await db.execute(sql` + // Per-band actual/estimated counts as FILTER columns — replaces the GROUP BY + // in the old getCountByEpcBand. Aliases derive from the constant band list. + const bandCols = sql.join( + BASELINE_EPC_BANDS.flatMap((b) => { + const k = b.toLowerCase(); + return [ + sql`COUNT(*) FILTER (WHERE ${bandExpr} = ${b} AND ${est} = false)::int AS ${sql.raw(`epc_${k}_actual`)}`, + sql`COUNT(*) FILTER (WHERE ${bandExpr} = ${b} AND ${est} = true)::int AS ${sql.raw(`epc_${k}_estimated`)}`, + ]; + }), + sql`, `, + ); + + const result = await db.execute>(sql` SELECT + COUNT(*)::int AS total, AVG(${effectiveSapSql})::float AS avg_sap, AVG(${carbonSql(sql`e`)})::float AS avg_carbon, AVG(${billsSql(sql`e`)})::float AS avg_bills, - AVG(${energyConsumptionSql(sql`e`)})::float AS avg_energy_consumption - FROM property p - LEFT JOIN property_details_epc e ON e.property_id = p.id - ${newApproachJoins} - WHERE p.portfolio_id = ${portfolioId}; - `); - - return result.rows[0]; -} - -export async function getTotals(portfolioId: number): Promise { - const result = await db.execute(sql` - SELECT + AVG(${energyConsumptionSql(sql`e`)})::float AS avg_energy_consumption, SUM(${carbonSql(sql`e`)})::float AS total_carbon, - SUM(${billsSql(sql`e`)})::float AS total_bills + SUM(${billsSql(sql`e`)})::float AS total_bills, + SUM(CASE WHEN ${est} = true THEN 1 ELSE 0 END)::int AS estimated, + SUM(CASE WHEN ${est} = false THEN 1 ELSE 0 END)::int AS actual, + SUM( + CASE + WHEN ${isExpiredSql(sql`e`)} = true AND ${est} = false THEN 1 + ELSE 0 + END + )::int AS expired, + ${bandCols} FROM property p LEFT JOIN property_details_epc e ON e.property_id = p.id ${newApproachJoins} WHERE p.portfolio_id = ${portfolioId}; `); - return result.rows[0]; + const row = result.rows[0]; + + // Omit zero-count bands to mirror the old GROUP BY (which only emitted bands + // that were present); the ladder + toBandCounts both tolerate the absence. + const epcBands: EpcBandCount[] = BASELINE_EPC_BANDS.map((b) => { + const k = b.toLowerCase(); + return { + epc: b, + actual: Number(row[`epc_${k}_actual`] ?? 0), + estimated: Number(row[`epc_${k}_estimated`] ?? 0), + }; + }).filter((band) => band.actual + band.estimated > 0); + + return { + total: Number(row.total ?? 0), + averages: { + avg_sap: row.avg_sap as number | null, + avg_carbon: row.avg_carbon as number | null, + avg_bills: row.avg_bills as number | null, + avg_energy_consumption: row.avg_energy_consumption as number | null, + }, + totals: { + total_carbon: row.total_carbon as number | null, + total_bills: row.total_bills as number | null, + }, + epcBands, + estimatedCounts: { + estimated: Number(row.estimated ?? 0), + actual: Number(row.actual ?? 0), + }, + expiredEpcs: Number(row.expired ?? 0), + }; } export async function getCountByAgeBand( @@ -101,59 +162,6 @@ export async function getCountByAgeBand( return result.rows; } -export async function getCountByEpcBand( - portfolioId: number, -): Promise { - const result = await db.execute(sql` - SELECT * - FROM ( - SELECT - COALESCE((${effectiveEpcBandSql})::text, 'Unknown') AS epc, - COUNT(*) FILTER ( - WHERE ${estimatedSql(sql`e`)} = false - )::int AS actual, - COUNT(*) FILTER ( - WHERE ${estimatedSql(sql`e`)} = true - )::int AS estimated - FROM property p - LEFT JOIN property_details_epc e - ON e.property_id = p.id - ${newApproachJoins} - WHERE p.portfolio_id = ${portfolioId} - GROUP BY epc - ) q - ORDER BY - CASE - WHEN q.epc = 'A' THEN 1 - WHEN q.epc = 'B' THEN 2 - WHEN q.epc = 'C' THEN 3 - WHEN q.epc = 'D' THEN 4 - WHEN q.epc = 'E' THEN 5 - WHEN q.epc = 'F' THEN 6 - WHEN q.epc = 'G' THEN 7 - ELSE 8 - END; - `); - - return result.rows; -} - -export async function getEstimatedCounts( - portfolioId: number, -): Promise { - const result = await db.execute(sql` - SELECT - SUM(CASE WHEN ${estimatedSql(sql`e`)} = true THEN 1 ELSE 0 END)::int AS estimated, - SUM(CASE WHEN ${estimatedSql(sql`e`)} = false THEN 1 ELSE 0 END)::int AS actual - FROM property p - LEFT JOIN property_details_epc e ON e.property_id = p.id - ${newApproachJoins} - WHERE p.portfolio_id = ${portfolioId}; - `); - - return result.rows[0]; -} - export async function getCountByPropertyType( portfolioId: number, ): Promise { @@ -169,25 +177,6 @@ export async function getCountByPropertyType( return result.rows; } -export async function getExpiredEpcCount(portfolioId: number): Promise { - const result = await db.execute<{ expired: number }>(sql` - SELECT - SUM( - CASE - WHEN ${isExpiredSql(sql`e`)} = true AND ${estimatedSql(sql`e`)} = false - THEN 1 - ELSE 0 - END - )::int AS expired - FROM property p - LEFT JOIN property_details_epc e ON e.property_id = p.id - ${newApproachJoins} - WHERE p.portfolio_id = ${portfolioId}; - `); - - return result.rows[0].expired; -} - export async function getLikelyDowngrades( portfolioId: number, ): Promise { @@ -213,34 +202,23 @@ export async function getLikelyDowngrades( export async function loadBaselineMetrics( portfolioId: number, ): Promise { - const [ - total, - averages, - totals, - ageBands, - epcBands, - estimatedCounts, - expiredEpcs, - likelyDowngrades, - ] = await Promise.all([ - getPortfolioCounts(portfolioId), - getAverages(portfolioId), - getTotals(portfolioId), + // 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), - getCountByEpcBand(portfolioId), - getEstimatedCounts(portfolioId), - getExpiredEpcCount(portfolioId), getLikelyDowngrades(portfolioId), ]); return { - total, - averages, - totals, + total: aggregates.total, + averages: aggregates.averages, + totals: aggregates.totals, ageBands, - epcBands, - estimatedCounts, - expiredEpcs, + epcBands: aggregates.epcBands, + estimatedCounts: aggregates.estimatedCounts, + expiredEpcs: aggregates.expiredEpcs, likelyDowngrades, }; }