mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-08-02 12:58:24 +00:00
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:
parent
e8fe65041e
commit
a17ffdefd1
4 changed files with 27 additions and 21 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 →
|
||||
|
|
|
|||
|
|
@ -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 →
|
||||
|
|
|
|||
|
|
@ -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) : [];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue