pulling from mainMerge branch 'main' of github.com:Hestia-Homes/assessment-model into feature/make_delete_work

This commit is contained in:
Jun-te Kim 2026-01-14 15:44:37 +00:00
commit e6e836d733
35 changed files with 36348 additions and 155 deletions

View file

@ -26,6 +26,7 @@ const PresignedUrlBodySchema = z
file_type: z.enum(["csv", "xlsx"]).optional(), // Specify the file type
file_format: z.enum(["domna_asset_list"]).optional().nullable(), // Specify the file format
sheet_name: z.string().optional().nullable(), // Specify the sheet name if applicable
enforce_fabric_first: z.boolean().optional().default(false),
})
.refine((data) => data.goal !== "Increasing EPC" || !!data.goal_value, {
path: ["goal_value"],

View file

@ -77,58 +77,32 @@ export async function GET(
// `);
const result = await db.execute(sql`
WITH latest_plans AS (
SELECT DISTINCT ON (property_id)
*
FROM plan
WHERE portfolio_id = ${pid}
AND scenario_id = ${sid}
ORDER BY property_id, created_at DESC
),
recommendation_flags AS (
SELECT
r.id AS recommendation_id,
r.measure_type AS measure_type,
r.property_id AS property_id,
r.estimated_cost AS estimated_cost,
r.type AS type
FROM latest_plans lp
JOIN plan_recommendations pr
ON pr.plan_id = lp.id
JOIN recommendation r
ON r.id = pr.recommendation_id
LEFT JOIN recommendation_materials rm
ON rm.recommendation_id = r.id
LEFT JOIN material m
ON m.id = rm.material_id
AND m.is_active = true
WHERE r.default = true
AND r.already_installed = false
GROUP BY
r.id,
r.measure_type,
r.type,
r.property_id,
r.estimated_cost
)
SELECT
measure_type,
type,
COUNT(DISTINCT property_id)::int AS homes_count,
SUM(estimated_cost)::float AS total_cost,
AVG(estimated_cost)::float AS average_cost
FROM recommendation_flags
r.measure_type,
r.type,
COUNT(DISTINCT r.property_id)::int AS homes_count,
SUM(r.estimated_cost)::float AS total_cost,
AVG(r.estimated_cost)::float AS average_cost
FROM recommendation r
WHERE r.default = true
AND r.already_installed = false
AND EXISTS (
SELECT 1
FROM (
SELECT DISTINCT ON (p.property_id)
p.id
FROM plan p
WHERE p.portfolio_id = ${pid}
AND p.scenario_id = ${sid}
ORDER BY p.property_id, p.created_at DESC
) lp
JOIN plan_recommendations pr
ON pr.plan_id = lp.id
WHERE pr.recommendation_id = r.id
)
GROUP BY
measure_type,
type
r.measure_type,
r.type
ORDER BY total_cost DESC;
`);

View file

@ -10,6 +10,7 @@ type BaselineAggregates = {
avg_bills: number | null;
total_carbon: number | null;
total_bills: number | null;
total_sap_uplift: number | null;
sap_points_array: (number | null)[];
};
@ -35,26 +36,39 @@ export async function GET(
//
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}
ORDER BY property_id, created_at DESC
)
SELECT DISTINCT ON (property_id)
*
FROM plan
WHERE portfolio_id = ${pid}
AND scenario_id = ${sid}
ORDER BY property_id, created_at DESC
)
SELECT
COUNT(*)::int AS n_units,
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,
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(post_co2_emissions)::float AS total_carbon,
SUM(post_energy_bill)::float AS total_bills,
SUM(lp.post_co2_emissions)::float AS total_carbon,
SUM(lp.post_energy_bill)::float AS total_bills,
ARRAY_AGG(post_sap_points) AS sap_points_array
FROM latest_plans;
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;
@ -113,7 +127,8 @@ export async function GET(
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.28; // Placeholder for PC cost
const pc_cost = construction_cost * 0.3; // Placeholder for PC cost
const total_sap_uplift = baseline.total_sap_uplift ?? 0;
//
// ----------------------------------------------------------
@ -161,5 +176,6 @@ export async function GET(
n_units_upgraded > 0
? (construction_cost + pc_cost) / n_units_upgraded
: 0,
total_sap_uplift,
});
}

View file

