From ce2475e39550981d735d9c3718364848f53652cf Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 6 Feb 2026 10:42:46 +0000 Subject: [PATCH 1/8] setting up basic functionality for filter ooptions --- .../reporting/ReportingClientArea.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx index 509493c..04875d8 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx @@ -21,6 +21,7 @@ import type { PropertyTypeCount, ScenarioSummary, } from "./types"; +import { ReportingFunctionalityButtons } from "./ReportingFunctionalityButtons"; interface ReportingClientAreaProps { baseline: BaselineMetrics; @@ -40,7 +41,7 @@ async function fetchScenarioReport({ scenarioId: number; }) { const res = await fetch( - `/api/portfolio/${portfolioId}/scenario/${scenarioId}/metrics` + `/api/portfolio/${portfolioId}/scenario/${scenarioId}/metrics`, ); if (!res.ok) { console.error("Failed to fetch scenario report:", await res.text()); @@ -57,7 +58,7 @@ async function fetchScenarioMeasures({ scenarioId: number; }) { const res = await fetch( - `/api/portfolio/${portfolioId}/scenario/${scenarioId}/measures` + `/api/portfolio/${portfolioId}/scenario/${scenarioId}/measures`, ); if (!res.ok) { @@ -74,9 +75,10 @@ export function ReportingClientArea({ portfolioId, }: ReportingClientAreaProps) { const [selectedScenarioId, setSelectedScenarioId] = useState( - null + null, ); const [measuresOpen, setMeasuresOpen] = useState(false); + const [hideNonCompliant, setHideNonCompliant] = useState(false); const drawerOpen = Boolean(selectedScenarioId); @@ -193,6 +195,7 @@ export function ReportingClientArea({ {/* RIGHT: Actions (only when scenario selected) */} {selectedScenarioId && (
+ {/* Show measures */} + + + {/* Download PDF */} { + setAppliedHideNonCompliant(value); + }} /> {/* Download PDF */} From 1b94d8c684ed4a20e3ad190110670c61fef7e2b5 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 7 Feb 2026 17:53:21 +0000 Subject: [PATCH 3/8] Added reset functionality --- .../ReportingFunctionalityButtons.tsx | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 src/app/portfolio/[slug]/(portfolio)/reporting/ReportingFunctionalityButtons.tsx diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingFunctionalityButtons.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingFunctionalityButtons.tsx new file mode 100644 index 0000000..610cb7a --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingFunctionalityButtons.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useState } from "react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/app/shadcn_components/ui/dropdown-menu"; +import { Button } from "@/app/shadcn_components/ui/button"; +import { Checkbox } from "@/app/shadcn_components/ui/checkbox"; + +export interface ReportingFunctionalityButtonsProps { + /** Currently applied value */ + hideNonCompliant: boolean; + + /** + * Explicit user action. + * Parent decides what "apply" means (refetch, mutate, etc). + */ + onApply: (value: boolean) => Promise | void; + + disabled?: boolean; +} + +export function ReportingFunctionalityButtons({ + hideNonCompliant, + onApply, + disabled = false, +}: ReportingFunctionalityButtonsProps) { + const [draftHideNonCompliant, setDraftHideNonCompliant] = + useState(hideNonCompliant); + + const [isApplying, setIsApplying] = useState(false); + + async function handleApply() { + try { + setIsApplying(true); + await onApply(draftHideNonCompliant); + } finally { + setIsApplying(false); + } + } + + async function handleReset() { + try { + // reset the filter and trigger the fetch + setIsApplying(true); + setDraftHideNonCompliant(false); + await onApply(false); + } finally { + setIsApplying(false); + } + } + + return ( + { + if (open) { + setDraftHideNonCompliant(hideNonCompliant); + } + }} + > + + + + + +
+ {/* Filter option */} +
+ + setDraftHideNonCompliant(Boolean(checked)) + } + className="mt-1" + /> + + +
+ + {/* Actions */} +
+ + + +
+
+
+
+ ); +} From 1864cce8d1c40ac41928e831c3724a06d8c1a793 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 7 Feb 2026 19:18:59 +0000 Subject: [PATCH 4/8] debugged the updating of EPC graph with new figures and changed baselining logic --- .../scenario/[scenarioId]/metrics/route.ts | 321 +++++++++--------- .../reporting/databaseFunctions.ts | 32 +- .../[slug]/(portfolio)/reporting/page.tsx | 2 + 3 files changed, 172 insertions(+), 183 deletions(-) diff --git a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts index 69f90c2..93f3ed6 100644 --- a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts +++ b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts @@ -1,10 +1,14 @@ import { db } from "@/app/db/db"; -import { sql, SQL } from "drizzle-orm"; +import { sql } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; import { sapToEpc } from "@/app/utils"; import type { PortfolioGoalType } from "@/app/db/schema/portfolio"; -type BaselineAggregates = { +/* ======================= + Types +======================= */ + +type ScenarioAggregates = { n_units: number; avg_sap: number | null; avg_carbon: number | null; @@ -12,7 +16,6 @@ type BaselineAggregates = { total_carbon: number | null; total_bills: number | null; total_sap_uplift: number | null; - sap_points_array: (number | null)[]; }; type UpgradedAggregates = { @@ -22,6 +25,14 @@ type UpgradedAggregates = { total_funding: number | null; }; +type EpcRow = { + effective_sap: number | null; +}; + +/* ======================= + Constants +======================= */ + const EPC_MIN_SAP: Record = { A: 92, B: 81, @@ -32,38 +43,9 @@ const EPC_MIN_SAP: Record = { G: 0, }; -type PlanFilterContext = { - hideNonCompliant: boolean; - scenarioGoal: PortfolioGoalType; - scenarioGoalValue: string; -}; - -function resolvePlanFilters(ctx: PlanFilterContext): SQL[] { - const conditions: SQL[] = []; - - if (ctx.hideNonCompliant && ctx.scenarioGoal === "Increasing EPC") { - const minSap = EPC_MIN_SAP[ctx.scenarioGoalValue]; - - if (minSap !== undefined) { - conditions.push(sql` - post_sap_points IS NOT NULL - AND post_sap_points >= ${minSap} - `); - } - } - - // Additional filters can be added in the future - - return conditions; -} - -function andConditions(conditions: SQL[]): SQL { - if (conditions.length === 0) { - return sql``; - } - - return sql`AND ${sql.join(conditions, sql` AND `)}`; -} +/* ======================= + Route +======================= */ export async function GET( request: NextRequest, @@ -71,18 +53,18 @@ export async function GET( ) { const { portfolioId, scenarioId } = await props.params; - // We shouldn't have missing scenario id but this is defensive to prevent clear errors and speed up diagnosis if it happens if (!scenarioId || scenarioId === "null") { return NextResponse.json({ error: "Invalid scenarioId" }, { status: 400 }); } + const pid = BigInt(portfolioId); const sid = BigInt(scenarioId); const hideNonCompliant = request.nextUrl.searchParams.get("hideNonCompliant") === "true"; - // ---------------------------------------------------------- - // Query 0 - get the scenario definition - // ---------------------------------------------------------- + /* ---------------------------------------------------------- + Query 0 — scenario definition + ---------------------------------------------------------- */ const scenarioResult = await db.execute(sql` SELECT goal, goal_value FROM scenario @@ -92,132 +74,131 @@ export async function GET( `); const scenario = scenarioResult.rows[0] as - | { - goal: PortfolioGoalType; - goal_value: string; - } + | { goal: PortfolioGoalType; goal_value: string } | undefined; if (!scenario) { return NextResponse.json({ error: "Scenario not found" }, { status: 404 }); } - // - // ---------------------------------------------------------- - // QUERY 1 — Baseline metrics for *all* properties - // ---------------------------------------------------------- - // + const minSap = + scenario.goal === "Increasing EPC" + ? EPC_MIN_SAP[scenario.goal_value] + : null; - // resolve filters: - const filterConditions = resolvePlanFilters({ - hideNonCompliant, - scenarioGoal: scenario.goal, - scenarioGoalValue: scenario.goal_value, - }); + /* ---------------------------------------------------------- + QUERY 1 — Scenario metrics (plans ONLY) + ---------------------------------------------------------- */ + const scenarioMetricsResult = await db.execute(sql` + WITH latest_plans AS ( + SELECT DISTINCT ON (property_id) + * + FROM plan + WHERE portfolio_id = ${pid} + AND scenario_id = ${sid} + AND ( + ${hideNonCompliant} = false + OR ( + ${minSap}::float IS NOT NULL + AND post_sap_points >= ${minSap}::float + ) + ) + ORDER BY property_id, created_at DESC + ) + SELECT + COUNT(*)::int AS n_units, + AVG(post_sap_points)::float AS avg_sap, + AVG(post_co2_emissions)::float AS avg_carbon, + AVG(post_energy_bill)::float AS avg_bills, + SUM(post_co2_emissions)::float AS total_carbon, + SUM(post_energy_bill)::float AS total_bills, + SUM( + CASE + WHEN cost_of_works > 0 + AND post_sap_points IS NOT NULL + THEN post_sap_points - p.current_sap_points + ELSE 0 + END + )::float AS total_sap_uplift + FROM latest_plans lp + JOIN property p ON p.id = lp.property_id; + `); - const baselineResult = await db.execute(sql` - WITH latest_plans AS ( - SELECT DISTINCT ON (property_id) - * - FROM plan - WHERE portfolio_id = ${pid} - AND scenario_id = ${sid} - ${andConditions(filterConditions)} - ORDER BY property_id, created_at DESC -) + const scenarioAgg = scenarioMetricsResult.rows[0] as + | ScenarioAggregates + | undefined; -SELECT - COUNT(*)::int AS n_units, - - AVG(lp.post_sap_points)::float AS avg_sap, - AVG(lp.post_co2_emissions)::float AS avg_carbon, - AVG(lp.post_energy_bill)::float AS avg_bills, - - SUM(lp.post_co2_emissions)::float AS total_carbon, - SUM(lp.post_energy_bill)::float AS total_bills, - - SUM( - CASE - WHEN lp.cost_of_works > 0.01 - AND p.current_sap_points IS NOT NULL - AND lp.post_sap_points IS NOT NULL - THEN lp.post_sap_points - p.current_sap_points - ELSE 0 - END - )::float AS total_sap_uplift, - - ARRAY_AGG(lp.post_sap_points) AS sap_points_array - -FROM latest_plans lp -JOIN property p - ON p.id = lp.property_id; -`); - - const baseline = baselineResult.rows[0] as BaselineAggregates | undefined; - - if (!baseline || baseline.n_units === 0) { + if (!scenarioAgg) { return NextResponse.json( - { error: "No plans found for this scenario" }, + { error: "No scenario metrics found" }, { status: 404 }, ); } - const { - n_units, - avg_sap, - avg_carbon, - avg_bills, - total_carbon, - total_bills, - sap_points_array, - } = baseline; - - // - // ---------------------------------------------------------- - // QUERY 2 — Upgrade metrics for properties receiving work - // ---------------------------------------------------------- - // + /* ---------------------------------------------------------- + QUERY 1b — Upgrade costs (still plan-only) + ---------------------------------------------------------- */ const upgradedResult = await db.execute(sql` - WITH latest_plans AS ( - SELECT DISTINCT ON (property_id) - * - FROM plan - WHERE portfolio_id = ${pid} - AND scenario_id = ${sid} - ${andConditions(filterConditions)} - ORDER BY property_id, created_at DESC - ) - - SELECT - COUNT(*)::int AS n_units_upgraded, - SUM(cost_of_works)::float AS total_cost, - SUM(contingency_cost)::float AS contingency, - SUM( - COALESCE(fp.project_funding, 0) - + COALESCE(fp.total_uplift, 0) - )::float AS total_funding - - FROM latest_plans lp - LEFT JOIN funding_package fp - ON fp.plan_id = lp.id - WHERE lp.cost_of_works > 0.01; -`); + WITH latest_plans AS ( + SELECT DISTINCT ON (property_id) + * + FROM plan + WHERE portfolio_id = ${pid} + AND scenario_id = ${sid} + AND ( + ${hideNonCompliant} = false + OR ( + ${minSap}::float IS NOT NULL + AND post_sap_points >= ${minSap}::float + ) + ) + ORDER BY property_id, created_at DESC + ) + SELECT + COUNT(*)::int AS n_units_upgraded, + SUM(cost_of_works)::float AS total_cost, + SUM(contingency_cost)::float AS contingency, + SUM( + COALESCE(fp.project_funding, 0) + + COALESCE(fp.total_uplift, 0) + )::float AS total_funding + FROM latest_plans lp + LEFT JOIN funding_package fp ON fp.plan_id = lp.id + WHERE lp.cost_of_works > 0; + `); const upgraded = upgradedResult.rows[0] as UpgradedAggregates; - const n_units_upgraded = upgraded.n_units_upgraded; - const construction_cost = upgraded.total_cost ?? 0; - const contingency = upgraded.contingency ?? 0; - const total_funding = upgraded.total_funding ?? 0; - const net_cost = construction_cost - total_funding; - const pc_cost = construction_cost * 0.3; // Placeholder for PC cost - const total_sap_uplift = baseline.total_sap_uplift ?? 0; + /* ---------------------------------------------------------- + QUERY 2 — EPC distribution (ALL properties) + ★ This is the important new one + ---------------------------------------------------------- */ + const epcRows = await db.execute(sql` + SELECT + CASE + WHEN lp.id IS NOT NULL THEN lp.post_sap_points + ELSE COALESCE(p.current_sap_points, 0) + END AS effective_sap + FROM property p + LEFT JOIN LATERAL ( + SELECT * + FROM plan + WHERE plan.property_id = p.id + AND plan.portfolio_id = ${pid} + AND plan.scenario_id = ${sid} + AND ( + ${hideNonCompliant} = false + OR ( + ${minSap}::float IS NOT NULL + AND plan.post_sap_points >= ${minSap}::float + ) + ) + ORDER BY created_at DESC + LIMIT 1 + ) lp ON true + WHERE p.portfolio_id = ${pid}; + `); - // - // ---------------------------------------------------------- - // EPC band distribution (all properties) - // ---------------------------------------------------------- - // const scenario_epc_counts: Record = { A: 0, B: 0, @@ -229,36 +210,38 @@ JOIN property p Unknown: 0, }; - for (const sap of sap_points_array) { - const band = sapToEpc(sap); + for (const row of epcRows.rows as EpcRow[]) { + const band = sapToEpc(row.effective_sap); scenario_epc_counts[band] += 1; } - // - // ---------------------------------------------------------- - // RESPONSE - // ---------------------------------------------------------- - // + /* ---------------------------------------------------------- + RESPONSE + ---------------------------------------------------------- */ + const pc_cost = (upgraded.total_cost ?? 0) * 0.3; + return NextResponse.json({ - // Baseline metrics (all units) - avg_sap: avg_sap !== null ? Number(avg_sap).toFixed(1) : null, - avg_carbon, - avg_bills, - total_carbon, - total_bills, - n_units, + avg_sap: + scenarioAgg.avg_sap !== null + ? Number(scenarioAgg.avg_sap).toFixed(1) + : null, + avg_carbon: scenarioAgg.avg_carbon, + avg_bills: scenarioAgg.avg_bills, + total_carbon: scenarioAgg.total_carbon, + total_bills: scenarioAgg.total_bills, + n_units: scenarioAgg.n_units, scenario_epc_counts, pc_cost, - // Upgrade metrics (only properties with work) - n_units_upgraded, - construction_cost, - contingency, - total_funding, - net_cost, + + n_units_upgraded: upgraded.n_units_upgraded, + construction_cost: upgraded.total_cost ?? 0, + contingency: upgraded.contingency ?? 0, + total_funding: upgraded.total_funding ?? 0, + net_cost: (upgraded.total_cost ?? 0) - (upgraded.total_funding ?? 0), gross_per_unit: - n_units_upgraded > 0 - ? (construction_cost + pc_cost) / n_units_upgraded + upgraded.n_units_upgraded > 0 + ? ((upgraded.total_cost ?? 0) + pc_cost) / upgraded.n_units_upgraded : 0, - total_sap_uplift, + total_sap_uplift: scenarioAgg.total_sap_uplift ?? 0, }); } diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts b/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts index 25e703a..7dbf121 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts @@ -23,7 +23,7 @@ export async function getPortfolioCounts(portfolioId: number): Promise { } export async function getAverages( - portfolioId: number + portfolioId: number, ): Promise { const result = await db.execute(sql` SELECT @@ -69,7 +69,7 @@ export async function getTotals(portfolioId: number): Promise { } export async function getCountByAgeBand( - portfolioId: number + portfolioId: number, ): Promise { const result = await db.execute(sql` SELECT @@ -96,23 +96,27 @@ export async function getCountByAgeBand( } export async function getCountByEpcBand( - portfolioId: number + portfolioId: number, ): Promise { const result = await db.execute(sql` SELECT * FROM ( - SELECT + SELECT COALESCE(p.current_epc_rating::text, 'Unknown') AS epc, - SUM(CASE WHEN e.estimated = false THEN 1 ELSE 0 END)::int AS actual, - SUM(CASE WHEN e.estimated = true THEN 1 ELSE 0 END)::int AS estimated + COUNT(*) FILTER ( + WHERE e.estimated = false OR e.estimated IS NULL + )::int AS actual, + COUNT(*) FILTER ( + WHERE e.estimated = true + )::int AS estimated FROM property p - LEFT JOIN property_details_epc e + LEFT JOIN property_details_epc e ON e.property_id = p.id WHERE p.portfolio_id = ${portfolioId} GROUP BY epc ) q - ORDER BY - CASE + ORDER BY + CASE WHEN q.epc = 'A' THEN 1 WHEN q.epc = 'B' THEN 2 WHEN q.epc = 'C' THEN 3 @@ -120,7 +124,7 @@ export async function getCountByEpcBand( WHEN q.epc = 'E' THEN 5 WHEN q.epc = 'F' THEN 6 WHEN q.epc = 'G' THEN 7 - ELSE 8 -- 'Unknown' + ELSE 8 END; `); @@ -128,7 +132,7 @@ export async function getCountByEpcBand( } export async function getEstimatedCounts( - portfolioId: number + portfolioId: number, ): Promise { const result = await db.execute(sql` SELECT @@ -142,7 +146,7 @@ export async function getEstimatedCounts( } export async function getCountByPropertyType( - portfolioId: number + portfolioId: number, ): Promise { const result = await db.execute(sql` SELECT property_type AS type, COUNT(*)::int AS count @@ -173,7 +177,7 @@ export async function getExpiredEpcCount(portfolioId: number): Promise { } export async function getLikelyDowngrades( - portfolioId: number + portfolioId: number, ): Promise { const result = await db.execute<{ downgrades: number }>(sql` SELECT @@ -192,7 +196,7 @@ export async function getLikelyDowngrades( } export async function loadBaselineMetrics( - portfolioId: number + portfolioId: number, ): Promise { const [ total, diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/page.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/page.tsx index 739436e..3bd3fc2 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/page.tsx @@ -17,6 +17,8 @@ export default async function ReportingPage(props: { ]); const scenarios = await getScenarios(Number(portfolioId)); + console.log("Baseline EPC counts", baseline.epcBands); + return (
From dce30edf0034a2b279afc7c3560537fceedfc60c Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 7 Feb 2026 19:59:51 +0000 Subject: [PATCH 5/8] fixed filtering logic --- .../scenario/[scenarioId]/metrics/route.ts | 132 +++++++++++++----- .../[slug]/(portfolio)/reporting/page.tsx | 2 - src/middleware.ts | 2 +- 3 files changed, 101 insertions(+), 35 deletions(-) diff --git a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts index 93f3ed6..ce373b6 100644 --- a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts +++ b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts @@ -25,6 +25,14 @@ type UpgradedAggregates = { total_funding: number | null; }; +type PortfolioAggregates = { + avg_sap: number | null; + avg_carbon: number | null; + avg_bills: number | null; + total_carbon: number | null; + total_bills: number | null; +}; + type EpcRow = { effective_sap: number | null; }; @@ -87,7 +95,7 @@ export async function GET( : null; /* ---------------------------------------------------------- - QUERY 1 — Scenario metrics (plans ONLY) + QUERY 1 — Scenario metrics (PLANS ONLY) ---------------------------------------------------------- */ const scenarioMetricsResult = await db.execute(sql` WITH latest_plans AS ( @@ -124,19 +132,10 @@ export async function GET( JOIN property p ON p.id = lp.property_id; `); - const scenarioAgg = scenarioMetricsResult.rows[0] as - | ScenarioAggregates - | undefined; - - if (!scenarioAgg) { - return NextResponse.json( - { error: "No scenario metrics found" }, - { status: 404 }, - ); - } + const scenarioAgg = scenarioMetricsResult.rows[0] as ScenarioAggregates; /* ---------------------------------------------------------- - QUERY 1b — Upgrade costs (still plan-only) + QUERY 1b — Upgrade costs (PLANS ONLY) ---------------------------------------------------------- */ const upgradedResult = await db.execute(sql` WITH latest_plans AS ( @@ -170,14 +169,78 @@ export async function GET( const upgraded = upgradedResult.rows[0] as UpgradedAggregates; /* ---------------------------------------------------------- - QUERY 2 — EPC distribution (ALL properties) - ★ This is the important new one + QUERY 2 — Portfolio AFTER scenario (ALL properties) + ---------------------------------------------------------- */ + const portfolioMetricsResult = await db.execute(sql` + SELECT + AVG(effective_sap)::float AS avg_sap, + AVG(effective_carbon)::float AS avg_carbon, + AVG(effective_bills)::float AS avg_bills, + SUM(effective_carbon)::float AS total_carbon, + SUM(effective_bills)::float AS total_bills + FROM ( + SELECT + /* ---------- SAP ---------- */ + CASE + WHEN lp.id IS NOT NULL THEN lp.post_sap_points + ELSE p.current_sap_points + END AS effective_sap, + + /* ---------- Carbon ---------- */ + CASE + WHEN lp.id IS NOT NULL THEN lp.post_co2_emissions + ELSE e.co2_emissions + END AS effective_carbon, + + /* ---------- Bills ---------- */ + CASE + WHEN lp.id IS NOT NULL THEN lp.post_energy_bill + ELSE ( + e.heating_cost_current + + e.hot_water_cost_current + + e.lighting_cost_current + + e.appliances_cost_current + + e.gas_standing_charge + + e.electricity_standing_charge - + COALESCE(e.installed_measures_total_energy_bill_adjustment, 0) + ) + END AS effective_bills + + FROM property p + LEFT JOIN property_details_epc e + ON e.property_id = p.id + + LEFT JOIN LATERAL ( + SELECT * + FROM plan + WHERE plan.property_id = p.id + AND plan.portfolio_id = ${pid} + AND plan.scenario_id = ${sid} + AND ( + ${hideNonCompliant} = false + OR ( + ${minSap}::float IS NOT NULL + AND plan.post_sap_points >= ${minSap}::float + ) + ) + ORDER BY created_at DESC + LIMIT 1 + ) lp ON true + + WHERE p.portfolio_id = ${pid} + ) q; + `); + + const portfolioAgg = portfolioMetricsResult.rows[0] as PortfolioAggregates; + + /* ---------------------------------------------------------- + QUERY 3 — EPC band distribution (ALL properties) ---------------------------------------------------------- */ const epcRows = await db.execute(sql` SELECT CASE WHEN lp.id IS NOT NULL THEN lp.post_sap_points - ELSE COALESCE(p.current_sap_points, 0) + ELSE p.current_sap_points END AS effective_sap FROM property p LEFT JOIN LATERAL ( @@ -218,30 +281,35 @@ export async function GET( /* ---------------------------------------------------------- RESPONSE ---------------------------------------------------------- */ - const pc_cost = (upgraded.total_cost ?? 0) * 0.3; + + const constructionCost = upgraded.total_cost ?? 0; + const nUpgraded = upgraded.n_units_upgraded ?? 0; + const pc_cost = constructionCost * 0.3; return NextResponse.json({ + /* -------- portfolio-after-scenario -------- */ avg_sap: - scenarioAgg.avg_sap !== null - ? Number(scenarioAgg.avg_sap).toFixed(1) + portfolioAgg.avg_sap !== null + ? Number(portfolioAgg.avg_sap).toFixed(1) : null, - avg_carbon: scenarioAgg.avg_carbon, - avg_bills: scenarioAgg.avg_bills, - total_carbon: scenarioAgg.total_carbon, - total_bills: scenarioAgg.total_bills, - n_units: scenarioAgg.n_units, - scenario_epc_counts, - pc_cost, + avg_carbon: portfolioAgg.avg_carbon, + avg_bills: portfolioAgg.avg_bills, + total_carbon: portfolioAgg.total_carbon, + total_bills: portfolioAgg.total_bills, - n_units_upgraded: upgraded.n_units_upgraded, - construction_cost: upgraded.total_cost ?? 0, + /* -------- scenario-only -------- */ + n_units: scenarioAgg.n_units, + n_units_upgraded: nUpgraded, + construction_cost: constructionCost, contingency: upgraded.contingency ?? 0, total_funding: upgraded.total_funding ?? 0, - net_cost: (upgraded.total_cost ?? 0) - (upgraded.total_funding ?? 0), - gross_per_unit: - upgraded.n_units_upgraded > 0 - ? ((upgraded.total_cost ?? 0) + pc_cost) / upgraded.n_units_upgraded - : 0, + net_cost: constructionCost - (upgraded.total_funding ?? 0), total_sap_uplift: scenarioAgg.total_sap_uplift ?? 0, + gross_per_unit: + nUpgraded > 0 ? (constructionCost + pc_cost) / nUpgraded : 0, + + /* -------- shared -------- */ + scenario_epc_counts, + pc_cost, }); } diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/page.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/page.tsx index 3bd3fc2..739436e 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/page.tsx @@ -17,8 +17,6 @@ export default async function ReportingPage(props: { ]); const scenarios = await getScenarios(Number(portfolioId)); - console.log("Baseline EPC counts", baseline.epcBands); - return (
diff --git a/src/middleware.ts b/src/middleware.ts index f1f44e9..4fa8c46 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -37,7 +37,7 @@ export async function middleware(req: NextRequest) { export const config = { matcher: [ - // Protect only your app’s authenticated areas + // Protect only app’s authenticated areas "/home/:path*", "/portfolio/:path*", "/search/:path*", From c7e7f6c50eeae8acdffaec6c054cb4542cde1c12 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 7 Feb 2026 20:47:56 +0000 Subject: [PATCH 6/8] adjusted loading user experience to open drawer -> show loading skeletons -> show metrics --- .../reporting/ReportingClientArea.tsx | 74 ++++---- .../reporting/ScenarioFinancialDrawer.tsx | 164 ++++++++++++++---- 2 files changed, 168 insertions(+), 70 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx index 6f586e6..6ca578b 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { ScenarioSelectorWrapper } from "./scenarioSelectorWrapper"; import { DashboardSummaryCards } from "./DashboardSummaryCards"; @@ -124,6 +124,7 @@ export function ReportingClientArea({ scenarioId: selectedScenarioId!, }), enabled: measuresOpen && !!selectedScenarioId, + keepPreviousData: true, }); const scenarioLoading = isLoading && !!selectedScenarioId; @@ -160,37 +161,40 @@ export function ReportingClientArea({ // Scenario specific metrics that appear in the drawer (from API) and cannot be overlayed on baseline // ---------------------------------------- - const scenarioSpecific = scenarioData - ? { - constructionCost: scenarioData.construction_cost, - pcCost: scenarioData.pc_cost, - contingency: scenarioData.contingency, - funding: scenarioData.total_funding, - costPerSap: - scenarioData.total_sap_uplift && scenarioData.total_sap_uplift > 0 - ? (scenarioData.construction_cost + scenarioData.pc_cost) / - scenarioData.total_sap_uplift - : 0, - costPerCo2: - scenarioData.construction_cost > 0 - ? (scenarioData.construction_cost + scenarioData.pc_cost) / - ((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon) - : 0, - netCost: scenarioData.net_cost, - grossPerUnit: scenarioData.gross_per_unit, - nUnits: scenarioData.n_units_upgraded, - totalCarbonSaved: - (baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon, - totalBillsSaved: - (baseline.totals.total_bills ?? 0) - scenarioData.total_bills, - averageCaribonSaved: - ((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon) / - scenarioData.n_units_upgraded, - averageBillsSaved: - ((baseline.totals.total_bills ?? 0) - scenarioData.total_bills) / - scenarioData.n_units_upgraded, - } - : null; + const scenarioSpecific = useMemo(() => { + if (!scenarioData) return null; + + return { + constructionCost: scenarioData.construction_cost, + pcCost: scenarioData.pc_cost, + contingency: scenarioData.contingency, + funding: scenarioData.total_funding, + costPerSap: + scenarioData.total_sap_uplift && scenarioData.total_sap_uplift > 0 + ? (scenarioData.construction_cost + scenarioData.pc_cost) / + scenarioData.total_sap_uplift + : 0, + costPerCo2: + scenarioData.construction_cost > 0 + ? (scenarioData.construction_cost + scenarioData.pc_cost) / + ((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon) + : 0, + netCost: scenarioData.net_cost, + grossPerUnit: scenarioData.gross_per_unit, + nUnits: scenarioData.n_units_upgraded, + totalCarbonSaved: + (baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon, + totalBillsSaved: + (baseline.totals.total_bills ?? 0) - scenarioData.total_bills, + averageCaribonSaved: + ((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon) / + scenarioData.n_units_upgraded, + averageBillsSaved: + ((baseline.totals.total_bills ?? 0) - scenarioData.total_bills) / + scenarioData.n_units_upgraded, + }; + }, [scenarioData, baseline]); + // Baseline stays baseline const activeMetrics = baseline; @@ -272,7 +276,11 @@ export function ReportingClientArea({ subtitle="High-level insights on performance, energy, and EPC quality." /> - +
{children}
@@ -75,6 +76,7 @@ function Metric({ color, gradient, variant = "green", + loading = false, }: { label: string; value: string | number; @@ -82,15 +84,38 @@ function Metric({ color: string; gradient: string; variant?: "green" | "blue" | "purple"; + loading?: boolean; }) { + if (loading || !value) { + return ( + +
+
+
+
+ + ); + } return (
- - {value} - - {label} - + {loading ? ( +
+
+
+
+
+ ) : ( + <> + + + {value} + + + {label} + + + )}
); @@ -108,6 +133,7 @@ function PairedMetric({ gradient, iconClassName = "text-gray-700", variant = "green", + loading = false, }: { title: string; icon: React.ComponentType>; @@ -116,30 +142,62 @@ function PairedMetric({ gradient: string; iconClassName?: string; variant?: "green" | "blue" | "purple"; + loading?: boolean; }) { + if (loading || !primary.value || !secondary.value) { + return ( + +
+
+
+
+ + ); + } + return (
-
- - {title} -
- -
-
-

{primary.label}

-

- {primary.value} -

+ {loading ? ( +
+
+
+
+
+
+
+
+
+
+
+
+ ) : ( + <> +
+ + + {title} + +
-
-

{secondary.label}

-

- {secondary.value} -

-
-
+
+
+

{primary.label}

+

+ {primary.value} +

+
+ +
+

{secondary.label}

+

+ {secondary.value} +

+
+
+ + )}
); @@ -172,7 +230,7 @@ function Section({
@@ -191,6 +249,22 @@ function Section({ ); } +/* ───────────────────────────────────────────── */ +/* Loading Skeleton for dashboard cards */ +/* ───────────────────────────────────────────── */ + +function LoadingOverlay() { + return ( +
+
+ {Array.from({ length: 9 }).map((_, i) => ( +
+ ))} +
+
+ ); +} + /* ───────────────────────────────────────────── */ /* Main Drawer */ /* ───────────────────────────────────────────── */ @@ -198,10 +272,11 @@ function Section({ export function ScenarioFinancialDrawer({ open, metrics, + loading = false, }: ScenarioFinancialDrawerProps) { return ( - {open && metrics && ( + {open && ( @@ -278,32 +362,37 @@ export function ScenarioFinancialDrawer({ iconClassName="text-blue-600" primary={{ label: "Construction works", - value: `£${formatNumber(metrics.constructionCost)}`, + value: metrics + ? `£${formatNumber(metrics.constructionCost)}` + : "", }} secondary={{ label: "Project delivery", - value: `£${formatNumber(metrics.pcCost)}`, + value: metrics ? `£${formatNumber(metrics.pcCost)}` : "", }} gradient={gradients.blue} variant="blue" + loading={loading} /> @@ -321,14 +410,15 @@ export function ScenarioFinancialDrawer({ iconClassName="text-purple-700" primary={{ label: "£ per SAP point", - value: `£${formatNumber(metrics.costPerSap)}`, + value: metrics ? `£${formatNumber(metrics.costPerSap)}` : "", }} secondary={{ label: "£ per tonne CO₂", - value: `£${formatNumber(metrics.costPerCo2)}`, + value: metrics ? `£${formatNumber(metrics.costPerCo2)}` : "", }} gradient={gradients.purple} variant="purple" + loading={loading} />
From ee1e6d31ab42d1b605bb44b0117859a04007203a Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 7 Feb 2026 21:06:21 +0000 Subject: [PATCH 7/8] fixed filter skeleton ui loading states --- .../reporting/DashboardSummaryCards.tsx | 197 ++++++++++-------- .../reporting/ReportingClientArea.tsx | 19 +- 2 files changed, 125 insertions(+), 91 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/DashboardSummaryCards.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/DashboardSummaryCards.tsx index 425b1ee..439b54a 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/DashboardSummaryCards.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/DashboardSummaryCards.tsx @@ -9,23 +9,29 @@ import { } from "@/app/shadcn_components/ui/card"; import { motion } from "framer-motion"; import { Home, Zap, Leaf, LineChart, FileQuestionIcon } from "lucide-react"; -import { formatNumber } from "@/app/utils"; +import { formatNumber, sapToEpc } from "@/app/utils"; import type { AverageMetrics, EstimatedCounts, TotalMetrics, ScenarioOverlayMetrics, + MetricKey, } from "./types"; -import type { MetricKey } from "./types"; -import { sapToEpc } from "@/app/utils"; -const cardStyles = { +/* ───────────────────────────────────────────── */ +/* Style maps */ +/* ───────────────────────────────────────────── */ + +const cardStyles: Record< + MetricKey, + { icon: React.ComponentType; color: string } +> = { totalHomes: { icon: Home, color: "text-purple-600" }, avgSap: { icon: LineChart, color: "text-blue-600" }, avgCarbon: { icon: Leaf, color: "text-emerald-600" }, avgBills: { icon: Zap, color: "text-amber-600" }, missingEpc: { icon: FileQuestionIcon, color: "text-red-600" }, -} as Record; color: string }>; +}; const epcColors: Record = { A: "text-epc_a", @@ -38,24 +44,38 @@ const epcColors: Record = { Unknown: "text-gray-400", }; +/* ───────────────────────────────────────────── */ +/* Helpers */ +/* ───────────────────────────────────────────── */ + function hasOverlay( - overlay: ScenarioOverlayMetrics | undefined + overlay: ScenarioOverlayMetrics | undefined, ): overlay is ScenarioOverlayMetrics { return overlay !== undefined; } +function Skeleton({ className = "" }: { className?: string }) { + return
; +} + +/* ───────────────────────────────────────────── */ +/* Component */ +/* ───────────────────────────────────────────── */ + export function DashboardSummaryCards({ total, totals, averages, estimatedCounts, scenarioOverlay, + loading = false, }: { total: number; totals: TotalMetrics; averages: AverageMetrics; estimatedCounts: EstimatedCounts; scenarioOverlay?: ScenarioOverlayMetrics | null; + loading?: boolean; }) { const missingEpcCount = estimatedCounts.estimated; const missingEpcPercent = total > 0 ? (missingEpcCount / total) * 100 : 0; @@ -66,10 +86,7 @@ export function DashboardSummaryCards({ const hasScenario = hasOverlay(overlay); function deltaLabel(baseline: number, scenario: number) { - const b = Number(baseline); - const s = Number(scenario); - const diff = s - b; - + const diff = scenario - baseline; if (!isFinite(diff) || diff === 0) return null; const sign = diff > 0 ? "▲" : "▼"; @@ -87,10 +104,6 @@ export function DashboardSummaryCards({ key: "totalHomes", title: "Number of Homes", baseline: total, - scenario: null, - baselineTotal: undefined, - scenarioTotal: undefined, - units: "", subtitle: "Total properties in this portfolio.", }, { @@ -100,8 +113,6 @@ export function DashboardSummaryCards({ scenario: overlay?.avgSap && `${sapToEpc(overlay.avgSap.scenario)} (${overlay.avgSap.scenario} pts)`, - baselineTotal: undefined, - scenarioTotal: undefined, subtitle: "Current SAP rating across all properties.", isEpc: true, }, @@ -144,92 +155,110 @@ export function DashboardSummaryCards({ return ( + {/* Header */} - - - - - {c.title} - + {loading ? ( + <> + + + + ) : ( + <> + + + + + {c.title} + + + )} + {/* Content */} - {/* BASELINE + SCENARIO ROW */}
- {/* BASELINE COLUMN */} + {/* Baseline */}
Baseline -
- - {c.key === "avgBills" ? `£${c.baseline}` : c.baseline} - - - {/* units next to baseline average */} - {c.units && ( - {c.units} - )} -
- - {/* Baseline total */} - {c.baselineTotal !== undefined && ( - - Total:{" "} - {c.key === "avgBills" - ? `£${formatNumber(c.baselineTotal)}` - : `${formatNumber(c.baselineTotal)} tCO₂e`} - + {loading ? ( + + ) : ( +
+ + {c.key === "avgBills" ? `£${c.baseline}` : c.baseline} + + {c.units && ( + {c.units} + )} +
)} + + {c.baselineTotal !== undefined && + (loading ? ( + + ) : ( + + Total:{" "} + {c.key === "avgBills" + ? `£${formatNumber(c.baselineTotal)}` + : `${formatNumber(c.baselineTotal)} tCO₂e`} + + ))}
- {/* SCENARIO COLUMN */} + {/* Scenario */} {hasScenario && c.scenario && (
Scenario - {/* average + delta + units row */} -
- - {c.key === "avgBills" ? `£${c.scenario}` : c.scenario} - - - {c.delta && {c.delta}} -
- - {/* Scenario total */} - {c.scenarioTotal !== undefined && ( - - Total:{" "} - {c.key === "avgBills" - ? `£${formatNumber(c.scenarioTotal)}` - : `${formatNumber(c.scenarioTotal)} tCO₂e`} - + {loading ? ( + + ) : ( +
+ + {c.key === "avgBills" ? `£${c.scenario}` : c.scenario} + + {c.delta && {c.delta}} +
)} + + {c.scenarioTotal !== undefined && + (loading ? ( + + ) : ( + + Total:{" "} + {c.key === "avgBills" + ? `£${formatNumber(c.scenarioTotal)}` + : `${formatNumber(c.scenarioTotal)} tCO₂e`} + + ))}
)}
@@ -246,7 +275,11 @@ export function DashboardSummaryCards({
-

{c.subtitle}

+ {loading ? ( + + ) : ( +

{c.subtitle}

+ )}
); diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx index 6ca578b..96867f3 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx @@ -127,8 +127,6 @@ export function ReportingClientArea({ keepPreviousData: true, }); - const scenarioLoading = isLoading && !!selectedScenarioId; - // ---------------------------------------- // Build overlay for Dashboard Summary cards // ---------------------------------------- @@ -198,6 +196,8 @@ export function ReportingClientArea({ // Baseline stays baseline const activeMetrics = baseline; + const scenarioBusy = !!selectedScenarioId && (isLoading || isFetching); + return ( <>
@@ -215,22 +215,22 @@ export function ReportingClientArea({ {/* Show measures */} { setAppliedHideNonCompliant(value); }} @@ -244,11 +244,11 @@ export function ReportingClientArea({ "_blank", ); }} - disabled={scenarioLoading} + disabled={scenarioBusy} className={` rounded-md border px-3 py-2 text-sm font-medium transition ${ - scenarioLoading + scenarioBusy ? "border-gray-200 text-gray-400 cursor-not-allowed" : "hover:bg-gray-50" } @@ -279,7 +279,7 @@ export function ReportingClientArea({
@@ -289,6 +289,7 @@ export function ReportingClientArea({ averages={activeMetrics.averages} estimatedCounts={activeMetrics.estimatedCounts} scenarioOverlay={scenarioOverlay} + loading={scenarioBusy} /> Date: Sat, 7 Feb 2026 21:09:52 +0000 Subject: [PATCH 8/8] fixed refetch on window focus --- .../[slug]/(portfolio)/reporting/ReportingClientArea.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx index 96867f3..7026af4 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx @@ -110,6 +110,8 @@ export function ReportingClientArea({ hideNonCompliant: appliedHideNonCompliant, }), enabled: !!selectedScenarioId, // only run when scenario selected + keepPreviousData: true, // keep showing old data while loading new scenario or applying filter + refetchOnWindowFocus: false, }); const { @@ -125,6 +127,7 @@ export function ReportingClientArea({ }), enabled: measuresOpen && !!selectedScenarioId, keepPreviousData: true, + refetchOnWindowFocus: false, }); // ----------------------------------------