feat(scenarios): sign-post disruptive measures in the creation journey

Internal wall and floor insulation (and room-in-roof) involve major
internal works, and landlords often exclude them. Encode that domain
knowledge once as DISRUPTIVE_MEASURES in the scenario model (with
plain-language reasons; guarded by a key-validity test) and surface it
three ways:

- measures step: allowed disruptive chips carry an amber "disruptive"
  tag + amber border, with the reason in the tooltip; a legend explains
  the marking
- "Low disruption" quick-start now excludes exactly this set, so the
  preset and the badges cannot drift apart
- review step: an amber nudge lists any disruptive measures still
  allowed before saving

New CONTEXT.md term: Disruptive measure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-06 09:34:51 +00:00
parent 34dcb0a54f
commit a12215b839
4 changed files with 72 additions and 5 deletions

View file

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

View file

@ -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({
<h3 className="font-manrope text-lg font-extrabold text-brandblue">
Which measures are on the table?
</h3>
<p className="mb-6 mt-1 max-w-[60ch] text-sm text-gray-600">
<p className="mb-2 mt-1 max-w-[60ch] text-sm text-gray-600">
Everything is allowed unless you rule it out. Click a measure to
exclude it the engine chooses the best mix from whatever
remains.
</p>
<p className="mb-5 max-w-[60ch] text-[13px] text-gray-400">
Measures marked{" "}
<span className="font-manrope text-[9px] font-extrabold uppercase tracking-[.08em] text-amber-600">
disruptive
</span>{" "}
involve major internal works landlords often exclude them.
Hover one to see why.
</p>
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="mr-1 font-manrope text-[11px] font-extrabold uppercase tracking-[.1em] text-gray-400">
Quick starts
@ -397,14 +405,22 @@ export function NewScenarioJourney({
<div className="flex flex-wrap gap-2">
{keys.map((k) => {
const off = excluded.has(k);
const disruptiveReason = DISRUPTIVE_MEASURES[k];
return (
<button
key={k}
aria-pressed={off}
title={
disruptiveReason
? `Disruptive: ${disruptiveReason}. Landlords often exclude this.`
: undefined
}
className={`inline-flex items-center gap-2 rounded-full border px-3.5 py-1.5 text-[13px] transition ${
off
? "border-gray-200 bg-gray-100 text-gray-400 line-through hover:border-gray-300"
: "border-gray-200 bg-white text-brandblue hover:border-gray-400"
: disruptiveReason
? "border-amber-200 bg-white text-brandblue hover:border-amber-400"
: "border-gray-200 bg-white text-brandblue hover:border-gray-400"
}`}
onClick={() =>
setExcluded((prev) => {
@ -419,6 +435,11 @@ export function NewScenarioJourney({
{off ? "✕" : "✓"}
</span>
{measuresDisplayLabels[k]}
{disruptiveReason && !off && (
<span className="font-manrope text-[9px] font-extrabold uppercase tracking-[.08em] text-amber-600">
disruptive
</span>
)}
</button>
);
})}
@ -438,6 +459,24 @@ export function NewScenarioJourney({
Check the configuration it can&apos;t be changed once saved (you
can rename later). A different question is a new scenario.
</p>
{(() => {
const allowedDisruptive = (
Object.keys(DISRUPTIVE_MEASURES) as MeasureKey[]
).filter((k) => !excluded.has(k));
return allowedDisruptive.length > 0 ? (
<div className="mb-5 max-w-[62ch] rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-[13px] text-amber-900">
This scenario still allows{" "}
<b>
{allowedDisruptive
.map((k) => measuresDisplayLabels[k])
.join(", ")}
</b>{" "}
disruptive measure{allowedDisruptive.length > 1 ? "s" : ""}{" "}
landlords often exclude. Go back a step if you&apos;d rather
rule {allowedDisruptive.length > 1 ? "them" : "it"} out.
</div>
) : null;
})()}
{duplicateView && (
<div
role="alert"

View file

@ -3,6 +3,7 @@ import { measuresList } from "@/app/db/schema/recommendations";
import {
constraintSummary,
deriveScenarioStatus,
DISRUPTIVE_MEASURES,
findExactDuplicate,
parseExclusions,
scenarioToInsertRow,
@ -227,6 +228,14 @@ describe("findExactDuplicate", () => {
});
});
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");

View file

@ -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<Record<MeasureKey, string>> = {
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. */