@ -108,9 +108,9 @@ export default function RecommendationCard({
) as Recommendation;
// A recommendation type could have no default recommendation, so we need to check if it exists
const alreadyInstalled = defaultComponent
? defaultComponent.alreadyInstalled
: false;
const alreadyInstalled = recommendationData.some(
(rec) => rec.alreadyInstalled
);
const [cardComponent, setCardComponent] =
useState<Recommendation>(defaultComponent);
@ -131,8 +131,8 @@ export default function RecommendationCard({
const cardClassName = alreadyInstalled
? alreadyInstalledStyling
: cardComponent
? selectionStyling
: noSelectionStyling;
? selectionStyling
: noSelectionStyling;
const optionTextClassName = alreadyInstalled
? "text-brandgold"
@ -141,8 +141,8 @@ export default function RecommendationCard({
const optionsText = alreadyInstalled
? "Already installed"
: cardComponent
? "Click for more options"
: "Click to select";
? "Click for more options"
: "Click to select";
const openModal = () => {
// If the card is already installed, we don't want to open the modal

View file

@ -19,12 +19,14 @@ import {
SecondaryEnergyEfficiencyImpactCard,
} from "./EnergyEfficiencyImpactCard";
import { FundingPackageWithMeasures } from "@/app/db/schema/funding";
import { InstalledMeasureSummary } from "@/app/portfolio/[slug]/building-passport/[propertyId]/utils";
interface RecommendationContainerProps {
recommendations: Recommendation[];
propertyMeta: PropertyMeta;
planMeta: Plan;
funding: FundingPackageWithMeasures[];
installedMeasures: InstalledMeasureSummary[];
}
const typeToCategoryMap: { [key in RecommendationType]?: RecommendationType } =
@ -57,7 +59,13 @@ export default function RecommendationContainer({
propertyMeta,
planMeta,
funding,
installedMeasures,
}: RecommendationContainerProps) {
// Get the unique types of installed measures for easy lookup
const installedMeasureTypeSet = new Set(
installedMeasures.map((m) => m.measureType)
);
const categorizedRecommendations = recommendations.reduce(
(acc, curr) => {
const typeKey = curr.type as RecommendationType;
@ -66,11 +74,28 @@ export default function RecommendationContainer({
if (!acc[category]) {
acc[category] = [];
}
acc[category].push(curr);
const alreadyInstalled =
curr.measureType != null &&
installedMeasureTypeSet.has(curr.measureType);
acc[category].push({
...curr,
alreadyInstalled: alreadyInstalled,
sapPoints: alreadyInstalled ? 0 : curr.sapPoints,
estimatedCost: alreadyInstalled ? 0 : curr.estimatedCost,
co2EquivalentSavings: alreadyInstalled ? 0 : curr.co2EquivalentSavings,
energyCostSavings: alreadyInstalled ? 0 : curr.energyCostSavings,
kwhSavings: alreadyInstalled ? 0 : curr.kwhSavings,
labourDays: alreadyInstalled ? 0 : curr.labourDays,
});
return acc;
},
{} as Record<RecommendationType, (typeof recommendations)[0][]>
{} as Record<
RecommendationType,
(Recommendation & { alreadyInstalled: boolean })[]
>
);
const defaultWallsRecommendations =

View file

@ -70,11 +70,6 @@ export function Toolbar({
const [openModal, setOpenModal] = useState(false);
const [showToast, setShowToast] = useState(false);
console.log(propertyId, "PropertyID")
console.log(portfolioId, "porfolio id")
console.log(propertyMeta, "property meta")
console.log(decentHomes, "decent homes")
function handleClickSettings() {
console.log("Settings were clicked, implement me");
}
@ -129,8 +124,6 @@ export function Toolbar({
</NavigationMenuLink>
);
return (
<>
<div className="flex items-center justify-between w-full">

View file

@ -0,0 +1 @@
CREATE INDEX "idx_recommendation_active_defaults" ON "recommendation" USING btree ("id") WHERE "recommendation"."default" = true AND "recommendation"."already_installed" = false;

View file

@ -0,0 +1 @@
CREATE INDEX "idx_plan_latest_per_property" ON "plan" USING btree ("portfolio_id","scenario_id","property_id","created_at" DESC NULLS LAST);

View file

@ -0,0 +1,2 @@
CREATE INDEX CONCURRENTLY "idx_plan_recommendations_plan_rec" ON "plan_recommendations" USING btree ("plan_id","recommendation_id");--> statement-breakpoint
CREATE INDEX CONCURRENTLY "idx_recommendation_active_id_property" ON "recommendation" USING btree ("id","property_id") WHERE "recommendation"."default" = true AND "recommendation"."already_installed" = false;

View file

@ -0,0 +1,19 @@
CREATE TYPE "public"."measure_type" AS ENUM('air_source_heat_pump', 'boiler_upgrade', 'high_heat_retention_storage_heaters', 'secondary_heating', 'roomstat_programmer_trvs', 'time_temperature_zone_control', 'cylinder_thermostat', 'cavity_wall_insulation', 'extension_cavity_wall_insulation', 'external_wall_insulation', 'internal_wall_insulation', 'loft_insulation', 'flat_roof_insulation', 'room_roof_insulation', 'solid_floor_insulation', 'suspended_floor_insulation', 'double_glazing', 'secondary_glazing', 'draught_proofing', 'mechanical_ventilation', 'low_energy_lighting', 'solar_pv', 'hot_water_tank_insulation', 'sealing_open_fireplace');--> statement-breakpoint
CREATE TABLE "installed_measure" (
"id" bigserial PRIMARY KEY NOT NULL,
"uprn" text NOT NULL,
"measure_type" "measure_type" NOT NULL,
"installed_at" timestamp DEFAULT now(),
"sap_points" real,
"carbon_savings" real,
"kwh_savings" real,
"bill_savings" real,
"heat_demand_savings" real,
"source" text,
"is_active" boolean DEFAULT true NOT NULL
);
--> statement-breakpoint
CREATE INDEX "idx_installed_measure_uprn" ON "installed_measure" USING btree ("uprn");--> statement-breakpoint
CREATE INDEX "idx_installed_measure_uprn_active" ON "installed_measure" USING btree ("uprn") WHERE "installed_measure"."is_active" = true;--> statement-breakpoint
CREATE INDEX "idx_installed_measure_measure_type" ON "installed_measure" USING btree ("measure_type");--> statement-breakpoint
CREATE INDEX "idx_installed_measure_uprn_measure" ON "installed_measure" USING btree ("uprn","measure_type") WHERE "installed_measure"."is_active" = true;

View file

@ -0,0 +1,3 @@
ALTER TABLE "property" ADD COLUMN "installed_measures_sap_point_adjustment" real;--> statement-breakpoint
ALTER TABLE "property" ADD COLUMN "is_sap_points_adjusted_for_installed_measures" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "property" ADD COLUMN "original_sap_points" real;

View file

@ -0,0 +1,2 @@
ALTER TABLE "property_installed_measures" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
DROP TABLE "property_installed_measures" CASCADE;--> statement-breakpoint

View file

@ -0,0 +1,9 @@
ALTER TABLE "property_details_epc" ADD COLUMN "original_co2_emissions" real;--> statement-breakpoint
ALTER TABLE "property_details_epc" ADD COLUMN "original_primary_energy_consumption" real;--> statement-breakpoint
ALTER TABLE "property_details_epc" ADD COLUMN "original_current_energy_demand" real;--> statement-breakpoint
ALTER TABLE "property_details_epc" ADD COLUMN "original_current_energy_demand_heating_hotwater" real;--> statement-breakpoint
ALTER TABLE "property_details_epc" ADD COLUMN "installed_measures_co2_adjustment" real;--> statement-breakpoint
ALTER TABLE "property_details_epc" ADD COLUMN "installed_measures_energy_demand_adjustment" real;--> statement-breakpoint
ALTER TABLE "property_details_epc" ADD COLUMN "installed_measures_total_energy_bill_adjustment" real;--> statement-breakpoint
ALTER TABLE "property_details_epc" ADD COLUMN "installed_measures_heat_demand_adjustment" real;--> statement-breakpoint
ALTER TABLE "property_details_epc" ADD COLUMN "is_epc_adjusted_for_installed_measures" boolean DEFAULT false;

View file

@ -0,0 +1,12 @@
-- migrations/2026_01_06_recommendation_cover.sql
CREATE INDEX CONCURRENTLY idx_recommendation_active_cover
ON recommendation (
id,
property_id,
measure_type,
type
)
INCLUDE (estimated_cost)
WHERE default = true
AND already_installed = false;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -995,6 +995,55 @@
"when": 1767619224711,
"tag": "0141_amazing_harry_osborn",
"breakpoints": true
},
{
"idx": 142,
"version": "7",
"when": 1767692283901,
"tag": "0142_serious_whirlwind",
"breakpoints": true
},
{
"idx": 143,
"version": "7",
"when": 1767693565375,
"tag": "0143_magenta_magus",
"breakpoints": true
},
{
"idx": 144,
"version": "7",
"when": 1767704869539,
"tag": "0144_lovely_moira_mactaggert",
"breakpoints": true
},
{
"idx": 145,
"version": "7",
"when": 1767810791075,
"tag": "0145_brave_power_pack",
"breakpoints": true
},
{
"idx": 146,
"version": "7",
"when": 1767812381964,
"tag": "0146_tiny_george_stacy",
"breakpoints": true
},
{
"idx": 147,
"version": "7",
"when": 1767814056667,
"tag": "0147_confused_killer_shrike",
"breakpoints": true
},
{
"idx": 148,
"version": "7",
"when": 1767823836420,
"tag": "0148_first_gamma_corps",
"breakpoints": true
}
]
}

View file

@ -132,6 +132,17 @@ export const property = pgTable(
currentEpcRating: epcEnum("current_epc_rating"),
currentSapPoints: real("current_sap_points"),
currentValuation: real("current_valuation"),
// When we have already installed measures, we will adjust the SAP points to reflect this. We keep a record of
// 1) The number of points we've adjusted by
// 2) a flag to indicate whether the SAP points have been adjusted, for easily filtering
installedMeasuresSapPointAdjustment: real(
"installed_measures_sap_point_adjustment"
),
isSapPointsAdjustedForInstalledMeasures: boolean(
"is_sap_points_adjusted_for_installed_measures"
).default(false),
originalSapPoints: real("original_sap_points"),
},
(table) => [
uniqueIndex("uq_property_portfolio_uprn")
@ -195,8 +206,10 @@ export const propertyDetailsEpc = pgTable(
numberStoreys: integer("number_of_storeys"),
mainsGas: boolean("mains_gas"),
energyTariff: text("energy_tariff"),
// This is heat demand
primaryEnergyConsumption: real("primary_energy_consumption"),
co2Emissions: real("co2_emissions"),
// Bad naming but currentEnergyDemand is the current kwh consumption - needs to be renamed
currentEnergyDemand: real("current_energy_demand"),
currentEnergyDemandHeatingHotwater: real(
"current_energy_demand_heating_hotwater"
@ -216,6 +229,36 @@ export const propertyDetailsEpc = pgTable(
appliancesEnergyCostCurrent: real("appliances_cost_current"),
gasStandingCharge: real("gas_standing_charge"),
electricityStandingCharge: real("electricity_standing_charge"),
// When we have already installed measures, we will adjust the carbon, bills, kwh, heat demandto reflect this. We keep a record of
// 1) The adjustments
// 2) original values
// 3) a flag to indicate whether the values have been adjusted, for easily filtering
// original values - we don't need bills because we don't actually adjust any of the originals we just subtract adjustments from current values
originalCo2Emissions: real("original_co2_emissions"),
originalPrimaryEnergyConsumption: real(
"original_primary_energy_consumption"
),
originalCurrentEnergyDemand: real("original_current_energy_demand"),
originalCurrentEnergyDemandHeatingHotwater: real(
"original_current_energy_demand_heating_hotwater"
),
// adjustment quantities
installedMeasuresCo2Adjustment: real("installed_measures_co2_adjustment"),
installedMeasuresEnergyDemandAdjustment: real(
"installed_measures_energy_demand_adjustment"
),
installedMeasuresTotalEnergyBillAdjustment: real(
"installed_measures_total_energy_bill_adjustment"
),
installedMeasuresHeatDemandAdjustment: real(
"installed_measures_heat_demand_adjustment"
),
isEpcAdjustedForInstalledMeasures: boolean(
"is_epc_adjusted_for_installed_measures"
).default(false),
},
(table) => [
uniqueIndex("uq_property_details_epc_property_portfolio").on(
@ -283,23 +326,6 @@ export const nonIntrusiveSurveyNotes = pgTable("non_intrusive_survey_notes", {
note: text("note").notNull(),
});
// This model is a record of the measures that have already been installed for a property
// This is considered as supplementary daa and stored against the UPRN
// RecommendationType is the
export const propertyInstalledMeasures = pgTable(
"property_installed_measures",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
uprn: bigint("uprn", { mode: "bigint" }).notNull(),
// The material types define the list of measures we should expect
measureType: materialTypeEnum("measure_type").notNull(),
// Record of when this measure was inserted into the db
createdAt: timestamp("created_at").notNull().defaultNow(),
// Record of when this measure was actually installed
installedAt: timestamp("installed_at").notNull().defaultNow(),
}
);
export type Property = InferModel<typeof property, "select">;
export type PropertyDetailsEpc = InferModel<
typeof propertyDetailsEpc,

View file

@ -13,9 +13,53 @@ import {
index,
} from "drizzle-orm/pg-core";
import { Material, material } from "./materials";
import { InferModel } from "drizzle-orm";
import { InferModel, sql } from "drizzle-orm";
import { z } from "zod";
// For recommendations, measure types was initially defined as a string but we should convert this to an enum in the future
export const measureTypeEnum = pgEnum("measure_type", [
// Heating systems
"air_source_heat_pump",
"boiler_upgrade",
"high_heat_retention_storage_heaters",
"secondary_heating",
// Heating controls
"roomstat_programmer_trvs",
"time_temperature_zone_control",
"cylinder_thermostat",
// Insulation
"cavity_wall_insulation",
"extension_cavity_wall_insulation",
"external_wall_insulation",
"internal_wall_insulation",
"loft_insulation",
"flat_roof_insulation",
"room_roof_insulation",
"solid_floor_insulation",
"suspended_floor_insulation",
// Windows & doors
"double_glazing",
"secondary_glazing",
"draught_proofing",
// Ventilation
"mechanical_ventilation",
// Lighting
"low_energy_lighting",
// Renewables
"solar_pv",
// Other fabric / hot water
"hot_water_tank_insulation",
"sealing_open_fireplace",
]);
export const recommendation = pgTable(
"recommendation",
{
@ -46,7 +90,20 @@ export const recommendation = pgTable(
labourDays: real("labour_days"),
alreadyInstalled: boolean("already_installed").default(false),
},
(table) => [index("recommendation_property_id_idx").on(table.propertyId)]
(table) => [
index("recommendation_property_id_idx").on(table.propertyId),
index("idx_recommendation_active_defaults")
.on(table.id)
.where(
sql`${table.default} = true AND ${table.alreadyInstalled} = false`
),
index("idx_recommendation_active_id_property")
.on(table.id, table.propertyId)
.where(
sql`${table.default} = true AND ${table.alreadyInstalled} = false`
),
]
);
export const unitQuantity: [string, ...string[]] = ["m2", "part", "kwp"];
@ -165,6 +222,12 @@ export const plan = pgTable(
table.portfolioId,
table.scenarioId
),
index("idx_plan_latest_per_property").on(
table.portfolioId,
table.scenarioId,
table.propertyId,
table.createdAt.desc()
),
]
);
@ -181,7 +244,13 @@ export const planRecommendations = pgTable(
.notNull()
.references(() => recommendation.id),
},
(table) => [index("idx_plan_recommendations_plan_id").on(table.planId)]
(table) => [
index("idx_plan_recommendations_plan_id").on(table.planId),
index("idx_plan_recommendations_plan_rec").on(
table.planId,
table.recommendationId
),
]
);
export const HousingType: [string, ...string[]] = ["Private", "Social"];
@ -241,6 +310,44 @@ export const scenario = pgTable("scenario", {
valuationReturnOnInvestment: text("valuation_return_on_investment"),
});
export const installedMeasure = pgTable(
"installed_measure",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
uprn: bigint("uprn", { mode: "bigint" }).notNull(),
measureType: measureTypeEnum("measure_type").notNull(),
installedAt: timestamp("installed_at").defaultNow(),
// Impacts
sapPoints: real("sap_points"),
carbonSavings: real("carbon_savings"), // tonnes CO₂e / yr
kwhSavings: real("kwh_savings"), // kWh / yr
billSavings: real("bill_savings"), // £ / yr
heatDemandSavings: real("heat_demand_savings"),
//
source: text("source"), // e.g. "EPC", "Survey", "Installer"
// Soft delete / supersession
isActive: boolean("is_active").notNull().default(true),
},
(table) => [
index("idx_installed_measure_uprn").on(table.uprn),
index("idx_installed_measure_uprn_active")
.on(table.uprn)
.where(sql`${table.isActive} = true`),
index("idx_installed_measure_measure_type").on(table.measureType),
index("idx_installed_measure_uprn_measure")
.on(table.uprn, table.measureType)
.where(sql`${table.isActive} = true`),
]
);
export type Plan = InferModel<typeof plan, "select">;
export type Recommendation = InferModel<typeof recommendation, "select">;
export type PlanRecommendations = InferModel<

View file

@ -4,6 +4,7 @@ import { PropertyWithRelations } from "@/app/db/schema/property";
import PropertyTable from "../components/PropertyTable";
import SummaryBox from "@/app/components/portfolio/SummaryBox";
import { Component } from "lucide-react";
// We enfore caching of data for 60 seconds
export const revalidate = 60;

View file

@ -57,7 +57,7 @@ export function BreakdownChart({
// Baseline (stacked)
rows.push({
label: `${epc} (baseline)`,
label: `${epc}`,
[friendlyKeys.actual]: d.actual ?? 0,
[friendlyKeys.estimated]: d.estimated ?? 0,
[friendlyKeys.scenario]: 0,
@ -114,6 +114,7 @@ export function BreakdownChart({
stack={selected === "epc"}
customTooltip={MyTooltip}
className="h-64"
showGridLines={false}
/>
</CardContent>
</Card>

View file

@ -144,6 +144,7 @@ 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,
@ -151,14 +152,14 @@ export function ReportingClientArea({
contingency: scenarioData.contingency,
funding: scenarioData.total_funding,
costPerSap:
scenarioData.construction_cost > 0
? scenarioData.gross_per_unit /
(scenarioData.avg_sap - (baseline.averages.avg_sap ?? 0))
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) /
scenarioData.total_carbon
((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon)
: 0,
netCost: scenarioData.net_cost,
grossPerUnit: scenarioData.gross_per_unit,
@ -175,7 +176,6 @@ export function ReportingClientArea({
scenarioData.n_units_upgraded,
}
: null;
// Baseline stays baseline
const activeMetrics = baseline;

View file

@ -35,7 +35,8 @@ export async function getAverages(
e.lighting_cost_current +
e.appliances_cost_current +
e.gas_standing_charge +
e.electricity_standing_charge
e.electricity_standing_charge -
COALESCE(e.installed_measures_total_energy_bill_adjustment, 0)
)::float AS avg_bills,
AVG(e.primary_energy_consumption)::float AS avg_energy_consumption
FROM property p
@ -56,7 +57,8 @@ export async function getTotals(portfolioId: number): Promise<TotalMetrics> {
e.lighting_cost_current +
e.appliances_cost_current +
e.gas_standing_charge +
e.electricity_standing_charge
e.electricity_standing_charge -
COALESCE(e.installed_measures_total_energy_bill_adjustment, 0)
)::float AS total_bills
FROM property p
LEFT JOIN property_details_epc e ON e.property_id = p.id

View file

@ -4,6 +4,7 @@ import {
getRecommendations,
getPlanMeta,
getPlanFunding,
getInstalledMeasuresByUprn,
} from "../../utils";
export default async function Recommendations(props: {
@ -14,6 +15,7 @@ export default async function Recommendations(props: {
const recommendations = await getRecommendations(params.planId);
const planMeta = await getPlanMeta(params.planId);
const funding = await getPlanFunding(params.planId);
const installedMeasures = await getInstalledMeasuresByUprn(propertyMeta.uprn);
return (
<div className="leading-loose tracking-wider">
@ -22,6 +24,7 @@ export default async function Recommendations(props: {
propertyMeta={propertyMeta}
planMeta={planMeta}
funding={funding}
installedMeasures={installedMeasures}
/>
</div>
);

View file

@ -61,11 +61,9 @@ function PlanCard({
);
}
export default async function RecommendationPlans(
props: {
params: Promise<{ slug: string; propertyId: string }>;
}
) {
export default async function RecommendationPlans(props: {
params: Promise<{ slug: string; propertyId: string }>;
}) {
const params = await props.params;
const propertyMeta = await getPropertyMeta(params.propertyId);
const plans = await getPlans(params.propertyId);
@ -79,22 +77,12 @@ export default async function RecommendationPlans(
<div className="max-w-3xl">
{plans.map((plan, index) => {
// We accumulate the cost and the sap points for only the default recommendations
const totalEstimatedCost = plan.planRecommendations.reduce(
(acc, rec) => {
if (rec.recommendation.default) {
return acc + rec.recommendation.estimatedCost;
}
return acc;
},
0
);
const totalSapPoints = plan.planRecommendations.reduce((acc, rec) => {
if (rec.recommendation.default) {
return acc + rec.recommendation.sapPoints;
}
return acc;
}, 0);
const totalEstimatedCost = plan.costOfWorks || 0;
const totalSapPoints =
(plan.postSapPoints || propertyMeta.currentSapPoints) -
propertyMeta.currentSapPoints;
// Placeholder while we return 999 for all sap points
const expectedSapPoints = Math.min(

View file

@ -4,6 +4,7 @@ import {
planRecommendations,
plan,
Plan,
installedMeasure,
} from "@/app/db/schema/recommendations";
import { db } from "@/app/db/db";
import { surveyDB } from "@/app/db/surveyDB/connection";
@ -28,17 +29,31 @@ import {
} from "@/app/db/schema/energy_assessments";
import {
fundingPackage,
FundingPackageWithMeasures
FundingPackageWithMeasures,
} from "@/app/db/schema/funding";
import { getUploadedFile, uploadedFiles, DB_REPORT_TYPES } from "@/app/db/surveyDB/schema/surveyDB";
import {
getUploadedFile,
uploadedFiles,
DB_REPORT_TYPES,
} from "@/app/db/surveyDB/schema/surveyDB";
export type InstalledMeasureSummary = {
measureType: string;
installedAt: Date | null;
sapPoints: number | null;
carbonSavings: number | null;
kwhSavings: number | null;
billSavings: number | null;
};
export async function getEnergyAssessmentFromS3(s3Uri: string): Promise<any> {
const url = new URL(s3Uri);
const bucketMatch = url.hostname.match(/^(.+)\.s3/);
const bucket = bucketMatch?.[1];
const key = url.pathname.startsWith("/") ? url.pathname.slice(1) : url.pathname;
const key = url.pathname.startsWith("/")
? url.pathname.slice(1)
: url.pathname;
if (!bucket || !key) {
throw new Error("Could not extract bucket or key from URI");
@ -65,7 +80,9 @@ type RecommendationList = {
recommendation: Recommendation;
}[];
export async function getPlanFunding(planId: string): Promise<FundingPackageWithMeasures[]> {
export async function getPlanFunding(
planId: string
): Promise<FundingPackageWithMeasures[]> {
const data = await db.query.fundingPackage.findMany({
where: eq(fundingPackage.planId, BigInt(planId)),
with: {
@ -80,13 +97,22 @@ export async function getPlanFunding(planId: string): Promise<FundingPackageWith
return data;
}
export async function getDocument(
{uprn, documentType}: {uprn: string; documentType: typeof DB_REPORT_TYPES[number]}
): Promise<getUploadedFile> {
export async function getDocument({
uprn,
documentType,
}: {
uprn: string;
documentType: (typeof DB_REPORT_TYPES)[number];
}): Promise<getUploadedFile> {
// We get the latest entry for the given UPRN and document type, by s3JsonUploadTimestamp
const data = await surveyDB.query.uploadedFiles.findFirst({
where: and(eq(uploadedFiles.uprn, String(uprn)), eq(uploadedFiles.docType, documentType)),
orderBy: (uploadedFiles, { desc }) => [desc(uploadedFiles.s3JsonUploadTimestamp)]
where: and(
eq(uploadedFiles.uprn, String(uprn)),
eq(uploadedFiles.docType, documentType)
),
orderBy: (uploadedFiles, { desc }) => [
desc(uploadedFiles.s3JsonUploadTimestamp),
],
});
// We may not have an uploaded document so we return an empty array
if (!data) {
@ -146,7 +172,10 @@ export async function getRecommendations(
}
// unnest the recommendations
const recommendations = data.map((item) => item.recommendation);
// We drop measures that are already installed
const recommendations = data
.map((item) => item.recommendation)
.filter((rec) => !rec.alreadyInstalled);
return recommendations;
}
@ -177,20 +206,20 @@ export async function getPlans(propertyId: string): Promise<PlanRelation[]> {
const data = await db.query.plan.findMany({
where: eq(plan.propertyId, BigInt(propertyId)),
orderBy: [desc(plan.createdAt)],
with: {
planRecommendations: {
columns: {},
with: {
recommendation: {
columns: {
estimatedCost: true,
sapPoints: true,
default: true,
},
},
},
},
},
// with: {
// planRecommendations: {
// columns: {},
// with: {
// recommendation: {
// columns: {
// estimatedCost: true,
// sapPoints: true,
// default: true,
// },
// },
// },
// },
// },
});
if (!data) {
@ -456,3 +485,26 @@ export function formatHeatDemandFeatures(
// },
];
}
export async function getInstalledMeasuresByUprn(
uprn: number
): Promise<InstalledMeasureSummary[]> {
const data = await db
.select({
measureType: installedMeasure.measureType,
installedAt: installedMeasure.installedAt,
sapPoints: installedMeasure.sapPoints,
carbonSavings: installedMeasure.carbonSavings,
kwhSavings: installedMeasure.kwhSavings,
billSavings: installedMeasure.billSavings,
})
.from(installedMeasure)
.where(
and(
eq(installedMeasure.uprn, BigInt(uprn)),
eq(installedMeasure.isActive, true)
)
);
return data;
}

View file

@ -58,6 +58,7 @@ export const uploadCsvSchema = baseFormSchema.extend({
},
z.union([z.number(), z.null()]).optional()
),
enforce_fabric_first: z.boolean().default(false),
});
export type UploadCsvFormValues = z.infer<typeof uploadCsvSchema>;

View file

@ -8,6 +8,7 @@ import {
Transition,
TransitionChild,
} from "@headlessui/react";
import { Checkbox } from "@/app/shadcn_components/ui/checkbox";
import { Fragment, useMemo, useState } from "react";
import { useMutation } from "@tanstack/react-query";
@ -250,6 +251,7 @@ export function useUploadCsvPlan({
onSuccessRedirect,
fileType,
fileFormat,
enforceFabricFirst,
}: {
file: File;
portfolioId: string;
@ -266,6 +268,7 @@ export function useUploadCsvPlan({
onSuccessRedirect: (path: string) => void;
fileType: "csv" | "xlsx";
fileFormat: "domna_asset_list" | null;
enforceFabricFirst: boolean;
}) {
const session = useSession();
const userId = String(session.data?.user.dbId);
@ -308,6 +311,7 @@ export function useUploadCsvPlan({
ashp_cop: Number(ashpCop),
file_type: fileType, // Pass the file type for backend processing
file_format: fileFormat,
enforce_fabric_first: enforceFabricFirst,
};
const triggerRes = await fetch("/api/plan/trigger", {
@ -421,6 +425,7 @@ export default function UploadCsvModal({
selectedSheet,
sheetCount: sheetCounts[selectedSheet] || 0,
onSuccessRedirect: (path) => router.push(path),
enforceFabricFirst: form.watch("enforce_fabric_first"),
});
const onSubmit = form.handleSubmit(async () => {
@ -733,6 +738,38 @@ export default function UploadCsvModal({
</div>
</div>
{/*enforceFabricFirst checkbox */}
<FormField
control={form.control}
name="enforce_fabric_first"
render={({ field }) => (
<FormItem className="mt-6">
<div className="rounded-lg border border-gray-200 p-4">
<FormControl>
<label className="flex items-start gap-3 cursor-pointer">
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
// disabled={selectedScenario !== NEW_SENTINEL} // Always editable
className="mt-1"
/>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">
Enforce Fabric First
</span>
<span className="text-xs text-gray-500">
Require insulation and fabric upgrades before
heating measures are recommended.
</span>
</div>
</label>
</FormControl>
<FormMessage className="text-brandbrown mt-2" />
</div>
</FormItem>
)}
/>
<div className="mt-auto pt-4 flex justify-end gap-4">
<Button
type="button"

View file

@ -508,6 +508,7 @@ export async function getProperties(
LEFT JOIN recommendation r
ON r.id = pr.recommendation_id
AND r.default = true
and r.already_installed = false
WHERE p.portfolio_id = ${portfolioId}
${combinedWhere}
GROUP BY

View file

@ -136,6 +136,10 @@ export function formatDateTime(dateTimeString: string | Date): string {
export function formatNumber(number: number): string {
if (number === 0) return "0";
if (number < 1) {
return number.toFixed(2);
}
const suffixes = ["", "k", "m", "b", "t"];
const suffixIndex = Math.floor(Math.log10(Math.abs(number)) / 3);