From a17ffdefd1cc99cf82ed53c744d8f18ef85fdc58 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 8 Jul 2026 10:08:39 +0000 Subject: [PATCH] perf/fix(reporting): faster measures query, cache scenarios, fix deep-link 404s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measures query: join recommendations on plan_id alone (dropping the redundant property_id predicate so idx_recommendation_plan_id is used) and GROUP BY measure_type only — the UI buckets by category and never read the 'type' variant, which was multiplying the grouped row count. Documents the partial-index follow-up that makes it near index-only. Caching: scenario metrics and measures now hold a 5-min staleTime / 30-min cacheTime, so switching scenarios and back is instant rather than a re-query. Deep-link 404s: /your-projects has no index route — it never existed. Drill 'Open in property table' now goes to the portfolio property table (/portfolio/[id]); per-row 'Open' goes to the building passport (/building-passport/[propertyId]); 'View upgraded homes' to the table. tsc clean, tests green. Co-Authored-By: Claude Fable 5 --- .../scenario/[scenarioId]/measures/route.ts | 30 +++++++++---------- .../reporting/ReportingClientArea.tsx | 4 ++- .../reporting/components/DrillDownShelf.tsx | 10 +++---- .../components/MeasureAllocation.tsx | 4 +++ 4 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/measures/route.ts b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/measures/route.ts index 21aaed37..bbf73b3b 100644 --- a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/measures/route.ts +++ b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/measures/route.ts @@ -4,8 +4,6 @@ import { NextRequest, NextResponse } from "next/server"; type MeasureAggregateRow = { measure_type: string | null; - type: string | null; - includes_battery: boolean | null; homes_count: number; total_cost: number | null; average_cost: number | null; @@ -20,15 +18,22 @@ export async function GET( const pid = BigInt(portfolioId); const sid = BigInt(scenarioId); - // Latest plan per property for this scenario, then its recommendations read - // through the DENORMALISED link: join by the indexed recommendation.property_id - // and scope to the plan via recommendation.plan_id. The plan_recommendations - // join table is retired (no rows for new-approach plans), so the old EXISTS - // against it returned zero measures. See the handover / ADR notes. + // Latest plan per property for this scenario, then its default active + // recommendations. The join is on plan_id alone (it uniquely identifies the + // plan and its property) — dropping the redundant property_id predicate lets + // Postgres use idx_recommendation_plan_id cleanly. Grouped by measure_type + // only: the reporting UI buckets by measure category and never reads the + // `type` variant, so grouping by it just multiplied the row count. + // + // PERF: for large portfolios the recommendation filter (default = true AND + // already_installed = false) still scans all of a plan's recommendations. + // A partial index recommendation(plan_id) WHERE default AND NOT + // already_installed would make this near index-only — proposed as a + // follow-up migration (kept out of this PR per the migrations-in-own-PR + // convention). const result = await db.execute(sql` SELECT r.measure_type, - r.type, COUNT(DISTINCT r.property_id)::int AS homes_count, SUM(r.estimated_cost)::float AS total_cost, AVG(r.estimated_cost)::float AS average_cost @@ -41,23 +46,18 @@ export async function GET( ORDER BY property_id, created_at DESC ) lp JOIN recommendation r - ON r.property_id = lp.property_id - AND r.plan_id = lp.id + ON r.plan_id = lp.id AND r.default = true AND r.already_installed = false - GROUP BY - r.measure_type, - r.type + GROUP BY r.measure_type ORDER BY total_cost DESC; `); const measures = (result.rows as MeasureAggregateRow[]).map((row) => ({ measureType: row.measure_type ?? "unknown", - type: row.type ?? "unknown", homesCount: row.homes_count, totalCost: Number(row.total_cost ?? 0), averageCost: Number(row.average_cost ?? 0), - // includesBattery: row.includes_battery ?? false, })); return NextResponse.json({ diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx index 11572cdc..7421ddcf 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx @@ -87,6 +87,8 @@ export function ReportingClientArea({ enabled: isScenario, keepPreviousData: true, refetchOnWindowFocus: false, + staleTime: 5 * 60 * 1000, + cacheTime: 30 * 60 * 1000, }); const total = baseline.total; @@ -148,7 +150,7 @@ export function ReportingClientArea({ unit: `of ${total}`, sub: ( View upgraded homes → diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/components/DrillDownShelf.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/components/DrillDownShelf.tsx index b994c827..24459b2b 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/components/DrillDownShelf.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/components/DrillDownShelf.tsx @@ -80,10 +80,10 @@ export function DrillDownShelf({ const from = total === 0 ? 0 : (page - 1) * pageSize + 1; const to = Math.min(page * pageSize, total); - const tableParams = new URLSearchParams(); - if (target.band) tableParams.set("epc", target.band); - else tableParams.set("reporting", target.filter); - const tableHref = `/portfolio/${portfolioId}/your-projects?${tableParams.toString()}`; + // The property table (the portfolio home) filters client-side and doesn't + // read URL params, so we navigate to it without a filter — the shelf itself + // is the filtered view. (A URL-driven table filter is a separate follow-up.) + const tableHref = `/portfolio/${portfolioId}`; return (
@@ -152,7 +152,7 @@ export function DrillDownShelf({ Open → diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/components/MeasureAllocation.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/components/MeasureAllocation.tsx index 929de2a9..aa24094d 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/components/MeasureAllocation.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/components/MeasureAllocation.tsx @@ -55,6 +55,10 @@ export function MeasureAllocation({ return r.json(); }), refetchOnWindowFocus: false, + // Measures for a scenario don't change within a session — keep them + // cached so switching scenarios and back is instant, not a re-query. + staleTime: 5 * 60 * 1000, + cacheTime: 30 * 60 * 1000, }); const groups: MeasureGroup[] = data ? groupMeasures(data.measures) : [];