Merge pull request #161 from Hestia-Homes/new-reporting

New reporting
This commit is contained in:
KhalimCK 2026-01-07 22:51:30 +00:00 committed by GitHub
commit 94e431680b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 21187 additions and 77 deletions

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,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;

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

@ -1016,6 +1016,34 @@
"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

@ -16,6 +16,50 @@ import { Material, material } from "./materials";
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",
{
@ -266,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

@ -175,7 +175,6 @@ export function ReportingClientArea({
scenarioData.n_units_upgraded,
}
: null;
// Baseline stays baseline
const activeMetrics = baseline;

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) {
@ -177,20 +203,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 +482,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

@ -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);