mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-12 13:28:55 +00:00
feat(scenarios): surface and set fabric-first across scenario pages
Adds the fabric-first constraint to the app-authored scenario flow: - Create: a "Fabric first" toggle in the Measures step (Step 2) with an explanation, mirrored in the Step 3 review and carried through the "Use as template" (?from=) flow. - List card: a "Fabric first" chip in the constraints box, shown only when the scenario is fabric-first. - Detail page: an always-shown "Fabric first" config row (chip + explanation, or "Off"). Domain layer (TDD): validateScenarioConfig carries the flag and defaults it to false; scenarioToInsertRow persists it; findExactDuplicate treats it as part of the immutable config identity, so two scenarios differing only in fabric-first are distinct. Threaded through ScenarioListItem, both query mappers, the create route, and the new-scenario loader. Backend already reads scenario.fabric_first for id-based modelling runs, so no dispatch changes are needed. Docs: adds a "Fabric first" glossary entry to CONTEXT.md, softens the stale "only measure-constraint" line, and pins the distinction from the "Fabric only" quick-start. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e48a5f4ae2
commit
2f3e4b4c8d
10 changed files with 132 additions and 9 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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: {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative grid grid-cols-[200px_1fr] items-start gap-4 px-6 py-4">
|
||||
<div className="relative grid grid-cols-[200px_1fr] items-start gap-4 border-b border-gray-100 px-6 py-4">
|
||||
<div className={rowLabel}>Exclusions</div>
|
||||
<div className="text-sm">
|
||||
{summary.kind === "all" ? (
|
||||
|
|
@ -151,6 +152,21 @@ export default async function ScenarioDetailPage(props: {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative grid grid-cols-[200px_1fr] items-start gap-4 px-6 py-4">
|
||||
<div className={rowLabel}>Fabric first</div>
|
||||
<div className="text-sm text-brandblue">
|
||||
{s.fabricFirst ? (
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<FabricFirstChip />
|
||||
<span className="text-gray-600">
|
||||
insulation, glazing & ventilation before heating & renewables
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400">Off — measures considered together</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DetailActions>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<string | null>(template?.goalValue ?? null);
|
||||
const [budget, setBudget] = useState(template?.budget ? String(template.budget) : "");
|
||||
const [excluded, setExcluded] = useState<Set<string>>(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({
|
|||
</div>
|
||||
</div>
|
||||
))}
|
||||
<label className="mt-2 flex cursor-pointer items-start gap-3 rounded-xl border border-gray-200 bg-white p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fabricFirst}
|
||||
onChange={(e) => setFabricFirst(e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 flex-none accent-brandmidblue"
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span className="text-sm font-semibold text-brandblue">Fabric first</span>
|
||||
<span className="text-[13px] text-gray-500">
|
||||
Insulation, glazing and ventilation are applied first; heating and
|
||||
renewables are only considered if the target isn't met.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{errors.excl && <div className="text-[13px] text-red-800">{errors.excl}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -542,6 +562,14 @@ export function NewScenarioJourney({
|
|||
</span>
|
||||
),
|
||||
],
|
||||
[
|
||||
"Fabric first",
|
||||
fabricFirst ? (
|
||||
"On — fabric before heating & renewables"
|
||||
) : (
|
||||
<span key="ff" className="text-gray-400">Off — measures considered together</span>
|
||||
),
|
||||
],
|
||||
].map(([label, value]) => (
|
||||
<div
|
||||
key={label as string}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export default async function NewScenarioPage(props: {
|
|||
goalValue: s.goalValue,
|
||||
budget: s.budget,
|
||||
exclusions: s.exclusions,
|
||||
fabricFirst: s.fabricFirst,
|
||||
modelled: s.status === "modelled",
|
||||
}));
|
||||
const template = from ? existing.find((s) => s.id === from) ?? null : null;
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<>
|
||||
{" "}
|
||||
· <FabricFirstChip className="align-middle" />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="relative flex items-center justify-between text-xs tabular-nums text-gray-400">
|
||||
<StatusPill status={s.status} />
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-semibold text-emerald-800 ${className}`}
|
||||
title="Insulation, glazing and ventilation are applied before heating and renewables."
|
||||
>
|
||||
<span aria-hidden className="h-1.5 w-1.5 flex-none rounded-full bg-emerald-500" />
|
||||
Fabric first
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const formatDate = (d: Date | string) =>
|
||||
new Date(d).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" });
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue