Both were per-property LATERAL lookups against tables with no index on the
join column (property_targets.property_id, funding_package.plan_id), so each
seq-scanned 150k-180k rows once per property — ~3.4s of the load for a
291-property portfolio came from property_targets alone.
Neither value is actually used by the portfolio table: the "Expected EPC" column
derives from currentSapPoints + recommendation SAP, and fundingScheme is fetched
separately by the proposals table. Drop both reads (kept as NULL to preserve the
PropertyWithRelations shape) and trim the plan LATERAL to the columns used.
291-property portfolio: ~3.5s -> ~85ms.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
getPropertyMeta self-fetched process.env.URL + /api/property-meta. That URL can
resolve to a different deployment (e.g. production), so the building-passport
pages were reading property meta from OLD code — the backfilled headline SAP/EPC
never arrived and the current rating rendered blank, regardless of caching.
Extract resolvePropertyMeta() and call it directly from both the route and
getPropertyMeta, so the page runs identical in-process logic. JSON round-trip
through serializeBigInt preserves the previous shape (bigints/dates as strings).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
getPropertyMeta self-fetched /api/property-meta with revalidate:60 (despite a
comment saying it shouldn't cache). Vercel's Data Cache served a stale response
from before the headline backfill, so the current EPC rating / SAP rendered
blank on the plans and plan-detail pages even though the API now returns them.
Use cache:"no-store" so backfilled headline values propagate immediately.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
getRecommendations read the retired plan_recommendations join table, which is
empty for new-approach plans, so the plan detail page showed no recommendations.
Read the recommendation table directly. Because recommendation.plan_id is
unindexed (26M rows), key off the plan's property_id (indexed) and filter
plan_id in memory — fast for any plan, default or not.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
/api/properties timed out (15s) on just 12 properties: joining the 26M-row
recommendation table on the unindexed plan_id forced a full-table hash, and the
multi-million-row plan/funding joins compounded it.
Rewrite getProperties/getPropertiesCount to read the denormalised model (no
plan_recommendations / recommendation_materials): one-row LATERAL lookups for
target/plan/funding and a recommendation aggregate keyed by the INDEXED
recommendation.property_id, dropping the GROUP BY entirely. ~1s -> instant for a
12-property portfolio.
Also fix the reporting page for new-approach properties (current_sap_points /
current_epc_rating on the property row are unwritten): source avg SAP and the
EPC band distribution from property_baseline_performance (lodged_sap_score /
lodged_epc_band) via new sapSql / epcBandSql helpers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per domain semantics: lodged_* on property_baseline_performance is the
gov-registry (actual) figure, effective_* is the modelling-adjusted baseline.
Use the lodged columns for the "current" carbon/primary-energy display and the
individual cost columns (summed) for bills. Bills return NULL when no baseline
row exists, so they're excluded from AVG/SUM rather than shown as £0.
(property_baseline_performance is still unwritten DB-wide; this makes the reads
correct for when the pipeline populates it.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
For new-approach properties the modelling pipeline currently writes only
epc_property + epc_energy_element + plan/recommendation — property_baseline_
performance, epc_property_energy_performance metrics and the property-row
headline columns are not yet populated.
- layout: guard propertyMeta.detailsEpc?.estimated (detailsEpc can be absent)
- resolveConditionReport: degrade to an all-Unknown report instead of throwing
when the EPC graph/legacy row is missing (no more 500s)
- getProperties: join recommendation via recommendation.plan_id instead of the
retired plan_recommendations table, so Plan Cost / recommendation SAP populate
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The new modelling pipeline no longer writes property_details_epc. Route all
EPC reads through src/lib/services/epcSources.ts, which branches on the cutoff
(property.updated_at >= 2026-06-01): legacy rows keep reading
property_details_epc; new-approach rows read epc_property (+ children),
property_baseline_performance, and plan/recommendation.
- per-property: resolveConditionReport (building passport),
resolveDetailsEpcMeta (/api/property-meta)
- aggregates: shared carbon/bills/energy/estimated/expired/floor-area/
lodgement/mainfuel SQL fragments + newApproachJoins, wired into the
portfolio list + filters, reporting metrics, and both scenario metrics routes
estimated = predicted-only EPC. Gracefully omits gap fields for new
properties (mainfuel, energyTariff, floorHeight, installed-measures bill
adjustment, SAP-05 likely-downgrade).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a `source` discriminator to `epc_property` so a property can hold a
lodged and a predicted EPC at the same time, per the EPC Prediction
production-wiring branch in the Model repo (docs/MIGRATION_NOTE_predicted_epc_source.md).
- `source` text NOT NULL default 'lodged' — backfills every existing row as
a real lodged EPC.
- Unique index becomes (property_id, portfolio_id, source) so lodged +
predicted rows can coexist for the same property/portfolio.
- New (property_id, source) index — lodged/predicted reads filter on both.
Allowed values: 'lodged' | 'predicted'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Self-contained brief to start a fresh context implementing the per-Property fact
layer in the bulk_upload_finaliser: locked decisions, the four-input assembly,
the two open hazards (classifier-CSV↔combiner-output join key; property_id for
no-UPRN rows), candidate architectures, and a first-steps list. v1 (async
finalise writing `property`) is shipped and working.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The async finaliser (ADR-0005) introduced the `finalising` status, but the
server page's STATUS_CONFIG had no entry for it, so it fell through to the
`ready_for_processing` fallback ("Awaiting column mapping") and never mounted the
live poller — the page looked stuck even though the Lambda had inserted the
properties and written `complete`.
- Add the `finalising` card ("Uploading to ARA") to STATUS_CONFIG.
- Render OnboardingProgress during `finalising` so it polls live.
- Refresh the server page once when the poll first sees a terminal status
(guarded by a new `serverStatus` prop to avoid a loop; uses react-query v4
onSuccess, no useEffect) so it advances to the "Processing complete" card.
- Add a `finalising` → "Uploading to ARA" badge on the uploads list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
For uploads with classifier columns, surface the classifier's
description→enum assumptions on the awaiting_review screen so the user can
correct any (written source='user') and acknowledge before Finalise.
Previously only the multi-entry order step existed, so non-multi-entry
uploads got no classification review at all and the assumptions were
applied silently.
- detectMultiEntry: capture a sample whenever classifier columns are
mapped (largest-count row if multi-entry, else first classified row);
the sample now carries all classifier columns. "sample != null" means
"there is something to verify"; largestCount >= 2 stays the multi-entry
signal.
- setVerifyAck + verify-classification PATCH route + useConfirmVerification.
- VerifyClassificationPanel (Step 1); MultiEntryOrderingPanel slimmed to
order-only with read-only classification annotation; canFinalize gated
on both steps where each applies.
- Unit tests for detectMultiEntry + ordering helpers.
The verify_ack column + 0219 migration landed separately via #303.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>