diff --git a/CONTEXT.md b/CONTEXT.md index 165b43fc..e5110d99 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -88,6 +88,10 @@ _Avoid_: target (reserve for the EPC band specifically), objective An optional per-Scenario spending cap, denominated **per property (£)** — never a whole-portfolio envelope. Absent budget = no cap. _Avoid_: portfolio budget (a separate concept on the Portfolio row), envelope, cost limit +**Disruptive measure**: +A measure whose installation involves major internal works for residents (internal wall insulation, solid/suspended floor insulation, room-in-roof insulation). Landlords frequently exclude them; the UI sign-posts them and the "Low disruption" quick-start excludes exactly this set. Canonical list: `DISRUPTIVE_MEASURES` in the Scenario domain model. +_Avoid_: invasive (unused here), wet trades (a narrower, overlapping set: plaster-and-screed work) + **Measure exclusion**: A Scenario constraint that takes a measure off the table for the optimiser. The *only* measure-constraint concept on a Scenario — there is no stored inclusion list; "only Solar PV + ASHP" is expressed by excluding everything else. An empty exclusion set is valid and means every measure is available. _Avoid_: inclusions, allowed measures (as a stored concept — UI may present "allowed/excluded" toggles, but what's captured is the exclusion set) diff --git a/src/app/portfolio/[slug]/(portfolio)/scenarios/new/NewScenarioJourney.tsx b/src/app/portfolio/[slug]/(portfolio)/scenarios/new/NewScenarioJourney.tsx index 4bcee7c9..3fc372bc 100644 --- a/src/app/portfolio/[slug]/(portfolio)/scenarios/new/NewScenarioJourney.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/scenarios/new/NewScenarioJourney.tsx @@ -10,7 +10,7 @@ import { measuresList, type MeasureKey, } from "@/app/db/schema/recommendations"; -import { EPC_BANDS, findExactDuplicate, SCENARIO_GOALS } from "@/lib/scenarios/model"; +import { DISRUPTIVE_MEASURES, EPC_BANDS, findExactDuplicate, SCENARIO_GOALS } from "@/lib/scenarios/model"; import { BandChip, EPC_SOFT, GOAL_DESCRIPTIONS, GOAL_SHORT_LABELS, GoalIcon, bandInk, bandTint, formatMoney } from "../ui"; interface ExistingScenario { @@ -36,8 +36,8 @@ const PRESETS: { label: string; hint: string; excl: MeasureKey[] }[] = [ excl: ["internal_wall_insulation", "external_wall_insulation", "solid_floor_insulation"] }, { label: "Fabric only", hint: "Insulation and glazing only — no heating systems or renewables", excl: ["boiler_upgrade", "high_heat_retention_storage_heaters", "air_source_heat_pump", "secondary_heating", "hot_water_tank_insulation", "cylinder_thermostat", "solar_pv", "low_energy_lighting", "fireplace", "ventilation"] }, - { label: "Low disruption", hint: "Nothing that requires residents to move out or clear rooms", - excl: ["internal_wall_insulation", "external_wall_insulation", "solid_floor_insulation", "suspended_floor_insulation", "room_roof_insulation"] }, + { label: "Low disruption", hint: "Excludes every measure marked as disruptive", + excl: Object.keys(DISRUPTIVE_MEASURES) as MeasureKey[] }, ]; const fieldLabel = @@ -356,11 +356,19 @@ export function NewScenarioJourney({

Which measures are on the table?

-

+

Everything is allowed unless you rule it out. Click a measure to exclude it — the engine chooses the best mix from whatever remains.

+

+ Measures marked{" "} + + disruptive + {" "} + involve major internal works — landlords often exclude them. + Hover one to see why. +

Quick starts @@ -397,14 +405,22 @@ export function NewScenarioJourney({
{keys.map((k) => { const off = excluded.has(k); + const disruptiveReason = DISRUPTIVE_MEASURES[k]; return ( ); })} @@ -438,6 +459,24 @@ export function NewScenarioJourney({ Check the configuration — it can't be changed once saved (you can rename later). A different question is a new scenario.

+ {(() => { + const allowedDisruptive = ( + Object.keys(DISRUPTIVE_MEASURES) as MeasureKey[] + ).filter((k) => !excluded.has(k)); + return allowedDisruptive.length > 0 ? ( +
+ This scenario still allows{" "} + + {allowedDisruptive + .map((k) => measuresDisplayLabels[k]) + .join(", ")} + {" "} + — disruptive measure{allowedDisruptive.length > 1 ? "s" : ""}{" "} + landlords often exclude. Go back a step if you'd rather + rule {allowedDisruptive.length > 1 ? "them" : "it"} out. +
+ ) : null; + })()} {duplicateView && (
{ }); }); +describe("DISRUPTIVE_MEASURES", () => { + it("only marks real measure keys (a typo would silently drop the badge)", () => { + for (const key of Object.keys(DISRUPTIVE_MEASURES)) { + expect(measuresList).toContain(key); + } + }); +}); + describe("display helpers", () => { it("derives status from plan existence — no stored status column", () => { expect(deriveScenarioStatus(0)).toBe("awaiting_modelling"); diff --git a/src/lib/scenarios/model.ts b/src/lib/scenarios/model.ts index 34faba91..5b596420 100644 --- a/src/lib/scenarios/model.ts +++ b/src/lib/scenarios/model.ts @@ -131,6 +131,21 @@ export function parseExclusions(text: string | null): string[] { return inner.split(",").map((s) => s.trim()).filter(Boolean); } +/** + * Measures whose installation involves major internal works — landlords + * frequently exclude them. The value is the plain-language reason shown to + * users. The "Low disruption" quick-start excludes exactly this set. + */ +export const DISRUPTIVE_MEASURES: Partial> = { + internal_wall_insulation: + "Rooms need clearing and replastering while the work is done", + solid_floor_insulation: + "Floors are dug up or raised — usually needs the home empty", + suspended_floor_insulation: + "Floorboards come up in every affected room", + room_roof_insulation: "Loft rooms are stripped back to the rafters", +}; + export type ScenarioStatus = "modelled" | "awaiting_modelling"; /** Status is derived from plan existence (ADR-0003) — never stored. */