tightening up typing for metrics api

This commit is contained in:
Khalim Conn-Kowlessar 2026-02-07 17:46:22 +00:00
parent ce2475e395
commit 61e0972737
4 changed files with 146 additions and 17 deletions

View file

@ -1,7 +1,8 @@
import { db } from "@/app/db/db";
import { sql } from "drizzle-orm";
import { sql, 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 = {
n_units: number;
@ -21,19 +22,99 @@ type UpgradedAggregates = {
total_funding: number | null;
};
const EPC_MIN_SAP: Record<string, number> = {
A: 92,
B: 81,
C: 69,
D: 55,
E: 39,
F: 21,
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 `)}`;
}
export async function GET(
request: NextRequest,
props: { params: Promise<{ portfolioId: string; scenarioId: string }> }
props: { params: Promise<{ portfolioId: string; scenarioId: string }> },
) {
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
// ----------------------------------------------------------
const scenarioResult = await db.execute(sql`
SELECT goal, goal_value
FROM scenario
WHERE id = ${sid}
AND portfolio_id = ${pid}
LIMIT 1
`);
const scenario = scenarioResult.rows[0] as
| {
goal: PortfolioGoalType;
goal_value: string;
}
| undefined;
if (!scenario) {
return NextResponse.json({ error: "Scenario not found" }, { status: 404 });
}
//
// ----------------------------------------------------------
// QUERY 1 — Baseline metrics for *all* properties
// ----------------------------------------------------------
//
// resolve filters:
const filterConditions = resolvePlanFilters({
hideNonCompliant,
scenarioGoal: scenario.goal,
scenarioGoalValue: scenario.goal_value,
});
const baselineResult = await db.execute(sql`
WITH latest_plans AS (
SELECT DISTINCT ON (property_id)
@ -41,6 +122,7 @@ export async function GET(
FROM plan
WHERE portfolio_id = ${pid}
AND scenario_id = ${sid}
${andConditions(filterConditions)}
ORDER BY property_id, created_at DESC
)
@ -76,7 +158,7 @@ JOIN property p
if (!baseline || baseline.n_units === 0) {
return NextResponse.json(
{ error: "No plans found for this scenario" },
{ status: 404 }
{ status: 404 },
);
}
@ -102,6 +184,7 @@ JOIN property p
FROM plan
WHERE portfolio_id = ${pid}
AND scenario_id = ${sid}
${andConditions(filterConditions)}
ORDER BY property_id, created_at DESC
)

View file

@ -6,7 +6,10 @@ import { DataItem, ChartData } from "@/app/portfolio/[slug]/utils";
import { eq } from "drizzle-orm";
import { scenario } from "@/app/db/schema/recommendations";
export async function GET(request: NextRequest, props: { params: Promise<{ scenarioId: string }> }) {
export async function GET(
request: NextRequest,
props: { params: Promise<{ scenarioId: string }> },
) {
const params = await props.params;
const scenarioId = params.scenarioId;
@ -50,7 +53,7 @@ export async function GET(request: NextRequest, props: { params: Promise<{ scena
{
scenarioName: scenarioName,
data: JSON.parse(
data[0].epcBreakdownPostRetrofit || "[]"
data[0].epcBreakdownPostRetrofit || "[]",
) as ChartData[],
},
],
@ -114,29 +117,49 @@ export async function GET(request: NextRequest, props: { params: Promise<{ scena
scenarios: [
{ scenarioName: scenarioName, data: data[0].costPerUnit || "" },
],
},
},
{
title: "Funding (£)",
scenarios: [
{ scenarioName: scenarioName, data: "£" + formatNumber(data[0].funding || 0) },
{
scenarioName: scenarioName,
data: "£" + formatNumber(data[0].funding || 0),
},
],
},
{
title: "Funding (£)/unit",
scenarios: [
{ scenarioName: scenarioName, data: "£" + formatNumber((data[0].funding || 0) / (data[0].nUnitsToRetrofit || 1)) },
{
scenarioName: scenarioName,
data:
"£" +
formatNumber(
(data[0].funding || 0) / (data[0].nUnitsToRetrofit || 1),
),
},
],
},
{
title: "Contingency (£)",
scenarios: [
{ scenarioName: scenarioName, data: "£" + formatNumber(data[0].contingency || 0) },
{
scenarioName: scenarioName,
data: "£" + formatNumber(data[0].contingency || 0),
},
],
},
{
title: "Contingency (£)/unit",
scenarios: [
{ scenarioName: scenarioName, data: "£" + formatNumber((data[0].contingency || 0) / (data[0].nUnitsToRetrofit || 1)) },
{
scenarioName: scenarioName,
data:
"£" +
formatNumber(
(data[0].contingency || 0) / (data[0].nUnitsToRetrofit || 1),
),
},
],
},
{

View file

@ -31,6 +31,14 @@ export const PortfolioGoal: [string, ...string[]] = [
"Energy Savings",
"None",
];
export type PortfolioGoalType = (typeof PortfolioGoal)[number];
export const PORTFOLIO_GOALS = {
EPC: "Increasing EPC",
VALUATION: "Valuation Improvement",
CO2: "Reducing CO2 emissions",
ENERGY: "Energy Savings",
NONE: "None",
} satisfies Record<string, PortfolioGoalType>;
export const PortfolioRole: [string, ...string[]] = [
"creator",
@ -79,10 +87,10 @@ export const portfolio = pgTable("portfolio", {
energyBillPerUnitPreRetrofit: text("energy_bill_per_unit_pre_retrofit"),
energyBillPerUnitPostRetrofit: text("energy_bill_per_unit_post_retrofit"),
energyConsumptionPerUnitPreRetrofit: text(
"energy_consumption_per_unit_pre_retrofit"
"energy_consumption_per_unit_pre_retrofit",
),
energyConsumptionPerUnitPostRetrofit: text(
"energy_consumption_per_unit_post_retrofit"
"energy_consumption_per_unit_post_retrofit",
),
valuationImprovementPerUnit: text("valuation_improvement_per_unit"),
costPerUnit: text("cost_per_unit"),

View file

@ -36,12 +36,17 @@ interface ReportingClientAreaProps {
async function fetchScenarioReport({
portfolioId,
scenarioId,
hideNonCompliant,
}: {
portfolioId: number;
scenarioId: number;
hideNonCompliant: boolean; /* this will remove plans that do not meet upgrade targets*/
}) {
const params = new URLSearchParams({
hideNonCompliant: String(hideNonCompliant),
});
const res = await fetch(
`/api/portfolio/${portfolioId}/scenario/${scenarioId}/metrics`,
`/api/portfolio/${portfolioId}/scenario/${scenarioId}/metrics?${params.toString()}`,
);
if (!res.ok) {
console.error("Failed to fetch scenario report:", await res.text());
@ -78,7 +83,8 @@ export function ReportingClientArea({
null,
);
const [measuresOpen, setMeasuresOpen] = useState<boolean>(false);
const [hideNonCompliant, setHideNonCompliant] = useState(false);
const [appliedHideNonCompliant, setAppliedHideNonCompliant] =
useState<boolean>(false);
const drawerOpen = Boolean(selectedScenarioId);
@ -88,13 +94,20 @@ export function ReportingClientArea({
const {
data: scenarioData,
isLoading,
isFetching,
isError,
} = useQuery({
queryKey: ["scenario-report", portfolioId, selectedScenarioId],
queryKey: [
"scenario-report",
portfolioId,
selectedScenarioId,
appliedHideNonCompliant,
],
queryFn: () =>
fetchScenarioReport({
portfolioId,
scenarioId: selectedScenarioId!,
hideNonCompliant: appliedHideNonCompliant,
}),
enabled: !!selectedScenarioId, // only run when scenario selected
});
@ -212,9 +225,11 @@ export function ReportingClientArea({
</button>
<ReportingFunctionalityButtons
hideNonCompliant={hideNonCompliant}
onApplyHideNonCompliant={setHideNonCompliant}
hideNonCompliant={appliedHideNonCompliant}
disabled={scenarioLoading}
onApply={async (value) => {
setAppliedHideNonCompliant(value);
}}
/>
{/* Download PDF */}