mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
tightening up typing for metrics api
This commit is contained in:
parent
ce2475e395
commit
61e0972737
4 changed files with 146 additions and 17 deletions
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue