perf/fix(reporting): faster measures query, cache scenarios, fix deep-link 404s

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-08 10:08:39 +00:00
parent e8fe65041e
commit a17ffdefd1
4 changed files with 27 additions and 21 deletions

View file

@ -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({

View file

@ -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: (
<a
href={`/portfolio/${portfolioId}/your-projects?scenario=${typeof view === "number" ? view : "recommended"}&upgraded=true`}
href={`/portfolio/${portfolioId}`}
className="font-semibold text-midblue"
>
View upgraded homes

View file

@ -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 (
<div className="mt-4 overflow-hidden rounded-lg border border-midblue bg-white">
@ -152,7 +152,7 @@ export function DrillDownShelf({
</td>
<td className="px-5 py-2 text-right">
<a
href={`/portfolio/${portfolioId}/your-projects?propertyId=${p.id}`}
href={`/portfolio/${portfolioId}/building-passport/${p.id}`}
className="whitespace-nowrap font-semibold text-midblue"
>
Open

View file

@ -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) : [];