mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-22 00:38:36 +00:00
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:
parent
34dcb0a54f
commit
a12215b839
4 changed files with 72 additions and 5 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'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'd rather
|
||||
rule {allowedDisruptive.length > 1 ? "them" : "it"} out.
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
{duplicateView && (
|
||||
<div
|
||||
role="alert"
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue