diff --git a/src/app/api/property-meta/[propertyId]/route.ts b/src/app/api/property-meta/[propertyId]/route.ts index 9bda547..81f47ae 100644 --- a/src/app/api/property-meta/[propertyId]/route.ts +++ b/src/app/api/property-meta/[propertyId]/route.ts @@ -26,6 +26,7 @@ export async function GET(request: NextRequest, props: { params: Promise<{ prope tenure: true, currentEpcRating: true, currentSapPoints: true, + originalSapPoints: true, updatedAt: true, currentValuation: true, }, diff --git a/src/app/components/building-passport/CurrentEfficiencyCard.tsx b/src/app/components/building-passport/CurrentEfficiencyCard.tsx new file mode 100644 index 0000000..723cb63 --- /dev/null +++ b/src/app/components/building-passport/CurrentEfficiencyCard.tsx @@ -0,0 +1,139 @@ +import { sapToEpc } from "@/app/utils"; +import { EpcInfoTooltip } from "./EpcInfoTooltip"; +import { LodgedEpcTooltip } from "./LodgedEpcTooltip"; + +interface CurrentEfficiencyCardProps { + epcRating: string | null; + sapPoints: number; + originalSapPoints?: number | null; +} + +function getEpcHex(letter: string | null | undefined): string { + switch (letter?.toUpperCase()) { + case "A": return "#117d58"; + case "B": return "#2da55c"; + case "C": return "#8dbd40"; + case "D": return "#f7cd14"; + case "E": return "#f3a96a"; + case "F": return "#ef8026"; + case "G": return "#e41e3b"; + default: return "#9ca3af"; + } +} + +const EPC_STOPS = [ + { sap: 0, color: "#e41e3b" }, // G + { sap: 21, color: "#ef8026" }, // F + { sap: 39, color: "#f3a96a" }, // E + { sap: 55, color: "#f7cd14" }, // D + { sap: 69, color: "#8dbd40" }, // C + { sap: 81, color: "#2da55c" }, // B + { sap: 92, color: "#117d58" }, // A +]; + +function getEpcGradient(sapPoints: number, epcHex: string): string { + if (sapPoints <= 0) return epcHex; + const stops = EPC_STOPS.filter(s => s.sap <= sapPoints); + const gradientStops = stops.map(s => { + const pct = s.sap === 0 ? 0 : (s.sap / sapPoints) * 100; + return `${s.color} ${pct.toFixed(1)}%`; + }); + gradientStops.push(`${epcHex} 100%`); + return `linear-gradient(to right, ${gradientStops.join(", ")})`; +} + +function getEpcDescription(letter: string | null | undefined): string { + switch (letter?.toUpperCase()) { + case "A": + case "B": return "This property is performing at or above modern energy standards."; + case "C": return "This property meets modern energy performance benchmarks."; + case "D": return "This property is performing slightly below modern energy standards."; + case "E": return "This property is performing below modern energy standards."; + case "F": + case "G": return "This property is performing significantly below modern energy standards."; + default: return "Energy performance data is not yet available for this property."; + } +} + +export function CurrentEfficiencyCard({ + epcRating, + sapPoints, + originalSapPoints, +}: CurrentEfficiencyCardProps) { + const epcHex = getEpcHex(epcRating); + const lodgedLetter = originalSapPoints ? sapToEpc(originalSapPoints) : null; + const showLodgedBadge = lodgedLetter !== null && lodgedLetter !== epcRating; + const lodgedHex = getEpcHex(lodgedLetter); + + return ( +
+
+ + {showLodgedBadge && ( +
+
+ + Lodged EPC + + +
+
+ + {lodgedLetter} + + {originalSapPoints != null && ( + + / {Math.round(originalSapPoints)} + + )} +
+
+ )} + +
+
+

+ Current Efficiency State +

+ +
+
+ + {epcRating ?? "—"} + + + / {sapPoints || "—"} + +
+

+ {getEpcDescription(epcRating)} +

+
+ +
+
+
+
+
+ Very Inefficient + Very Efficient +
+
+
+ ); +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/EpcInfoTooltip.tsx b/src/app/components/building-passport/EpcInfoTooltip.tsx similarity index 100% rename from src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/EpcInfoTooltip.tsx rename to src/app/components/building-passport/EpcInfoTooltip.tsx diff --git a/src/app/components/building-passport/LodgedEpcTooltip.tsx b/src/app/components/building-passport/LodgedEpcTooltip.tsx new file mode 100644 index 0000000..b37b1f1 --- /dev/null +++ b/src/app/components/building-passport/LodgedEpcTooltip.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { QuestionMarkCircleIcon } from "@heroicons/react/24/outline"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/app/shadcn_components/ui/tooltip"; + +export function LodgedEpcTooltip() { + return ( + + + + + + +
+

Lodged EPC Rating

+

From the official EPC register

+
+
+

+ This is the rating recorded on the{" "} + official EPC register at the time of the last survey. +

+

+ Your current rating may differ because we re-model this property under{" "} + SAP 10, which uses updated energy factors and can produce a different result than the original survey. +

+

+ We also re-model when the EPC has{" "} + expired or is{" "} + invalid, or when you have told us the property has{" "} + changed since the last survey. +

+
+
+

+ SAP 10 is the latest version of the{" "} + Standard Assessment Procedure, introduced to better reflect modern energy use and lower-carbon fuels. +

+
+
+
+
+ ); +} diff --git a/src/app/db/schema/crm/hubspot_deal_table.ts b/src/app/db/schema/crm/hubspot_deal_table.ts index 04c73b6..3b7161e 100644 --- a/src/app/db/schema/crm/hubspot_deal_table.ts +++ b/src/app/db/schema/crm/hubspot_deal_table.ts @@ -48,7 +48,7 @@ export const hubspotDealData = pgTable("hubspot_deal_data", { damnpMouldAndRepairComments: text("damp_mould_and_repairs_comments"), confirmedSurveyDate: timestamp("confirmed_survey_date", { precision: 6, withTimezone: true }), confirmedSurveyTime: text("confirmed_survey_time"), - SurveyedDate: timestamp("surveyed_date", { precision: 6, withTimezone: true }), + surveyedDate: timestamp("surveyed_date", { precision: 6, withTimezone: true }), createdAt: timestamp("created_at", { precision: 6, withTimezone: true }) .defaultNow() diff --git a/src/app/db/schema/property.ts b/src/app/db/schema/property.ts index 3d2dd56..58e956f 100644 --- a/src/app/db/schema/property.ts +++ b/src/app/db/schema/property.ts @@ -34,6 +34,7 @@ export interface PropertyMeta { tenure: string; currentEpcRating: string; currentSapPoints: number; + originalSapPoints: number | null; updatedAt: string; currentValuation: number | null; detailsEpc: { diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/CompletionTrendsChart.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/CompletionTrendsChart.tsx index dfcd492..3fa3b4e 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/CompletionTrendsChart.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/CompletionTrendsChart.tsx @@ -110,9 +110,9 @@ const DESIGN_TYPE_ORDER = [ function getMondayOfWeek(date: Date): string { const d = new Date(date); - const day = d.getDay(); - d.setDate(d.getDate() - (day === 0 ? 6 : day - 1)); - d.setHours(0, 0, 0, 0); + const day = d.getUTCDay(); + d.setUTCDate(d.getUTCDate() - (day === 0 ? 6 : day - 1)); + d.setUTCHours(0, 0, 0, 0); return d.toISOString().split("T")[0]; } @@ -132,7 +132,7 @@ function fillWeekGaps(keys: string[]): string[] { const end = new Date(sorted[sorted.length - 1]); while (current <= end) { result.push(current.toISOString().split("T")[0]); - current.setDate(current.getDate() + 7); + current.setUTCDate(current.getUTCDate() + 7); } return result; } @@ -314,14 +314,28 @@ export default function CompletionTrendsChart({ const isDesign = metric === "design"; const isStacked = isCoordination || isAssessments || isLodgement || isDesign; - // External assessments with no date + // Assessments (retrofit or EPC) with no date const undatedAssessments = isAssessments ? deals.filter((d) => { const o = d.outcome ?? ""; - return (o === "Surveyed" || o === "Surveyed - Pending Upload") && !d.surveyedDate; + return ( + (o === "Surveyed" || o === "Surveyed - Pending Upload" || o === "EPC Completed") && + !d.surveyedDate + ); }) : []; + // Dated assessments broken down by type — used for summary badges + const retrofitDeals = isAssessments + ? deals.filter((d) => { + const o = d.outcome ?? ""; + return (o === "Surveyed" || o === "Surveyed - Pending Upload") && !!d.surveyedDate; + }) + : []; + const epcDeals = isAssessments + ? deals.filter((d) => d.outcome === "EPC Completed" && !!d.surveyedDate) + : []; + // Build chart data let chartData: Record[]; let categories: string[]; @@ -363,7 +377,37 @@ export default function CompletionTrendsChart({ Trends Over Time - {totalCompleted !== null && ( + {isAssessments ? ( +
+ {([ + { label: "Retrofit Assessments", dealList: retrofitDeals }, + { label: "EPCs", dealList: epcDeals }, + ] as const).filter(({ dealList }) => dealList.length > 0).map(({ label, dealList }) => ( + + ))} +
+ ) : totalCompleted !== null && (
{totalCompleted} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/SurveyIssuesPanel.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/SurveyIssuesPanel.tsx index d8c576b..65cc8ab 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/SurveyIssuesPanel.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/SurveyIssuesPanel.tsx @@ -5,7 +5,7 @@ import { AlertCircle } from "lucide-react"; import { Card, CardContent } from "@/app/shadcn_components/ui/card"; import type { ClassifiedDeal } from "./types"; -const SUCCESSFUL_OUTCOMES = new Set(["Surveyed", "Surveyed - Pending Upload"]); +const SUCCESSFUL_OUTCOMES = new Set(["Surveyed", "Surveyed - Pending Upload", "EPC Completed"]); const COLUMNS: (keyof ClassifiedDeal)[] = [ "dealname", diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx index 55da823..f7002fc 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx @@ -53,7 +53,7 @@ function mapDbRowToHubspotDeal(row: DbDeal): HubspotDeal { measuresLodgementDate: row.measuresLodgementDate, fullLodgementDate: row.lodgementDate, confirmedSurveyDate: row.confirmedSurveyDate, - surveyedDate: row.SurveyedDate, + surveyedDate: row.surveyedDate, designType: row.dealType, createdAt: row.createdAt, updatedAt: row.updatedAt, diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts index 931413c..40fa764 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts @@ -230,6 +230,7 @@ export type DocumentDrawerState = { export const SURVEYOR_OUTCOMES = [ "Surveyed", "Surveyed - Pending Upload", + "EPC Completed", "Tenant Refusal", "Other", "Not Viable", diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/page.tsx index 78d7b26..a2123cb 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/page.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/page.tsx @@ -18,36 +18,10 @@ import { import { getSolarData, getSolarScenarioData } from "../solar-analysis/utils"; import PropertyMapWrapper from "../solar-analysis/PropertyMapWrapper"; import SolarSimulationWrapper from "../solar-analysis/SolarSimulationWrapper"; -import { EpcInfoTooltip } from "./EpcInfoTooltip"; +import { CurrentEfficiencyCard } from "@/app/components/building-passport/CurrentEfficiencyCard"; // ── Helpers ──────────────────────────────────────────────────────────────────── -function getEpcHex(letter: string | null | undefined): string { - switch (letter?.toUpperCase()) { - case "A": return "#117d58"; - case "B": return "#2da55c"; - case "C": return "#8dbd40"; - case "D": return "#f7cd14"; - case "E": return "#f3a96a"; - case "F": return "#ef8026"; - case "G": return "#e41e3b"; - default: return "#9ca3af"; - } -} - -function getEpcDescription(letter: string | null | undefined): string { - switch (letter?.toUpperCase()) { - case "A": - case "B": return "This property is performing at or above modern energy standards."; - case "C": return "This property meets modern energy performance benchmarks."; - case "D": return "This property is performing slightly below modern energy standards."; - case "E": return "This property is performing below modern energy standards."; - case "F": - case "G": return "This property is performing significantly below modern energy standards."; - default: return "Energy performance data is not yet available for this property."; - } -} - function getDirectionLabel(az: number): { label: string; short: string } { const norm = ((az % 360) + 360) % 360; if (norm >= 337.5 || norm < 22.5) return { short: "N", label: "North" }; @@ -211,10 +185,6 @@ export default async function PreAssessmentReport(props: { const rawSolar = await getSolarData(Number(propertyMeta.uprn)); const solarData = rawSolar ?? null; - const epcLetter = propertyMeta.currentEpcRating ?? null; - const sapScore = propertyMeta.currentSapPoints ?? 0; - const epcHex = getEpcHex(epcLetter); - // Solar derived values const sp = solarData?.googleApiResponse?.solarPotential ?? null; const solarScenarioData = solarData ? await getSolarScenarioData(String(solarData.id)) : null; @@ -265,49 +235,11 @@ export default async function PreAssessmentReport(props: {
{/* EPC Hero — matches overview page style */} -
-
-
-
-

- Current Efficiency State -

- -
-
- - {epcLetter ?? "—"} - - - / {sapScore || "—"} - -
-

- {getEpcDescription(epcLetter)} -

-
-
-
-
-
-
- Very Inefficient - Very Efficient -
-
-
+ {/* Right column: 3 metric cards + general features grid */}
diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/page.tsx index ee422a3..1035edf 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/page.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/page.tsx @@ -15,6 +15,7 @@ import { getInstalledMeasuresByUprn, } from "./utils"; import { HeritageTooltip } from "./HeritageTooltip"; +import { CurrentEfficiencyCard } from "@/app/components/building-passport/CurrentEfficiencyCard"; export const revalidate = 1; @@ -25,33 +26,6 @@ function formatGbp(value: number | null | undefined): string { return `£${Math.round(value).toLocaleString("en-GB")}`; } -/** Map EPC letter to its hex color from the project's palette */ -function getEpcHex(letter: string | null | undefined): string { - switch (letter?.toUpperCase()) { - case "A": return "#117d58"; - case "B": return "#2da55c"; - case "C": return "#8dbd40"; - case "D": return "#f7cd14"; - case "E": return "#f3a96a"; - case "F": return "#ef8026"; - case "G": return "#e41e3b"; - default: return "#9ca3af"; - } -} - -function getEpcDescription(letter: string | null | undefined): string { - switch (letter?.toUpperCase()) { - case "A": - case "B": return "This property is performing at or above modern energy standards."; - case "C": return "This property meets modern energy performance benchmarks."; - case "D": return "This property is performing slightly below modern energy standards."; - case "E": return "This property is performing below modern energy standards."; - case "F": - case "G": return "This property is performing significantly below modern energy standards."; - default: return "Energy performance data is not yet available for this property."; - } -} - // ── Sub-components ──────────────────────────────────────────────────────────── function SectionHeading({ icon, label }: { icon: React.ReactNode; label: string }) { @@ -102,10 +76,6 @@ export default async function BuildingPassportHome(props: { (conditionReport.hotWaterEnergyCostCurrent ?? 0) + (conditionReport.lightingEnergyCostCurrent ?? 0); - const epcLetter = propertyMeta.currentEpcRating ?? null; - const sapScore = propertyMeta.currentSapPoints ?? 0; - const epcHex = getEpcHex(epcLetter); - return (
@@ -113,46 +83,11 @@ export default async function BuildingPassportHome(props: {
{/* EPC Hero */} -
-
-
-

- Current Efficiency State -

-
- - {epcLetter ?? "—"} - - - / {sapScore || "—"} - -
-

- {getEpcDescription(epcLetter)} -

-
-
-
-
-
-
- Very Inefficient - Very Efficient -
-
-
+ {/* Energy Stats + Heritage Status */}
diff --git a/src/app/portfolio/[slug]/components/CurrentEpcTooltip.tsx b/src/app/portfolio/[slug]/components/CurrentEpcTooltip.tsx new file mode 100644 index 0000000..9002030 --- /dev/null +++ b/src/app/portfolio/[slug]/components/CurrentEpcTooltip.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { QuestionMarkCircleIcon } from "@heroicons/react/24/outline"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/app/shadcn_components/ui/tooltip"; + +export function CurrentEpcTooltip() { + return ( + + + + + + +
+

Current EPC Rating

+

How we calculate this rating

+
+
+

+ We show a modelled rating when: +

+
    +
  • The lodged EPC is expired or invalid
  • +
  • You have told us the property differs from the last survey
  • +
+
+
+

+ This rating may differ from the lodged EPC because we re-model under{" "} + SAP 10. +

+
+
+
+
+ ); +} diff --git a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx index 50ff5b5..7ef022e 100644 --- a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx +++ b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx @@ -21,6 +21,7 @@ import { TENURE_OPTIONS, MAINFUEL_OPTIONS, } from "@/app/utils/propertyFilters"; +import { CurrentEpcTooltip } from "./CurrentEpcTooltip"; /* ----------------------------------------------------------------------- Helpers @@ -199,7 +200,7 @@ const coreColumns: ColumnDef[] = [
{address} @@ -222,7 +223,10 @@ const coreColumns: ColumnDef[] = [ { accessorKey: "currentEpc", header: () => ( -
Current EPC
+
+ Current EPC + +
), cell: ({ row }) => (