fix(plans): source headline SAP/EPC from baseline for new properties

The plans page computed the plan uplift as postSapPoints - currentSapPoints,
but current_sap_points is unwritten on the property row for new-approach
properties (null -> 0), so the uplift showed the full post score (e.g. +71.39)
and no current rating.

property-meta now backfills currentSapPoints / currentEpcRating from
property_baseline_performance (lodged_sap_score / lodged_epc_band) for
new-approach properties, fixing every propertyMeta consumer (plans page, hero
cards, building passport). e.g. 709634: current 60 (D), uplift now +11.39.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-17 14:20:49 +00:00
parent 14f06abb6d
commit 5d87481b6b
2 changed files with 49 additions and 9 deletions

View file

@ -1,7 +1,10 @@
import { db } from "@/app/db/db";
import { property } from "@/app/db/schema/property";
import { serializeBigInt } from "@/app/utils";
import { resolveDetailsEpcMeta } from "@/lib/services/epcSources";
import {
resolveDetailsEpcMeta,
resolvePropertyHeadline,
} from "@/lib/services/epcSources";
import { eq } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
@ -38,15 +41,26 @@ export async function GET(request: NextRequest, props: { params: Promise<{ prope
return new NextResponse(null, { status: 404 });
}
// `detailsEpc` is resolved via the cutoff: legacy properties read
// property_details_epc; new-approach ones read epc_property +
// property_baseline_performance. See src/lib/services/epcSources.ts.
const detailsEpc = await resolveDetailsEpcMeta(
propertyMeta.id,
propertyMeta.updatedAt,
);
// `detailsEpc` and the headline SAP/EPC are resolved via the cutoff: legacy
// properties read property_details_epc / the property row; new-approach ones
// read property_baseline_performance (the property-row headline columns aren't
// written yet). See src/lib/services/epcSources.ts.
const [detailsEpc, headline] = await Promise.all([
resolveDetailsEpcMeta(propertyMeta.id, propertyMeta.updatedAt),
resolvePropertyHeadline(propertyMeta.id, propertyMeta.updatedAt),
]);
return new NextResponse(
JSON.stringify({ ...propertyMeta, detailsEpc }, serializeBigInt),
JSON.stringify(
{
...propertyMeta,
currentSapPoints:
headline.currentSapPoints ?? propertyMeta.currentSapPoints,
currentEpcRating:
headline.currentEpcRating ?? propertyMeta.currentEpcRating,
detailsEpc,
},
serializeBigInt,
),
);
}

View file

@ -381,6 +381,32 @@ export async function resolveConditionReport(
return legacyConditionReport((legacy ?? {}) as Record<string, unknown>);
}
/**
* Headline SAP score + EPC band for the property meta. New-approach properties
* don't have `current_sap_points` / `current_epc_rating` written on the row yet,
* so we fall back to the lodged baseline (property_baseline_performance). Returns
* nulls for legacy properties, signalling the caller to keep the row values.
*
* Uses lodged_* (gov-registry actual) to match the rest of the EPC reads; swap to
* effective_* for the modelling-adjusted baseline.
*/
export async function resolvePropertyHeadline(
propertyId: bigint,
updatedAt: Date | string | null | undefined,
): Promise<{ currentSapPoints: number | null; currentEpcRating: string | null }> {
if (!isNewApproach(updatedAt)) {
return { currentSapPoints: null, currentEpcRating: null };
}
const baseline = await db.query.propertyBaselinePerformance.findFirst({
columns: { lodgedSapScore: true, lodgedEpcBand: true },
where: eq(propertyBaselinePerformance.propertyId, propertyId),
});
return {
currentSapPoints: baseline?.lodgedSapScore ?? null,
currentEpcRating: baseline?.lodgedEpcBand ?? null,
};
}
/** The minimal EPC meta the `/api/property-meta` route embeds as `detailsEpc`. */
export interface DetailsEpcMeta {
currentEnergyDemand: number | null;