perf(reporting): collapse baseline + scenario queries, parallelize

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) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-18 21:36:57 +01:00
parent d0fdb6ad72
commit c3530e7e5c
4 changed files with 285 additions and 258 deletions

View file

@ -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 AG", async () => {

View file

@ -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 (A92, B81,
* C69, D55, E39, F21, 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<string, number> = {
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
---------------------------------------------------------- */

View file

@ -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 (A92, B81,
* C69, D55, E39, F21, 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<string, number> = {
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
---------------------------------------------------------- */

View file

@ -31,46 +31,107 @@ import {
export type { ReportingScenario };
export async function getPortfolioCounts(portfolioId: number): Promise<number> {
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<AverageMetrics> {
const result = await db.execute<AverageMetrics>(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<Record<string, number | null>>(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<TotalMetrics> {
const result = await db.execute<TotalMetrics>(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<EpcBandCount[]> {
const result = await db.execute<EpcBandCount>(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<EstimatedCounts> {
const result = await db.execute<EstimatedCounts>(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<PropertyTypeCount[]> {
@ -169,25 +177,6 @@ export async function getCountByPropertyType(
return result.rows;
}
export async function getExpiredEpcCount(portfolioId: number): Promise<number> {
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<number> {
@ -213,34 +202,23 @@ export async function getLikelyDowngrades(
export async function loadBaselineMetrics(
portfolioId: number,
): Promise<BaselineMetrics> {
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,
};
}