diff --git a/CONTEXT.md b/CONTEXT.md index 3105f6a3..46f0f7a9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -82,6 +82,28 @@ _Avoid_: multi-row, multi-record, duplicate address The user's declaration, captured once per file, of which list-position maps to which Building part — because the entry order is a consistent per-file mistake (`"A, B"` could be `[Main, Extension 1]` or `[Extension 1, Main]`). Stored per entry-count as a permutation. See [ADR-0004](./docs/adr/0004-multi-entry-building-part-ordering.md). _Avoid_: sort order, sequence, column mapping +### Scenarios + +**Scenario**: +A named modelling configuration for a Portfolio — the *question* a modelling run answers (e.g. "what does it take to reach EPC C, excluding solid floor insulation?"). Captures the target, the measure constraints and the **housing type** (Social/Private — a genuine modelling input because it drives funding treatment, not portfolio metadata). **The configuration is immutable once created** — a change of mind is a new Scenario — but the *name* is a label, renameable at any time, and a Scenario that has no Plans yet may be deleted (an unasked question can be withdrawn; a modelled one cannot). Plans are generated *against* a Scenario by the modelling engine; impact/results live on the Plans (and Reporting), never on the Scenario itself. +_Avoid_: plan (a Plan is one property's modelled answer within a Scenario), simulation, run + +**Goal**: +The optimisation objective of a Scenario. **Increasing EPC** targets a band (its goal value is the band letter, required). Every other goal (**Reducing CO₂ emissions**, **Energy Savings**, **Valuation Improvement**) means "improve that dimension as much as possible" — it carries **no goal value**; the only brake is an optional **Budget**, without which the engine recommends the maximum interventions. +_Avoid_: target (reserve for the EPC band specifically), objective + +**Budget**: +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) + ## Lifecycle A **BulkUpload** moves through these statuses: diff --git a/docs/adr/0003-app-authored-scenarios.md b/docs/adr/0003-app-authored-scenarios.md new file mode 100644 index 00000000..fc8aef67 --- /dev/null +++ b/docs/adr/0003-app-authored-scenarios.md @@ -0,0 +1,76 @@ +# 3. Scenarios are authored by the app, insert-only, with modelling state derived + +Date: 2026-07-04 + +## Status + +Accepted + +## Context + +Historically a `scenario` row was created **by the modelling backend at trigger +time** — a scenario was born already modelled, with plans attached. The new +portfolio journey (upload properties → create scenarios → trigger modelling) +requires users to author a Scenario *before* any modelling exists. The trigger +API already accepts an optional `scenario_id`, so "author first, model later" +fits the existing contract. + +Recent data (last 6 months, n=402) shows the modern Scenario is small: name, +housing type, goal + goal value, measure exclusions, optional per-property +budget. The scenario table's aggregate/result columns (`cost`, +`co2_equivalent_savings`, `cost_per_sap_point`, …) are **legacy**: the new +pipeline populates none of them (0 of 7 new-era rows) — results live on plans +and are computed by Reporting. + +## Decision + +- **The Next.js app inserts `scenario` rows directly** when a user completes + the creation journey. The **configuration is immutable** — goal, target, + housing type, budget and exclusions never change after creation (a change of + mind is a new Scenario), so modelled results always correspond to the config + they were generated against. Two narrow mutations are allowed: + - **Rename** — the name is a label, not configuration; renameable any time. + - **Delete, only while unmodelled** — permitted iff no plans reference the + scenario. Exposed on the scenario detail page only: one + `COUNT(DISTINCT property_id)` scoped to `(portfolio_id, scenario_id)` + (index-only scan on the plan composite index) both gates deletion and + powers the blocked-delete message ("There are N properties with plans in + this scenario — contact a Domna administrator to remove the plans first"). + The gallery's derived status uses one grouped query per portfolio, never + per-scenario probes. The delete must guard atomically + (`DELETE ... WHERE NOT EXISTS (SELECT 1 FROM plan WHERE scenario_id = …)`) + so a modelling run landing plans mid-request cannot orphan them. +- **No status column.** A Scenario's modelling state is *derived*: plans + referencing `plan.scenario_id` exist → "Modelled"; none → "Awaiting + modelling". This mirrors the codebase's preference for derived signals + (provenance) and cannot drift from reality. +- App-authored rows set `multi_plan = true` (universal in modern data), + `is_default = false` (the backend owns default-scenario semantics at + modelling time), and never populate the legacy aggregate columns. +- The backend keeps writing its own scenario rows for trigger paths that don't + pass a `scenario_id` (existing upload modal) — **two writers, one table**, + distinguishable by plan existence, not provenance flags. + +## Alternatives considered + +- **Separate `scenario_draft` table**: keeps the "scenario rows imply results" + invariant, but forces a copy-on-trigger dance, splits one concept across two + tables, and the invariant is already dead — new-era rows carry no results. +- **Stored status column**: explicit lifecycle, but a second source of truth + that drifts (re-runs, deleted plans) and needs backfilling for 1,200+ + existing rows. +- **Backend-owned creation via a new FastAPI endpoint**: preserves single + writer, but couples a pure-CRUD authoring flow to backend availability and + a cross-service deploy for a UI feature. + +## Consequences + +- Any consumer that assumes "scenario ⇒ has plans/results" must now tolerate + plan-less Scenarios. Known today: Reporting's scenario selector (should hide + or mark unmodelled Scenarios — its metrics endpoint returns zeros for them). +- The future trigger-wiring step passes `scenario_id` and inherits authored + config; it must not create a duplicate scenario row for that path. +- The in-flight "modelling running" state is not representable by derivation; + when trigger wiring lands it needs an explicit marker (e.g. + `last_triggered_at`), recorded here as a known follow-up, not smuggled in + now. diff --git a/src/app/api/portfolio/[portfolioId]/scenarios/[scenarioId]/route.ts b/src/app/api/portfolio/[portfolioId]/scenarios/[scenarioId]/route.ts new file mode 100644 index 00000000..ef839996 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/scenarios/[scenarioId]/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + deleteScenarioIfUnmodelled, + renameScenario, +} from "@/lib/scenarios/queries"; + +type Params = { params: Promise<{ portfolioId: string; scenarioId: string }> }; + +function parseIds(portfolioId: string, scenarioId: string) { + if (!/^\d+$/.test(portfolioId) || !/^\d+$/.test(scenarioId)) return null; + return { pid: BigInt(portfolioId), sid: BigInt(scenarioId) }; +} + +/** Rename only — a Scenario's configuration is immutable (ADR-0003). */ +export async function PATCH(request: NextRequest, props: Params) { + const { portfolioId, scenarioId } = await props.params; + const ids = parseIds(portfolioId, scenarioId); + if (!ids) return NextResponse.json({ error: "Invalid id" }, { status: 400 }); + + let body: { name?: unknown }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + const name = typeof body.name === "string" ? body.name.trim() : ""; + if (!name) { + return NextResponse.json({ error: "Name is required" }, { status: 400 }); + } + + const renamed = await renameScenario(ids.pid, ids.sid, name); + if (!renamed) { + return NextResponse.json({ error: "Scenario not found" }, { status: 404 }); + } + return NextResponse.json({ name }); +} + +export async function DELETE(request: NextRequest, props: Params) { + const { portfolioId, scenarioId } = await props.params; + const ids = parseIds(portfolioId, scenarioId); + if (!ids) return NextResponse.json({ error: "Invalid id" }, { status: 400 }); + + const result = await deleteScenarioIfUnmodelled(ids.pid, ids.sid); + if (result.outcome === "deleted") { + return NextResponse.json({ deleted: true }); + } + if (result.outcome === "has_plans") { + return NextResponse.json( + { + error: `There are ${result.plansCount} properties with plans in this scenario, so it can't be deleted. Contact a Domna administrator to remove the plans first.`, + plansCount: result.plansCount, + }, + { status: 409 }, + ); + } + return NextResponse.json({ error: "Scenario not found" }, { status: 404 }); +} diff --git a/src/app/api/portfolio/[portfolioId]/scenarios/route.ts b/src/app/api/portfolio/[portfolioId]/scenarios/route.ts new file mode 100644 index 00000000..a2065311 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/scenarios/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { validateScenarioConfig } from "@/lib/scenarios/model"; +import { createScenario } from "@/lib/scenarios/queries"; + +export async function POST( + request: NextRequest, + props: { params: Promise<{ portfolioId: string }> }, +) { + const { portfolioId } = await props.params; + if (!portfolioId || !/^\d+$/.test(portfolioId)) { + return NextResponse.json({ error: "Invalid portfolioId" }, { status: 400 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const b = body as Record; + const result = validateScenarioConfig({ + name: typeof b.name === "string" ? b.name : "", + housingType: typeof b.housingType === "string" ? b.housingType : "", + goal: typeof b.goal === "string" ? b.goal : "", + goalValue: typeof b.goalValue === "string" ? b.goalValue : null, + budgetPerProperty: + typeof b.budgetPerProperty === "number" ? b.budgetPerProperty : null, + exclusions: Array.isArray(b.exclusions) + ? b.exclusions.filter((k): k is string => typeof k === "string") + : [], + }); + + if (!result.ok) { + const detail = Object.values(result.errors).join("; "); + return NextResponse.json({ error: detail }, { status: 400 }); + } + + const id = await createScenario(BigInt(portfolioId), result.value); + return NextResponse.json({ id: String(id) }, { status: 201 }); +} diff --git a/src/app/components/portfolio/Toolbar.tsx b/src/app/components/portfolio/Toolbar.tsx index 05c2bfa8..537f8211 100644 --- a/src/app/components/portfolio/Toolbar.tsx +++ b/src/app/components/portfolio/Toolbar.tsx @@ -6,6 +6,7 @@ import { HomeModernIcon, BuildingOffice2Icon, ArrowPathIcon, + AdjustmentsHorizontalIcon, } from "@heroicons/react/24/outline"; import { NavigationMenu, @@ -49,6 +50,12 @@ export function Toolbar({ portfolioId }: ToolbarProps) { match: (p: string) => p === `/portfolio/${portfolioId}/reporting`, href: `/portfolio/${portfolioId}/reporting`, }, + { + label: "Scenarios", + icon: AdjustmentsHorizontalIcon, + match: (p: string) => p.startsWith(`/portfolio/${portfolioId}/scenarios`), + href: `/portfolio/${portfolioId}/scenarios`, + }, { label: "Decent Homes", icon: HomeModernIcon, diff --git a/src/app/portfolio/[slug]/(portfolio)/scenarios/[scenarioId]/DetailActions.tsx b/src/app/portfolio/[slug]/(portfolio)/scenarios/[scenarioId]/DetailActions.tsx new file mode 100644 index 00000000..9fb942b2 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/scenarios/[scenarioId]/DetailActions.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useMutation } from "@tanstack/react-query"; +import { + Dialog, + DialogContent, + DialogTitle, + DialogFooter, +} from "@/app/shadcn_components/ui/dialog"; +import { Button } from "@/app/shadcn_components/ui/button"; + +const btn = + "rounded-xl border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-brandblue transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-45"; + +/** + * Client shell around the read-only config sheet: rename (label only), + * use-as-template, view-in-reporting, and the guarded delete zone. + */ +export function DetailActions({ + portfolioId, + scenarioId, + name: initialName, + modelled, + plansCount, + statusPill, + meta, + children, +}: { + portfolioId: string; + scenarioId: string; + name: string; + modelled: boolean; + plansCount: number; + statusPill: React.ReactNode; + meta: string; + children: React.ReactNode; +}) { + const router = useRouter(); + const [name, setName] = useState(initialName); + const [editing, setEditing] = useState(false); + const [draftName, setDraftName] = useState(initialName); + const [error, setError] = useState(null); + const [deleteOpen, setDeleteOpen] = useState(false); + const [deleteConfirmation, setDeleteConfirmation] = useState(""); + const deleteConfirmed = deleteConfirmation.trim().toLowerCase() === "delete"; + + const rename = useMutation({ + mutationFn: async (newName: string) => { + const res = await fetch( + `/api/portfolio/${portfolioId}/scenarios/${scenarioId}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: newName }), + }, + ); + if (!res.ok) throw new Error((await res.json()).error ?? "Rename failed"); + return newName; + }, + onSuccess: (newName) => { + setName(newName); + setEditing(false); + setError(null); + router.refresh(); + }, + onError: (e: Error) => setError(e.message), + }); + + const remove = useMutation({ + mutationFn: async () => { + const res = await fetch( + `/api/portfolio/${portfolioId}/scenarios/${scenarioId}`, + { method: "DELETE" }, + ); + if (!res.ok) throw new Error((await res.json()).error ?? "Delete failed"); + }, + onSuccess: () => router.push(`/portfolio/${portfolioId}/scenarios`), + onError: (e: Error) => { + setError(e.message); + setDeleteOpen(false); + }, + }); + + return ( + <> +
+ {editing ? ( +
+ setDraftName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && draftName.trim()) + rename.mutate(draftName.trim()); + if (e.key === "Escape") setEditing(false); + }} + className="min-w-0 flex-1 max-w-[460px] rounded-xl border border-brandmidblue px-3 py-1.5 font-manrope text-lg font-extrabold text-brandblue shadow-[0_0_0_3px_rgba(57,67,183,.12)] outline-none" + /> + + +
+ ) : ( +

+ {name} + +

+ )} + {statusPill} +
+
{meta}
+ + {children} + +
+ + View results in Reporting → + + + Use as template + + {!modelled && ( + + Results will appear here once modelling has run against this scenario. + + )} +
+ +
+ {modelled ? ( + <> + + + There are{" "} + + {plansCount.toLocaleString("en-GB")} properties + {" "} + with plans in this scenario, so it can't be deleted. Contact a + Domna administrator to remove the plans first. + + + ) : ( + <> + + + Nothing has been modelled against this scenario — deleting removes + only the configuration. + + + )} +
+ {error &&
{error}
} + + + + + Delete “{name}”? + +

+ Nothing has been modelled against this scenario — deleting removes + only the configuration. This can't be undone. +

+
+ + setDeleteConfirmation(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && deleteConfirmed && !remove.isLoading) + remove.mutate(); + }} + className="w-full rounded-xl border border-gray-200 px-3 py-2 text-sm outline-none transition focus:border-red-300 focus:shadow-[0_0_0_3px_rgba(185,28,28,.10)]" + /> +
+ + + + +
+
+ + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/scenarios/[scenarioId]/page.tsx b/src/app/portfolio/[slug]/(portfolio)/scenarios/[scenarioId]/page.tsx new file mode 100644 index 00000000..55fd7b98 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/scenarios/[scenarioId]/page.tsx @@ -0,0 +1,159 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { PORTFOLIO_GOALS } from "@/app/db/schema/portfolio"; +import { measuresDisplayLabels, type MeasureKey } from "@/app/db/schema/recommendations"; +import { constraintSummary } from "@/lib/scenarios/model"; +import { getScenarioWithPlansCount } from "@/lib/scenarios/queries"; +import { + BandChip, + EPC_SOFT, + GOAL_SHORT_LABELS, + GoalIcon, + StatusPill, + formatDate, + formatMoney, + meterGradient, + meterWidth, +} from "../ui"; +import { DetailActions } from "./DetailActions"; + +export const dynamic = "force-dynamic"; + +const rowLabel = + "pt-1 font-manrope text-[11px] font-extrabold uppercase tracking-[.1em] text-gray-400"; + +export default async function ScenarioDetailPage(props: { + params: Promise<{ slug: string; scenarioId: string }>; +}) { + const { slug, scenarioId } = await props.params; + if (!/^\d+$/.test(scenarioId)) notFound(); + const s = await getScenarioWithPlansCount(BigInt(slug), BigInt(scenarioId)); + if (!s) notFound(); + + const isEpc = s.goal === PORTFOLIO_GOALS.EPC && s.goalValue; + const summary = constraintSummary(s.exclusions); + + return ( +
+ + ← All scenarios + +
+ } + meta={`Created ${formatDate(s.createdAt)}${ + s.status === "modelled" + ? ` · modelled against ${s.plansCount.toLocaleString("en-GB")} properties` + : "" + } · configuration is fixed — rename any time`} + > +
+ +
+
Goal
+
+
+ {isEpc ? : } + {isEpc + ? `Bring every property to EPC ${s.goalValue}` + : GOAL_SHORT_LABELS[s.goal] ?? s.goal} + {!isEpc && ( + + — as far as possible{s.budget ? " within budget" : ""} + + )} +
+ {isEpc && ( +
+
+ +
+
+ {["G", "F", "E", "D", "C", "B", "A"].map((b) => ( + {b} + ))} +
+
+ )} +
+
+
+
Housing type
+
+ {s.housingType} + — drives funding treatment +
+
+
+
Budget / property
+
+ {s.budget ? ( + formatMoney(s.budget) + ) : ( + + No cap — the engine recommends the maximum interventions + + )} +
+
+
+
Exclusions
+
+ {summary.kind === "all" ? ( + + None — every measure is on the table + + ) : summary.kind === "only" ? ( + <> +
+ {summary.measures.map((m) => ( + + {m} + + ))} +
+
+ Shown as the allowed set — all other measures are excluded. +
+ + ) : ( +
+ {s.exclusions.map((k) => ( + + {measuresDisplayLabels[k as MeasureKey] ?? k} + + ))} +
+ )} +
+
+
+
+
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/scenarios/new/NewScenarioJourney.tsx b/src/app/portfolio/[slug]/(portfolio)/scenarios/new/NewScenarioJourney.tsx new file mode 100644 index 00000000..3fc372bc --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/scenarios/new/NewScenarioJourney.tsx @@ -0,0 +1,578 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useMutation } from "@tanstack/react-query"; +import { PORTFOLIO_GOALS } from "@/app/db/schema/portfolio"; +import { + measuresDisplayLabels, + measuresList, + type MeasureKey, +} from "@/app/db/schema/recommendations"; +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 { + id: string; + name: string; + housingType: string; + goal: string; + goalValue: string | null; + budget: number | null; + exclusions: string[]; + modelled: boolean; +} + +const MEASURE_GROUPS: [string, MeasureKey[]][] = [ + ["Insulation", ["internal_wall_insulation", "external_wall_insulation", "cavity_wall_insulation", "loft_insulation", "flat_roof_insulation", "room_roof_insulation", "suspended_floor_insulation", "solid_floor_insulation"]], + ["Heating & hot water", ["boiler_upgrade", "high_heat_retention_storage_heaters", "air_source_heat_pump", "secondary_heating", "hot_water_tank_insulation", "cylinder_thermostat"]], + ["Glazing & ventilation", ["double_glazing", "secondary_glazing", "ventilation"]], + ["Renewables & other", ["solar_pv", "low_energy_lighting", "fireplace"]], +]; + +const PRESETS: { label: string; hint: string; excl: MeasureKey[] }[] = [ + { label: "No wet trades", hint: "Rules out plaster-and-screed work: wall and solid-floor insulation", + 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: "Excludes every measure marked as disruptive", + excl: Object.keys(DISRUPTIVE_MEASURES) as MeasureKey[] }, +]; + +const fieldLabel = + "mb-2 block font-manrope text-[11px] font-extrabold uppercase tracking-[.1em] text-gray-600"; +const inputCls = + "w-full rounded-xl border border-gray-200 bg-white px-3 py-2.5 text-sm outline-none transition focus:border-brandmidblue focus:shadow-[0_0_0_3px_rgba(57,67,183,.12)]"; +const btn = + "rounded-xl border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-brandblue transition hover:bg-gray-50"; +const btnPrimary = + "rounded-xl px-4 py-2.5 text-sm font-semibold text-white shadow-[0_2px_10px_rgba(57,67,183,.35)] transition hover:shadow-[0_4px_16px_rgba(57,67,183,.45)]"; +const gradPrimary = { background: "linear-gradient(135deg,#3943b7,#2d348f)" }; + +export function NewScenarioJourney({ + portfolioId, + existing, + defaultHousing, + template, +}: { + portfolioId: string; + existing: ExistingScenario[]; + defaultHousing: string; + template: ExistingScenario | null; +}) { + const router = useRouter(); + const [step, setStep] = useState(1); + const [name, setName] = useState(""); + const [housing, setHousing] = useState(template?.housingType ?? defaultHousing); + const [goal, setGoal] = useState(template?.goal ?? PORTFOLIO_GOALS.EPC); + 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 [errors, setErrors] = useState<{ name?: string; band?: string; excl?: string }>({}); + + const isEpc = goal === PORTFOLIO_GOALS.EPC; + const budgetNumber = budget ? Number(budget) : null; + + const save = useMutation({ + mutationFn: async () => { + const res = await fetch(`/api/portfolio/${portfolioId}/scenarios`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: name.trim(), + housingType: housing, + goal, + goalValue: isEpc ? band : null, + budgetPerProperty: budgetNumber, + exclusions: [...excluded], + }), + }); + if (!res.ok) throw new Error((await res.json()).error ?? "Save failed"); + }, + onSuccess: () => router.push(`/portfolio/${portfolioId}/scenarios`), + onError: (e: Error) => setErrors({ excl: e.message }), + }); + + const suggestion = (() => { + if (name) return null; + if (!isEpc) { + return GOAL_SHORT_LABELS[goal] + (budgetNumber ? ` — ${formatMoney(budgetNumber)} cap` : ""); + } + if (!band) return null; + let s = `Reach EPC ${band}`; + const ex = [...excluded]; + if (ex.length === 1) s += ` — excl. ${measuresDisplayLabels[ex[0] as MeasureKey]}`; + else if (ex.length > 1) s += ` — ${ex.length} exclusions`; + if (budgetNumber) s += ` — ${formatMoney(budgetNumber)}/property`; + return s; + })(); + + const duplicate = + step === 3 + ? findExactDuplicate( + { + housingType: housing, + goal, + goalValue: isEpc ? band : null, + budgetPerProperty: budgetNumber, + exclusions: [...excluded], + }, + existing.map((s) => ({ + id: BigInt(s.id), + name: s.name, + housingType: s.housingType, + goal: s.goal, + goalValue: s.goalValue, + budget: s.budget, + exclusions: s.exclusions.length ? `{${s.exclusions.join(",")}}` : null, + })), + ) + : null; + const duplicateView = duplicate + ? existing.find((s) => s.id === String(duplicate.id)) + : null; + + const next = () => { + if (step === 1) { + const e: typeof errors = {}; + if (!name.trim()) e.name = "Give the scenario a name before continuing."; + if (isEpc && !band) e.band = "Pick the band every property should reach."; + setErrors(e); + if (Object.keys(e).length) return; + } + if (step === 2 && excluded.size === measuresList.length) { + setErrors({ excl: "You've excluded every measure — the engine would have nothing to work with. Allow at least one." }); + return; + } + setErrors({}); + setStep(step + 1); + }; + + const rail = [ + [1, "Goal"], + [2, "Measures"], + [3, "Review"], + ] as const; + + return ( +
+ + ← All scenarios + +
+ + New scenario + +

+ Create a scenario +

+
+
+ +
+
+ {rail.map(([n, label]) => ( +
n + ? "text-gray-600" + : "text-gray-400" + }`} + > + n + ? "border-transparent text-white" + : step === n + ? "border-brandmidblue bg-white text-brandmidblue" + : "border-gray-200 bg-white" + }`} + style={step > n ? gradPrimary : undefined} + > + {step > n ? "✓" : n} + + {label} +
+ ))} +
+ +
+ {step === 1 && isEpc && band && ( + + )} + + {step === 1 && ( +
+

+ What should this scenario achieve? +

+

+ Give it a name your team will recognise, and set the goal the + engine optimises for. +

+
+ + setName(e.target.value)} + /> + {suggestion && ( + + )} + {errors.name &&
{errors.name}
} +
+
+ +
+ {["Social", "Private"].map((h) => ( + + ))} +
+
+ Pre-filled from this portfolio's previous scenarios. Housing + type changes which funding applies. +
+
+
+ +
+ {SCENARIO_GOALS.map((g) => ( + + ))} +
+
+ {isEpc && ( +
+ +
+ {EPC_BANDS.map((b) => ( + + ))} +
+ {errors.band &&
{errors.band}
} +
+ )} +
+ + setBudget(e.target.value)} + /> +
+ Leave empty for no cap — the engine recommends the maximum + interventions. +
+
+
+ )} + + {step === 2 && ( +
+

+ 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 + + {PRESETS.map((p) => ( + + ))} +
+
+ + + + {excluded.size === 0 + ? `All ${measuresList.length} measures allowed` + : `${excluded.size} excluded · ${measuresList.length - excluded.size} allowed`} + +
+ {MEASURE_GROUPS.map(([group, keys]) => ( +
+ + {group} + +
+ {keys.map((k) => { + const off = excluded.has(k); + const disruptiveReason = DISRUPTIVE_MEASURES[k]; + return ( + + ); + })} +
+
+ ))} + {errors.excl &&
{errors.excl}
} +
+ )} + + {step === 3 && ( +
+

+ Review and save +

+

+ 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 && ( +
+
+ Identical to “{duplicateView.name}” — same goal, + housing type, budget and exclusions.{" "} + {duplicateView.modelled + ? "It has already been modelled; running this again would produce the same plans." + : "It hasn't been modelled yet — you may just want that one."} +
+ + View “{duplicateView.name}” + +
+ )} +
+ {[ + ["Name", {name.trim()}], + [ + "Goal", + + {isEpc && band ? : } + {isEpc ? `Bring every property to EPC ${band}` : `${GOAL_SHORT_LABELS[goal]} — as far as possible`} + , + ], + ["Housing type", housing], + [ + "Budget / property", + budgetNumber ? ( + formatMoney(budgetNumber) + ) : ( + No cap — maximum interventions + ), + ], + [ + "Exclusions", + excluded.size === 0 ? ( + None — every measure is on the table + ) : ( + + {[...excluded].sort().map((k) => ( + + {measuresDisplayLabels[k as MeasureKey] ?? k} + + ))} + + ), + ], + ].map(([label, value]) => ( +
+
+ {label} +
+
{value}
+
+ ))} +
+ {errors.excl &&
{errors.excl}
} +
+ )} + +
+ + {step < 3 ? ( + + ) : ( + + )} +
+
+
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/scenarios/new/page.tsx b/src/app/portfolio/[slug]/(portfolio)/scenarios/new/page.tsx new file mode 100644 index 00000000..82f95a28 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/scenarios/new/page.tsx @@ -0,0 +1,37 @@ +import { listScenariosWithStatus } from "@/lib/scenarios/queries"; +import { NewScenarioJourney } from "./NewScenarioJourney"; + +export const dynamic = "force-dynamic"; + +export default async function NewScenarioPage(props: { + params: Promise<{ slug: string }>; + searchParams: Promise<{ from?: string }>; +}) { + const [{ slug }, { from }] = await Promise.all([ + props.params, + props.searchParams, + ]); + const scenarios = await listScenariosWithStatus(BigInt(slug)); + + // Plain-JSON view of existing scenarios: duplicate detection + housing default. + const existing = scenarios.map((s) => ({ + id: String(s.id), + name: s.name ?? `Scenario ${s.id}`, + housingType: s.housingType, + goal: s.goal, + goalValue: s.goalValue, + budget: s.budget, + exclusions: s.exclusions, + modelled: s.status === "modelled", + })); + const template = from ? existing.find((s) => s.id === from) ?? null : null; + + return ( + + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/scenarios/page.tsx b/src/app/portfolio/[slug]/(portfolio)/scenarios/page.tsx new file mode 100644 index 00000000..05113760 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/scenarios/page.tsx @@ -0,0 +1,149 @@ +import Link from "next/link"; +import { PORTFOLIO_GOALS } from "@/app/db/schema/portfolio"; +import { listScenariosWithStatus } from "@/lib/scenarios/queries"; +import { + BandChip, + EPC_SOFT, + GOAL_SHORT_LABELS, + GoalIcon, + StatusPill, + constraintLine, + formatDate, + formatMoney, + meterGradient, + meterWidth, +} from "./ui"; + +export const dynamic = "force-dynamic"; + +export default async function ScenariosPage(props: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await props.params; + const scenarios = await listScenariosWithStatus(BigInt(slug)); + + return ( +
+
+
+ + Scenarios + +

+ Retrofit scenarios +

+
+
+ + + New scenario + +
+

+ Each scenario is a question you ask the modelling engine. Its + configuration is fixed once created — rename any time; a different + question is a new scenario. +

+ + {scenarios.length === 0 ? ( +
+
+ {["G", "F", "E", "D", "C", "B", "A"].map((b) => ( + + ))} +
+

+ No scenarios yet +

+

+ A scenario asks the modelling engine a question — “what does it + take to reach EPC C?” Create your first and the answers appear in + Reporting. +

+ + Create your first scenario + +
+ ) : ( +
+ {scenarios.map((s) => { + const isEpc = s.goal === PORTFOLIO_GOALS.EPC && s.goalValue; + return ( + + +
+ {isEpc ? : } +
+

+ {s.name ?? `Scenario ${s.id}`} +

+
+ {isEpc + ? `Target EPC ${s.goalValue}` + : GOAL_SHORT_LABELS[s.goal] ?? s.goal}{" "} + · {s.housingType} +
+
+
+
+ +
+
+ {constraintLine(s.exclusions)} + {s.budget ? ( + <> + {" "} + · {formatMoney(s.budget)} + /property cap + + ) : null} +
+
+ + + {s.status === "modelled" + ? `${s.plansCount.toLocaleString("en-GB")} properties · ` + : ""} + Created {formatDate(s.createdAt)} + +
+ + ); + })} +
+ )} +
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/scenarios/ui.tsx b/src/app/portfolio/[slug]/(portfolio)/scenarios/ui.tsx new file mode 100644 index 00000000..ac907289 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/scenarios/ui.tsx @@ -0,0 +1,137 @@ +import { PORTFOLIO_GOALS } from "@/app/db/schema/portfolio"; +import type { ScenarioStatus } from "@/lib/scenarios/model"; +import { constraintSummary } from "@/lib/scenarios/model"; + +/** Softened EPC palette from the building-passport efficiency meter. */ +export const EPC_SOFT: Record = { + A: "#117d58", + B: "#2da55c", + C: "#8dbd40", + D: "#f7cd14", + E: "#f3a96a", + F: "#ee8749", + G: "#e25c50", +}; +const DARK_TEXT_BANDS = new Set(["C", "D", "E"]); + +/** Letter ink with adequate contrast on the band's full colour (dark on the light mid-bands, white on the saturated ends). */ +export function bandInk(band: string): string { + return DARK_TEXT_BANDS.has(band) ? "#333a1d" : "#fff"; +} + +/** The band colour as a light tint — used for unselected picker chips so every letter can share one dark ink. */ +export function bandTint(band: string, alpha = 0.22): string { + const hex = (EPC_SOFT[band] ?? "#9ca3af").slice(1); + const [r, g, b] = [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16)); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +export const GOAL_DESCRIPTIONS: Record = { + [PORTFOLIO_GOALS.EPC]: "Bring every property up to a target band.", + [PORTFOLIO_GOALS.CO2]: "Cut carbon as far as possible, within any budget you set.", + [PORTFOLIO_GOALS.ENERGY]: "Cut kWh demand as far as possible, within any budget you set.", + [PORTFOLIO_GOALS.VALUATION]: "Maximise property value uplift, within any budget you set.", +}; +export const GOAL_SHORT_LABELS: Record = { + [PORTFOLIO_GOALS.EPC]: "Increase EPC rating", + [PORTFOLIO_GOALS.CO2]: "Reduce CO₂ emissions", + [PORTFOLIO_GOALS.ENERGY]: "Save energy", + [PORTFOLIO_GOALS.VALUATION]: "Improve valuation", +}; + +/** G→band gradient, mirroring the passport's getEpcGradient. */ +export function meterGradient(band: string): string { + const order = ["G", "F", "E", "D", "C", "B", "A"]; + const upto = order.slice(0, order.indexOf(band) + 1); + const stops = upto.map( + (b, i) => `${EPC_SOFT[b]} ${((i / Math.max(upto.length - 1, 1)) * 100).toFixed(0)}%`, + ); + return `linear-gradient(to right, ${stops.join(", ")})`; +} +export function meterWidth(band: string): string { + return { G: "16%", F: "30%", E: "44%", D: "58%", C: "72%", B: "86%", A: "100%" }[band] ?? "0%"; +} + +export function BandChip({ band, size = 34 }: { band: string; size?: number }) { + return ( + + {band} + + ); +} + +export function GoalIcon({ size = 34 }: { size?: number }) { + return ( + + ↓ + + ); +} + +export function StatusPill({ + status, + className = "", +}: { + status: ScenarioStatus; + className?: string; +}) { + const modelled = status === "modelled"; + return ( + + + {modelled ? "Modelled" : "Awaiting modelling"} + + ); +} + +export function constraintLine(exclusions: string[]): React.ReactNode { + const summary = constraintSummary(exclusions); + if (summary.kind === "all") + return ( + <> + All measures on the table + + ); + if (summary.kind === "only") + return ( + <> + Only: {summary.measures.join(", ")} + + ); + return ( + <> + Excludes{" "} + + {summary.measures.length} measure{summary.measures.length > 1 ? "s" : ""} + + + ); +} + +export const formatDate = (d: Date | string) => + new Date(d).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }); + +export const formatMoney = (n: number) => "£" + n.toLocaleString("en-GB"); diff --git a/src/lib/scenarios/model.test.ts b/src/lib/scenarios/model.test.ts new file mode 100644 index 00000000..52b80303 --- /dev/null +++ b/src/lib/scenarios/model.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it } from "vitest"; +import { measuresList } from "@/app/db/schema/recommendations"; +import { + constraintSummary, + deriveScenarioStatus, + DISRUPTIVE_MEASURES, + findExactDuplicate, + parseExclusions, + scenarioToInsertRow, + serializeExclusions, + validateScenarioConfig, +} from "./model"; + +describe("validateScenarioConfig", () => { + it("requires a target band when the goal is Increasing EPC", () => { + const result = validateScenarioConfig({ + name: "Reach EPC C", + housingType: "Social", + goal: "Increasing EPC", + goalValue: null, + budgetPerProperty: null, + exclusions: [], + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors.goalValue).toMatch(/target/i); + } + }); + + it("accepts an Increasing EPC config with a valid band", () => { + const result = validateScenarioConfig({ + name: "Reach EPC C", + housingType: "Social", + goal: "Increasing EPC", + goalValue: "C", + budgetPerProperty: null, + exclusions: [], + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.goalValue).toBe("C"); + } + }); + + it("accepts a maximise-goal without budget and normalises goalValue to null", () => { + // "Reducing CO2 emissions" means "as much as possible"; no goal value — + // and no budget means the engine recommends the maximum interventions. + const result = validateScenarioConfig({ + name: "Max CO₂ reduction", + housingType: "Social", + goal: "Reducing CO2 emissions", + goalValue: "20%", // stray value must not survive + budgetPerProperty: null, + exclusions: [], + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.goalValue).toBeNull(); + } + }); + + it("rejects a goal outside the portfolio goal enum", () => { + const result = validateScenarioConfig({ + name: "X", + housingType: "Social", + goal: "Net Zero", + goalValue: null, + budgetPerProperty: null, + exclusions: [], + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors.goal).toBeTruthy(); + }); + + it("requires a name and trims surrounding whitespace", () => { + const base = { + housingType: "Social", + goal: "Increasing EPC", + goalValue: "C" as const, + budgetPerProperty: null, + exclusions: [], + }; + const blank = validateScenarioConfig({ ...base, name: " " }); + expect(blank.ok).toBe(false); + if (!blank.ok) expect(blank.errors.name).toBeTruthy(); + + const padded = validateScenarioConfig({ ...base, name: " Reach EPC C " }); + expect(padded.ok).toBe(true); + if (padded.ok) expect(padded.value.name).toBe("Reach EPC C"); + }); + + it("accepts a positive per-property budget and rejects zero or negative", () => { + const base = { + name: "Budgeted", + housingType: "Social", + goal: "Energy Savings", + goalValue: null, + exclusions: [], + }; + expect(validateScenarioConfig({ ...base, budgetPerProperty: 5000 }).ok).toBe(true); + const zero = validateScenarioConfig({ ...base, budgetPerProperty: 0 }); + expect(zero.ok).toBe(false); + if (!zero.ok) expect(zero.errors.budgetPerProperty).toBeTruthy(); + expect(validateScenarioConfig({ ...base, budgetPerProperty: -100 }).ok).toBe(false); + }); + + it("rejects a housing type outside Social/Private", () => { + const result = validateScenarioConfig({ + name: "X", + housingType: "Council", + goal: "Increasing EPC", + goalValue: "C", + budgetPerProperty: null, + exclusions: [], + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors.housingType).toBeTruthy(); + }); + + const validBase = { + name: "X", + housingType: "Social", + goal: "Increasing EPC", + goalValue: "C", + budgetPerProperty: null, + }; + + it("normalises exclusions: dedupes, sorts, and an empty set is valid", () => { + const result = validateScenarioConfig({ + ...validBase, + exclusions: ["solid_floor_insulation", "loft_insulation", "solid_floor_insulation"], + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.exclusions).toEqual(["loft_insulation", "solid_floor_insulation"]); + } + // empty exclusion set = every measure on the table — explicitly valid + expect(validateScenarioConfig({ ...validBase, exclusions: [] }).ok).toBe(true); + }); + + it("rejects unknown measure keys", () => { + const result = validateScenarioConfig({ + ...validBase, + exclusions: ["asbestos_removal"], + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors.exclusions).toMatch(/asbestos_removal/); + }); + + it("rejects excluding every measure — the engine needs at least one option", () => { + const result = validateScenarioConfig({ ...validBase, exclusions: [...measuresList] }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors.exclusions).toBeTruthy(); + }); +}); + +describe("scenarioToInsertRow", () => { + it("maps a validated config to an ADR-0003 app-authored row", () => { + const result = validateScenarioConfig({ + name: "Reach EPC C", + housingType: "Social", + goal: "Increasing EPC", + goalValue: "C", + budgetPerProperty: 5000, + exclusions: ["solid_floor_insulation"], + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + const row = scenarioToInsertRow(result.value, 796n); + expect(row).toMatchObject({ + portfolioId: 796n, + name: "Reach EPC C", + housingType: "Social", + goal: "Increasing EPC", + goalValue: "C", + budget: 5000, + exclusions: "{solid_floor_insulation}", + multiPlan: true, // universal in modern data + isDefault: false, // backend owns default-scenario semantics + }); + }); + + it("stores maximise-goals with null goal value, budget and exclusions", () => { + const result = validateScenarioConfig({ + name: "Max CO₂", + housingType: "Private", + goal: "Reducing CO2 emissions", + goalValue: null, + budgetPerProperty: null, + exclusions: [], + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + const row = scenarioToInsertRow(result.value, 42n); + expect(row.goalValue).toBeNull(); + expect(row.budget).toBeNull(); + expect(row.exclusions).toBeNull(); + }); +}); + +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}" }, + { id: 2n, name: "Max CO₂", housingType: "Social", goal: "Reducing CO2 emissions", + goalValue: null, budget: 6000, exclusions: null }, + ]; + const config = { + name: "A totally different name", + housingType: "Social", + goal: "Increasing EPC", + goalValue: "C" as string | null, + budgetPerProperty: null as number | null, + exclusions: ["solid_floor_insulation", "external_wall_insulation"], + }; + + it("matches on configuration regardless of name or exclusion order", () => { + expect(findExactDuplicate(config, existing)?.id).toBe(1n); + }); + + it("does not match when any configuration facet differs", () => { + expect(findExactDuplicate({ ...config, goalValue: "B" }, existing)).toBeNull(); + expect(findExactDuplicate({ ...config, budgetPerProperty: 5000 }, existing)).toBeNull(); + expect(findExactDuplicate({ ...config, housingType: "Private" }, existing)).toBeNull(); + expect( + findExactDuplicate({ ...config, exclusions: ["solid_floor_insulation"] }, existing), + ).toBeNull(); + }); +}); + +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"); + expect(deriveScenarioStatus(292)).toBe("modelled"); + }); + + it("frames constraints from whichever side is shorter", () => { + expect(constraintSummary([])).toEqual({ kind: "all" }); + expect(constraintSummary(["solid_floor_insulation"])).toEqual({ + kind: "excludes", + measures: ["Solid Floor Insulation"], + }); + // 18 exclusions → say what's allowed instead (in canonical measure order) + const onlyTwo = measuresList.filter( + (k) => k !== "solar_pv" && k !== "air_source_heat_pump", + ); + expect(constraintSummary(onlyTwo)).toEqual({ + kind: "only", + measures: ["Air Source Heat Pump", "Solar PV"], + }); + }); +}); + +describe("exclusions text format", () => { + it("serialises to the backend's brace format and round-trips", () => { + const keys = ["loft_insulation", "solid_floor_insulation"]; + const text = serializeExclusions(keys); + expect(text).toBe("{loft_insulation,solid_floor_insulation}"); + expect(parseExclusions(text)).toEqual(keys); + }); + + it("serialises the empty set as null and parses null/empty as no exclusions", () => { + expect(serializeExclusions([])).toBeNull(); + expect(parseExclusions(null)).toEqual([]); + expect(parseExclusions("{}")).toEqual([]); + }); + + it("parses the legacy space-padded variant the backend also writes", () => { + // e.g. scenario 1270: "{external_wall_insulation, internal_wall_insulation, ...}" + expect( + parseExclusions("{external_wall_insulation, internal_wall_insulation}"), + ).toEqual(["external_wall_insulation", "internal_wall_insulation"]); + }); +}); diff --git a/src/lib/scenarios/model.ts b/src/lib/scenarios/model.ts new file mode 100644 index 00000000..5b596420 --- /dev/null +++ b/src/lib/scenarios/model.ts @@ -0,0 +1,215 @@ +/** + * Scenario domain model — the pure logic behind the Scenarios tab. + * + * See CONTEXT.md (Scenario, Goal, Budget, Measure exclusion) and ADR-0003: + * a Scenario's configuration is immutable once created; the name is a + * renameable label; goal semantics are "Increasing EPC" targets a band while + * every other goal maximises within an optional per-property budget. + */ + +import { PORTFOLIO_GOALS } from "@/app/db/schema/portfolio"; +import { + HousingType, + measuresDisplayLabels, + measuresList, + type MeasureKey, +} from "@/app/db/schema/recommendations"; + +export const EPC_BANDS = ["A", "B", "C", "D", "E", "F", "G"] as const; +export type EpcBand = (typeof EPC_BANDS)[number]; + +/** + * Goals a user may author a Scenario with. "None" exists in the portfolio + * goal enum but is not a modelling objective, so it is not offered. + */ +export const SCENARIO_GOALS = [ + PORTFOLIO_GOALS.EPC, + PORTFOLIO_GOALS.CO2, + PORTFOLIO_GOALS.ENERGY, + PORTFOLIO_GOALS.VALUATION, +] as const; + +export interface ScenarioConfigInput { + name: string; + housingType: string; + goal: string; + goalValue: string | null; + budgetPerProperty: number | null; + exclusions: string[]; +} + +export type ValidationResult = + | { ok: true; value: ScenarioConfigInput } + | { ok: false; errors: Partial> }; + +export function validateScenarioConfig( + input: ScenarioConfigInput, +): ValidationResult { + const errors: Partial> = {}; + + if (!(SCENARIO_GOALS as readonly string[]).includes(input.goal)) { + errors.goal = `Goal must be one of: ${SCENARIO_GOALS.join(", ")}`; + } + + if (!HousingType.includes(input.housingType)) { + errors.housingType = `Housing type must be one of: ${HousingType.join(", ")}`; + } + + const name = input.name.trim(); + if (!name) { + errors.name = "Give the scenario a name"; + } + + if (input.budgetPerProperty !== null && !(input.budgetPerProperty > 0)) { + errors.budgetPerProperty = + "Budget per property must be a positive amount, or empty for no cap"; + } + + const isEpcGoal = input.goal === PORTFOLIO_GOALS.EPC; + let goalValue: string | null = null; + if (isEpcGoal) { + if (!input.goalValue || !EPC_BANDS.includes(input.goalValue as EpcBand)) { + errors.goalValue = "A target EPC band (A–G) is required for this goal"; + } else { + goalValue = input.goalValue; + } + } + // Non-EPC goals are "improve as much as possible" — they carry no goal + // value (any stray input is dropped); the only brake is the budget. + + const exclusions = [...new Set(input.exclusions)].sort(); + const unknown = exclusions.filter((k) => !(measuresList as string[]).includes(k)); + if (unknown.length > 0) { + errors.exclusions = `Unknown measures: ${unknown.join(", ")}`; + } else if (exclusions.length === measuresList.length) { + errors.exclusions = + "Every measure is excluded — the engine would have nothing to work with. Allow at least one."; + } + + if (Object.keys(errors).length > 0) return { ok: false, errors }; + return { ok: true, value: { ...input, name, goalValue, exclusions } }; +} + +/** + * `scenario.exclusions` is a text column holding the backend's brace format, + * e.g. "{loft_insulation,solid_floor_insulation}" — some legacy rows pad + * entries with spaces. Empty set is stored as NULL (matching rows the + * backend writes with no exclusions). + */ +export function serializeExclusions(keys: string[]): string | null { + if (keys.length === 0) return null; + return "{" + keys.join(",") + "}"; +} + +/** + * Map a validated config to the `scenario` insert row per ADR-0003: + * app-authored rows always set multiPlan=true (universal in modern data) and + * isDefault=false (the backend owns default-scenario semantics at modelling + * time), and never touch the legacy aggregate columns. + */ +export function scenarioToInsertRow( + value: ScenarioConfigInput, + portfolioId: bigint, +) { + return { + portfolioId, + name: value.name, + housingType: value.housingType, + goal: value.goal, + goalValue: value.goalValue, + budget: value.budgetPerProperty, + exclusions: serializeExclusions(value.exclusions), + multiPlan: true, + isDefault: false, + }; +} + +export function parseExclusions(text: string | null): string[] { + if (!text) return []; + const inner = text.trim().replace(/^\{/, "").replace(/\}$/, ""); + if (!inner.trim()) return []; + 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. */ +export function deriveScenarioStatus(plansCount: number): ScenarioStatus { + return plansCount > 0 ? "modelled" : "awaiting_modelling"; +} + +export type ConstraintSummary = + | { kind: "all" } + | { kind: "excludes"; measures: string[] } + | { kind: "only"; measures: string[] }; + +/** + * Frame a scenario's measure constraints from whichever side is shorter: + * few exclusions → "Excludes …"; nearly-all excluded (≤3 allowed) → "Only …". + * Returns display labels, in the canonical measuresList order. + */ +export function constraintSummary(exclusions: string[]): ConstraintSummary { + if (exclusions.length === 0) return { kind: "all" }; + const excluded = new Set(exclusions); + const allowed = measuresList.filter((k) => !excluded.has(k)); + if (allowed.length <= 3) { + return { kind: "only", measures: allowed.map((k) => measuresDisplayLabels[k]) }; + } + return { + kind: "excludes", + measures: measuresList + .filter((k) => excluded.has(k)) + .map((k) => measuresDisplayLabels[k as MeasureKey]), + }; +} + +/** The stored shape a duplicate check compares against (subset of the scenario row). */ +export interface ExistingScenarioConfig { + id: bigint; + name: string | null; + housingType: string; + goal: string; + goalValue: string | null; + budget: number | null; + exclusions: string | null; +} + +/** + * 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. + */ +export function findExactDuplicate( + config: Pick< + ScenarioConfigInput, + "housingType" | "goal" | "goalValue" | "budgetPerProperty" | "exclusions" + >, + existing: ExistingScenarioConfig[], +): ExistingScenarioConfig | null { + const wanted = [...new Set(config.exclusions)].sort().join(","); + return ( + existing.find( + (s) => + s.goal === config.goal && + (s.goalValue ?? null) === (config.goalValue ?? null) && + s.housingType === config.housingType && + (s.budget ?? null) === (config.budgetPerProperty ?? null) && + parseExclusions(s.exclusions).sort().join(",") === wanted, + ) ?? null + ); +} diff --git a/src/lib/scenarios/queries.ts b/src/lib/scenarios/queries.ts new file mode 100644 index 00000000..74b0de9f --- /dev/null +++ b/src/lib/scenarios/queries.ts @@ -0,0 +1,156 @@ +/** + * Scenario persistence per ADR-0003. Query discipline: + * - gallery: ONE grouped plans-count query per portfolio (index prefix scan) + * - detail: ONE count scoped to (portfolio_id, scenario_id) (index-only) + * - delete: single atomic statement guarded on no-plans-exist + * Never per-scenario probes inside a list. + */ +import { db } from "@/app/db/db"; +import { desc, eq, and, sql } from "drizzle-orm"; +import { scenario } from "@/app/db/schema/recommendations"; +import { + deriveScenarioStatus, + parseExclusions, + scenarioToInsertRow, + type ScenarioConfigInput, + type ScenarioStatus, +} from "./model"; + +export interface ScenarioListItem { + id: bigint; + name: string | null; + housingType: string; + goal: string; + goalValue: string | null; + budget: number | null; + exclusions: string[]; + createdAt: Date; + plansCount: number; + status: ScenarioStatus; +} + +export async function listScenariosWithStatus( + portfolioId: bigint, +): Promise { + const [rows, counts] = await Promise.all([ + db + .select() + .from(scenario) + .where(eq(scenario.portfolioId, portfolioId)) + .orderBy(desc(scenario.createdAt), desc(scenario.id)), + db.execute<{ scenario_id: string; n: number }>(sql` + SELECT scenario_id, COUNT(DISTINCT property_id)::int AS n + FROM plan + WHERE portfolio_id = ${portfolioId} AND scenario_id IS NOT NULL + GROUP BY scenario_id + `), + ]); + const byScenario = new Map(counts.rows.map((r) => [String(r.scenario_id), r.n])); + return rows.map((s) => { + const plansCount = byScenario.get(String(s.id)) ?? 0; + return { + id: s.id, + name: s.name, + housingType: s.housingType, + goal: s.goal, + goalValue: s.goalValue, + budget: s.budget, + exclusions: parseExclusions(s.exclusions), + createdAt: s.createdAt, + plansCount, + status: deriveScenarioStatus(plansCount), + }; + }); +} + +export async function getScenarioWithPlansCount( + portfolioId: bigint, + scenarioId: bigint, +): Promise { + const [row] = await db + .select() + .from(scenario) + .where(and(eq(scenario.id, scenarioId), eq(scenario.portfolioId, portfolioId))) + .limit(1); + if (!row) return null; + const plansCount = await countScenarioProperties(portfolioId, scenarioId); + return { + id: row.id, + name: row.name, + housingType: row.housingType, + goal: row.goal, + goalValue: row.goalValue, + budget: row.budget, + exclusions: parseExclusions(row.exclusions), + createdAt: row.createdAt, + plansCount, + status: deriveScenarioStatus(plansCount), + }; +} + +/** Index-only scan on the plan composite index — gates delete and powers the blocked-delete message. */ +export async function countScenarioProperties( + portfolioId: bigint, + scenarioId: bigint, +): Promise { + const res = await db.execute<{ n: number }>(sql` + SELECT COUNT(DISTINCT property_id)::int AS n + FROM plan + WHERE portfolio_id = ${portfolioId} AND scenario_id = ${scenarioId} + `); + return res.rows[0].n; +} + +export async function createScenario( + portfolioId: bigint, + config: ScenarioConfigInput, +): Promise { + const [created] = await db + .insert(scenario) + .values(scenarioToInsertRow(config, portfolioId)) + .returning({ id: scenario.id }); + return created.id; +} + +export async function renameScenario( + portfolioId: bigint, + scenarioId: bigint, + name: string, +): Promise { + const updated = await db + .update(scenario) + .set({ name }) + .where(and(eq(scenario.id, scenarioId), eq(scenario.portfolioId, portfolioId))) + .returning({ id: scenario.id }); + return updated.length > 0; +} + +export type DeleteScenarioResult = + | { outcome: "deleted" } + | { outcome: "not_found" } + | { outcome: "has_plans"; plansCount: number }; + +/** + * Atomic guarded delete: the no-plans check lives inside the DELETE statement + * so a modelling run landing plans mid-request cannot orphan them (ADR-0003). + */ +export async function deleteScenarioIfUnmodelled( + portfolioId: bigint, + scenarioId: bigint, +): Promise { + const res = await db.execute(sql` + DELETE FROM scenario s + WHERE s.id = ${scenarioId} + AND s.portfolio_id = ${portfolioId} + AND NOT EXISTS ( + SELECT 1 FROM plan p + WHERE p.portfolio_id = ${portfolioId} AND p.scenario_id = s.id + ) + RETURNING s.id + `); + if (res.rows.length > 0) return { outcome: "deleted" }; + + const plansCount = await countScenarioProperties(portfolioId, scenarioId); + if (plansCount > 0) return { outcome: "has_plans", plansCount }; + return { outcome: "not_found" }; +}