diff --git a/CONTEXT.md b/CONTEXT.md index de164f7b..60fc3694 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -101,9 +101,13 @@ A measure whose installation involves major internal works for residents (intern _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. +A Scenario constraint that takes a measure off the table for the optimiser. The only measure-*exclusion* 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. (Exclusions decide *which* measures are available; **Fabric first** decides the *order* they are considered — the two compose.) _Avoid_: inclusions, allowed measures (as a stored concept — UI may present "allowed/excluded" toggles, but what's captured is the exclusion set) +**Fabric first**: +A Scenario constraint that forces the optimiser to apply every viable **fabric measure** (insulation, glazing, ventilation) before it will consider heating or renewables — the heating/renewables tier is reached only if the Scenario's upgrade target is still unmet once fabric is exhausted. Unlike a Measure exclusion (which removes a measure entirely), it constrains the *order* measures are considered, not which are on the table; the two compose (an excluded fabric measure simply isn't among those forced in). Off by default, set at creation and immutable like the rest of the config. The modelling backend owns the ordering logic and the fabric/heating split — the app only records the flag (trigger-payload field `enforce_fabric_first`). +_Avoid_: fabric only (the quick-start that *excludes* heating/renewables outright — fabric-first still allows them as a fallback), sequencing/phasing (reserve for PIBI installation order), enforce fabric first (a UI verb on the toggle, not the domain noun) + **Modelling run**: One user-initiated act of triggering modelling: a set of Scenarios crossed with one filter-defined property selection within a Portfolio. A durable record of *what was asked* — the Run filters, the Scenarios, who triggered it, when, and the matched-property count shown at preview — so Plans can be traced back to the selection that produced them, alongside how the work is going. A Scenario is the *question*; a Modelling run *asks* it (possibly again, possibly for a different property selection). Re-running an already-modelled property appends a newer Plan; latest wins on read. _Avoid_: job, batch, trigger request, modelling task (the pipeline's execution records) diff --git a/src/app/api/portfolio/[portfolioId]/scenarios/route.ts b/src/app/api/portfolio/[portfolioId]/scenarios/route.ts index a2065311..98539171 100644 --- a/src/app/api/portfolio/[portfolioId]/scenarios/route.ts +++ b/src/app/api/portfolio/[portfolioId]/scenarios/route.ts @@ -29,6 +29,7 @@ export async function POST( exclusions: Array.isArray(b.exclusions) ? b.exclusions.filter((k): k is string => typeof k === "string") : [], + fabricFirst: b.fabricFirst === true, }); if (!result.ok) { diff --git a/src/app/portfolio/[slug]/(portfolio)/scenarios/[scenarioId]/page.tsx b/src/app/portfolio/[slug]/(portfolio)/scenarios/[scenarioId]/page.tsx index 55fd7b98..a9e3101b 100644 --- a/src/app/portfolio/[slug]/(portfolio)/scenarios/[scenarioId]/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/scenarios/[scenarioId]/page.tsx @@ -7,6 +7,7 @@ import { getScenarioWithPlansCount } from "@/lib/scenarios/queries"; import { BandChip, EPC_SOFT, + FabricFirstChip, GOAL_SHORT_LABELS, GoalIcon, StatusPill, @@ -114,7 +115,7 @@ export default async function ScenarioDetailPage(props: { )} -
+
Exclusions
{summary.kind === "all" ? ( @@ -151,6 +152,21 @@ export default async function ScenarioDetailPage(props: { )}
+
+
Fabric first
+
+ {s.fabricFirst ? ( + + + + insulation, glazing & ventilation before heating & renewables + + + ) : ( + Off — measures considered together + )} +
+
diff --git a/src/app/portfolio/[slug]/(portfolio)/scenarios/new/NewScenarioJourney.tsx b/src/app/portfolio/[slug]/(portfolio)/scenarios/new/NewScenarioJourney.tsx index b3aaea2f..0603da40 100644 --- a/src/app/portfolio/[slug]/(portfolio)/scenarios/new/NewScenarioJourney.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/scenarios/new/NewScenarioJourney.tsx @@ -24,6 +24,7 @@ interface ExistingScenario { goalValue: string | null; budget: number | null; exclusions: string[]; + fabricFirst: boolean; modelled: boolean; } @@ -73,6 +74,7 @@ export function NewScenarioJourney({ const [band, setBand] = useState(template?.goalValue ?? null); const [budget, setBudget] = useState(template?.budget ? String(template.budget) : ""); const [excluded, setExcluded] = useState>(new Set(template?.exclusions ?? [])); + const [fabricFirst, setFabricFirst] = useState(template?.fabricFirst ?? false); const [errors, setErrors] = useState<{ name?: string; band?: string; excl?: string }>({}); const isEpc = goal === PORTFOLIO_GOALS.EPC; @@ -90,6 +92,7 @@ export function NewScenarioJourney({ goalValue: isEpc ? band : null, budgetPerProperty: budgetNumber, exclusions: [...excluded], + fabricFirst, }), }); if (!res.ok) throw new Error((await res.json()).error ?? "Save failed"); @@ -126,6 +129,7 @@ export function NewScenarioJourney({ goalValue: isEpc ? band : null, budgetPerProperty: budgetNumber, exclusions: [...excluded], + fabricFirst, }, existing.map((s) => ({ id: BigInt(s.id), @@ -135,6 +139,7 @@ export function NewScenarioJourney({ goalValue: s.goalValue, budget: s.budget, exclusions: s.exclusions.length ? `{${s.exclusions.join(",")}}` : null, + fabricFirst: s.fabricFirst, })), ) : null; @@ -455,6 +460,21 @@ export function NewScenarioJourney({ ))} + {errors.excl &&
{errors.excl}
} )} @@ -542,6 +562,14 @@ export function NewScenarioJourney({ ), ], + [ + "Fabric first", + fabricFirst ? ( + "On — fabric before heating & renewables" + ) : ( + Off — measures considered together + ), + ], ].map(([label, value]) => (
s.id === from) ?? null : null; diff --git a/src/app/portfolio/[slug]/(portfolio)/scenarios/page.tsx b/src/app/portfolio/[slug]/(portfolio)/scenarios/page.tsx index ba1afc38..76439ac6 100644 --- a/src/app/portfolio/[slug]/(portfolio)/scenarios/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/scenarios/page.tsx @@ -4,6 +4,7 @@ import { listScenariosWithStatus } from "@/lib/scenarios/queries"; import { BandChip, EPC_SOFT, + FabricFirstChip, GOAL_SHORT_LABELS, GoalIcon, StatusPill, @@ -139,6 +140,12 @@ export default async function ScenariosPage(props: { /property cap ) : null} + {s.fabricFirst ? ( + <> + {" "} + · + + ) : null}
diff --git a/src/app/portfolio/[slug]/(portfolio)/scenarios/ui.tsx b/src/app/portfolio/[slug]/(portfolio)/scenarios/ui.tsx index ac907289..e7c73080 100644 --- a/src/app/portfolio/[slug]/(portfolio)/scenarios/ui.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/scenarios/ui.tsx @@ -131,6 +131,19 @@ export function constraintLine(exclusions: string[]): React.ReactNode { ); } +/** Small badge marking a fabric-first Scenario. Render only when the flag is set. */ +export function FabricFirstChip({ className = "" }: { className?: string }) { + return ( + + + Fabric first + + ); +} + export const formatDate = (d: Date | string) => new Date(d).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }); diff --git a/src/lib/scenarios/model.test.ts b/src/lib/scenarios/model.test.ts index c901b236..c84e0207 100644 --- a/src/lib/scenarios/model.test.ts +++ b/src/lib/scenarios/model.test.ts @@ -197,6 +197,18 @@ describe("validateScenarioConfig", () => { expect(result.ok).toBe(false); if (!result.ok) expect(result.errors.exclusions).toBeTruthy(); }); + + it("carries fabric first through when the scenario is fabric-first", () => { + const result = validateScenarioConfig({ ...validBase, exclusions: [], fabricFirst: true }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value.fabricFirst).toBe(true); + }); + + it("defaults fabric first to false when the flag is absent", () => { + const result = validateScenarioConfig({ ...validBase, exclusions: [] }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value.fabricFirst).toBe(false); + }); }); describe("scenarioToInsertRow", () => { @@ -241,14 +253,30 @@ describe("scenarioToInsertRow", () => { expect(row.budget).toBeNull(); expect(row.exclusions).toBeNull(); }); + + it("persists the fabric-first flag, defaulting to false", () => { + const off = validateScenarioConfig({ + name: "Reach EPC C", housingType: "Social", goal: "Increasing EPC", + goalValue: "C", budgetPerProperty: null, exclusions: [], + }); + const on = validateScenarioConfig({ + name: "Reach EPC C — fabric first", housingType: "Social", goal: "Increasing EPC", + goalValue: "C", budgetPerProperty: null, exclusions: [], fabricFirst: true, + }); + expect(off.ok && on.ok).toBe(true); + if (!off.ok || !on.ok) return; + expect(scenarioToInsertRow(off.value, 1n).fabricFirst).toBe(false); + expect(scenarioToInsertRow(on.value, 1n).fabricFirst).toBe(true); + }); }); describe("findExactDuplicate", () => { const existing = [ { id: 1n, name: "Reach EPC C", housingType: "Social", goal: "Increasing EPC", - goalValue: "C", budget: null, exclusions: "{external_wall_insulation,solid_floor_insulation}" }, + goalValue: "C", budget: null, exclusions: "{external_wall_insulation,solid_floor_insulation}", + fabricFirst: false }, { id: 2n, name: "Max CO₂", housingType: "Social", goal: "Reducing CO2 emissions", - goalValue: null, budget: 6000, exclusions: null }, + goalValue: null, budget: 6000, exclusions: null, fabricFirst: false }, ]; const config = { name: "A totally different name", @@ -271,6 +299,18 @@ describe("findExactDuplicate", () => { findExactDuplicate({ ...config, exclusions: ["solid_floor_insulation"] }, existing), ).toBeNull(); }); + + it("treats fabric first as part of the configuration identity", () => { + // id 1 is not fabric-first; the same config asked fabric-first is a distinct question. + expect(findExactDuplicate({ ...config, fabricFirst: true }, existing)).toBeNull(); + // ...and matches once both sides agree it is fabric-first. + const fabricFirstExisting = existing.map((s) => + s.id === 1n ? { ...s, fabricFirst: true } : s, + ); + expect( + findExactDuplicate({ ...config, fabricFirst: true }, fabricFirstExisting)?.id, + ).toBe(1n); + }); }); describe("DISRUPTIVE_MEASURES", () => { diff --git a/src/lib/scenarios/model.ts b/src/lib/scenarios/model.ts index fe2e3820..56639302 100644 --- a/src/lib/scenarios/model.ts +++ b/src/lib/scenarios/model.ts @@ -75,6 +75,8 @@ export interface ScenarioConfigInput { goalValue: string | null; budgetPerProperty: number | null; exclusions: string[]; + /** Fabric-before-heating ordering constraint (CONTEXT.md). Off by default. */ + fabricFirst?: boolean; } export type ValidationResult = @@ -128,7 +130,10 @@ export function validateScenarioConfig( } if (Object.keys(errors).length > 0) return { ok: false, errors }; - return { ok: true, value: { ...input, name, goalValue, exclusions } }; + return { + ok: true, + value: { ...input, name, goalValue, exclusions, fabricFirst: input.fabricFirst ?? false }, + }; } /** @@ -162,6 +167,7 @@ export function scenarioToInsertRow( exclusions: serializeExclusions(value.exclusions), multiPlan: true, isDefault: false, + fabricFirst: value.fabricFirst ?? false, }; } @@ -233,17 +239,20 @@ export interface ExistingScenarioConfig { goalValue: string | null; budget: number | null; exclusions: string | null; + fabricFirst: boolean; } /** - * Exact-configuration duplicate: same goal, goal value, housing type, budget - * and exclusion set. The name is a label and is deliberately ignored — - * an identically-configured scenario would model to identical plans. + * Exact-configuration duplicate: same goal, goal value, housing type, budget, + * exclusion set and fabric-first flag. The name is a label and is deliberately + * ignored — an identically-configured scenario would model to identical plans, + * so any facet that changes the modelled outcome (fabric-first among them) + * makes it a distinct question. */ export function findExactDuplicate( config: Pick< ScenarioConfigInput, - "housingType" | "goal" | "goalValue" | "budgetPerProperty" | "exclusions" + "housingType" | "goal" | "goalValue" | "budgetPerProperty" | "exclusions" | "fabricFirst" >, existing: ExistingScenarioConfig[], ): ExistingScenarioConfig | null { @@ -255,6 +264,7 @@ export function findExactDuplicate( (s.goalValue ?? null) === (config.goalValue ?? null) && s.housingType === config.housingType && (s.budget ?? null) === (config.budgetPerProperty ?? null) && + (s.fabricFirst ?? false) === (config.fabricFirst ?? false) && parseExclusions(s.exclusions).sort().join(",") === wanted, ) ?? null ); diff --git a/src/lib/scenarios/queries.ts b/src/lib/scenarios/queries.ts index 74b0de9f..f6e2a12c 100644 --- a/src/lib/scenarios/queries.ts +++ b/src/lib/scenarios/queries.ts @@ -24,6 +24,7 @@ export interface ScenarioListItem { goalValue: string | null; budget: number | null; exclusions: string[]; + fabricFirst: boolean; createdAt: Date; plansCount: number; status: ScenarioStatus; @@ -56,6 +57,7 @@ export async function listScenariosWithStatus( goalValue: s.goalValue, budget: s.budget, exclusions: parseExclusions(s.exclusions), + fabricFirst: s.fabricFirst, createdAt: s.createdAt, plansCount, status: deriveScenarioStatus(plansCount), @@ -82,6 +84,7 @@ export async function getScenarioWithPlansCount( goalValue: row.goalValue, budget: row.budget, exclusions: parseExclusions(row.exclusions), + fabricFirst: row.fabricFirst, createdAt: row.createdAt, plansCount, status: deriveScenarioStatus(plansCount),