Merge pull request #353 from Hestia-Homes/feature/portfolio-scenarios

feat(scenarios): portfolio Scenarios tab — gallery, detail, and creation journey
This commit is contained in:
KhalimCK 2026-07-06 16:21:21 +01:00 committed by GitHub
commit c861d4a0b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 2156 additions and 0 deletions

View file

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

View file

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

View file

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

View file

@ -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<string, unknown>;
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 });
}

View file

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

View file

@ -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<string | null>(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 (
<>
<div className="mb-1 flex items-center justify-between gap-4">
{editing ? (
<div className="flex min-w-0 flex-1 items-center gap-2">
<input
autoFocus
value={draftName}
maxLength={120}
onChange={(e) => 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"
/>
<button
className="rounded-xl px-4 py-2 text-sm font-semibold text-white"
style={{ background: "linear-gradient(135deg,#3943b7,#2d348f)" }}
disabled={!draftName.trim() || rename.isLoading}
onClick={() => rename.mutate(draftName.trim())}
>
Save
</button>
<button
className="px-2 py-2 text-sm font-semibold text-brandmidblue"
onClick={() => setEditing(false)}
>
Cancel
</button>
</div>
) : (
<h2 className="min-w-0 truncate font-manrope text-2xl font-extrabold tracking-tight text-brandblue">
{name}
<button
title="Rename scenario"
aria-label="Rename scenario"
className="ml-2 rounded-lg px-2 py-0.5 align-[2px] text-[15px] text-gray-400 hover:bg-brandlightblue hover:text-brandmidblue"
onClick={() => {
setDraftName(name);
setEditing(true);
}}
>
</button>
</h2>
)}
{statusPill}
</div>
<div className="mb-5 text-[13px] tabular-nums text-gray-400">{meta}</div>
{children}
<div className="mt-5 flex flex-wrap items-center gap-3">
<Link
href={modelled ? `/portfolio/${portfolioId}/reporting` : "#"}
aria-disabled={!modelled}
className={`${btn} ${modelled ? "" : "pointer-events-none opacity-45"}`}
>
View results in Reporting
</Link>
<Link href={`/portfolio/${portfolioId}/scenarios/new?from=${scenarioId}`} className={btn}>
Use as template
</Link>
{!modelled && (
<span className="text-[13px] text-gray-400">
Results will appear here once modelling has run against this scenario.
</span>
)}
</div>
<div className="mt-6 flex flex-wrap items-center gap-3.5 border-t border-gray-100 pt-4">
{modelled ? (
<>
<button className={`${btn} !text-red-800/70`} disabled>
Delete scenario
</button>
<span className="max-w-[52ch] text-[13px] text-gray-400">
There are{" "}
<b className="font-semibold text-gray-600">
{plansCount.toLocaleString("en-GB")} properties
</b>{" "}
with plans in this scenario, so it can&apos;t be deleted. Contact a
Domna administrator to remove the plans first.
</span>
</>
) : (
<>
<button
className={`${btn} border-red-100 !text-red-800 hover:!bg-red-50`}
onClick={() => {
setDeleteConfirmation("");
setDeleteOpen(true);
}}
>
Delete scenario
</button>
<span className="max-w-[52ch] text-[13px] text-gray-400">
Nothing has been modelled against this scenario deleting removes
only the configuration.
</span>
</>
)}
</div>
{error && <div className="mt-3 text-[13px] text-red-800">{error}</div>}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent className="max-w-md">
<DialogTitle className="font-manrope text-lg font-extrabold text-brandblue">
Delete {name}?
</DialogTitle>
<p className="text-sm text-gray-600">
Nothing has been modelled against this scenario deleting removes
only the configuration. This can&apos;t be undone.
</p>
<div>
<label
htmlFor="delete-confirmation"
className="mb-1.5 block text-sm text-gray-600"
>
To confirm, type <strong className="text-brandblue">delete</strong>
</label>
<input
id="delete-confirmation"
autoFocus
type="text"
value={deleteConfirmation}
placeholder="delete"
onChange={(e) => 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)]"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteOpen(false)}>
Cancel
</Button>
<Button
className="bg-red-700 hover:bg-red-800"
disabled={!deleteConfirmed || remove.isLoading}
onClick={() => remove.mutate()}
>
{remove.isLoading ? "Deleting…" : "Delete scenario"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -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 (
<div className="max-w-8xl mx-auto px-6 pb-16 pt-6">
<Link
href={`/portfolio/${slug}/scenarios`}
className="mb-4 inline-flex items-center gap-1.5 text-[13px] text-gray-600 hover:text-brandblue"
>
All scenarios
</Link>
<div className="max-w-[780px]">
<DetailActions
portfolioId={slug}
scenarioId={String(s.id)}
name={s.name ?? `Scenario ${s.id}`}
modelled={s.status === "modelled"}
plansCount={s.plansCount}
statusPill={<StatusPill status={s.status} />}
meta={`Created ${formatDate(s.createdAt)}${
s.status === "modelled"
? ` · modelled against ${s.plansCount.toLocaleString("en-GB")} properties`
: ""
} · configuration is fixed rename any time`}
>
<div className="relative overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-sm">
<span
aria-hidden
className="pointer-events-none absolute -right-14 -top-14 h-[170px] w-[170px] rounded-full opacity-[.14] blur-[46px]"
style={{ background: isEpc ? EPC_SOFT[s.goalValue!] : "#3943b7" }}
/>
<div className="relative grid grid-cols-[200px_1fr] items-start gap-4 border-b border-gray-100 px-6 py-4">
<div className={rowLabel}>Goal</div>
<div className="text-sm">
<div className="flex items-center gap-2.5 font-semibold text-brandblue">
{isEpc ? <BandChip band={s.goalValue!} /> : <GoalIcon />}
{isEpc
? `Bring every property to EPC ${s.goalValue}`
: GOAL_SHORT_LABELS[s.goal] ?? s.goal}
{!isEpc && (
<span className="font-normal text-gray-600">
as far as possible{s.budget ? " within budget" : ""}
</span>
)}
</div>
{isEpc && (
<div className="mt-3 max-w-[420px]">
<div className="h-1.5 overflow-hidden rounded-full bg-gray-100">
<i
className="block h-full rounded-full"
style={{
width: meterWidth(s.goalValue!),
background: meterGradient(s.goalValue!),
}}
/>
</div>
<div className="mt-1 flex justify-between font-manrope text-[10px] font-extrabold tracking-wide text-gray-400">
{["G", "F", "E", "D", "C", "B", "A"].map((b) => (
<span key={b}>{b}</span>
))}
</div>
</div>
)}
</div>
</div>
<div className="relative grid grid-cols-[200px_1fr] items-start gap-4 border-b border-gray-100 px-6 py-4">
<div className={rowLabel}>Housing type</div>
<div className="text-sm text-brandblue">
{s.housingType}
<span className="text-gray-400"> drives funding treatment</span>
</div>
</div>
<div className="relative grid grid-cols-[200px_1fr] items-start gap-4 border-b border-gray-100 px-6 py-4">
<div className={rowLabel}>Budget / property</div>
<div className="text-sm text-brandblue">
{s.budget ? (
formatMoney(s.budget)
) : (
<span className="text-gray-400">
No cap the engine recommends the maximum interventions
</span>
)}
</div>
</div>
<div className="relative grid grid-cols-[200px_1fr] items-start gap-4 px-6 py-4">
<div className={rowLabel}>Exclusions</div>
<div className="text-sm">
{summary.kind === "all" ? (
<span className="text-gray-400">
None every measure is on the table
</span>
) : summary.kind === "only" ? (
<>
<div className="flex flex-wrap gap-1.5">
{summary.measures.map((m) => (
<span
key={m}
className="rounded-full border border-gray-200 bg-gray-50 px-3 py-0.5 text-[13px]"
>
{m}
</span>
))}
</div>
<div className="mt-2 text-xs text-gray-400">
Shown as the allowed set all other measures are excluded.
</div>
</>
) : (
<div className="flex flex-wrap gap-1.5">
{s.exclusions.map((k) => (
<span
key={k}
className="rounded-full border border-red-100 bg-white px-3 py-0.5 text-[13px] text-red-800 line-through"
>
{measuresDisplayLabels[k as MeasureKey] ?? k}
</span>
))}
</div>
)}
</div>
</div>
</div>
</DetailActions>
</div>
</div>
);
}

View file

@ -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<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 [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 (
<div className="max-w-8xl mx-auto px-6 pb-16 pt-6">
<Link
href={`/portfolio/${portfolioId}/scenarios`}
className="mb-4 inline-flex items-center gap-1.5 text-[13px] text-gray-600 hover:text-brandblue"
>
All scenarios
</Link>
<div className="mb-6">
<span className="font-manrope text-[11px] font-extrabold uppercase tracking-[.14em] text-brandmidblue">
New scenario
</span>
<h2 className="mt-0.5 font-manrope text-2xl font-extrabold tracking-tight text-brandblue">
Create a scenario
</h2>
<div
className="mt-3 h-[3px] max-w-[280px] rounded-full"
style={{ background: "linear-gradient(to right,#14163d 0%,#3943b7 34%,rgba(57,67,183,0) 90%)" }}
/>
</div>
<div className="grid max-w-[1000px] grid-cols-1 gap-8 md:grid-cols-[225px_minmax(0,1fr)]">
<div className="sticky top-5 flex flex-col self-start">
{rail.map(([n, label]) => (
<div
key={n}
className={`flex items-center gap-3 rounded-xl px-3 py-2.5 font-medium ${
step === n
? "bg-brandlightblue font-semibold text-brandblue"
: step > n
? "text-gray-600"
: "text-gray-400"
}`}
>
<span
className={`inline-flex h-[25px] w-[25px] flex-none items-center justify-center rounded-full border-[1.5px] font-manrope text-xs font-extrabold ${
step > 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}
</span>
{label}
</div>
))}
</div>
<div className="relative overflow-hidden rounded-2xl border border-gray-100 bg-white p-7 shadow-sm">
{step === 1 && isEpc && band && (
<span
aria-hidden
className="pointer-events-none absolute -right-14 -top-14 h-[170px] w-[170px] rounded-full opacity-[.14] blur-[46px]"
style={{ background: EPC_SOFT[band] }}
/>
)}
{step === 1 && (
<div className="relative">
<h3 className="font-manrope text-lg font-extrabold text-brandblue">
What should this scenario achieve?
</h3>
<p className="mb-6 mt-1 max-w-[60ch] text-sm text-gray-600">
Give it a name your team will recognise, and set the goal the
engine optimises for.
</p>
<div className="mb-5">
<label className={fieldLabel} htmlFor="f-name">Scenario name</label>
<input
id="f-name"
className={`${inputCls} max-w-[440px]`}
value={name}
placeholder="e.g. Reach EPC C — no wet trades"
onChange={(e) => setName(e.target.value)}
/>
{suggestion && (
<button
className="mt-2 inline-block rounded-full border border-dashed border-brandmidblue bg-brandlightblue px-3 py-1 text-xs font-semibold text-brandmidblue"
onClick={() => setName(suggestion)}
>
Use {suggestion}
</button>
)}
{errors.name && <div className="mt-2 text-[13px] text-red-800">{errors.name}</div>}
</div>
<div className="mb-5">
<label className={fieldLabel}>Housing type</label>
<div className="inline-flex overflow-hidden rounded-xl border border-gray-200 bg-white" role="group">
{["Social", "Private"].map((h) => (
<button
key={h}
aria-pressed={housing === h}
className={`px-5 py-2 text-sm font-semibold ${
housing === h ? "text-white" : "text-gray-600"
} ${h === "Private" ? "border-l border-gray-200" : ""}`}
style={housing === h ? gradPrimary : undefined}
onClick={() => setHousing(h)}
>
{h}
</button>
))}
</div>
<div className="mt-2 text-xs text-gray-400">
Pre-filled from this portfolio&apos;s previous scenarios. Housing
type changes which funding applies.
</div>
</div>
<div className="mb-5">
<label className={fieldLabel}>Goal</label>
<div className="grid grid-cols-[repeat(auto-fill,minmax(215px,1fr))] gap-2.5">
{SCENARIO_GOALS.map((g) => (
<button
key={g}
aria-pressed={goal === g}
className={`flex flex-col gap-1 rounded-xl border p-3.5 text-left transition ${
goal === g
? "border-brandmidblue bg-brandlightblue shadow-[0_0_0_1px_#3943b7]"
: "border-gray-200 bg-white hover:border-gray-400"
}`}
onClick={() => setGoal(g)}
>
<b className="font-manrope text-sm font-bold text-brandblue">
{GOAL_SHORT_LABELS[g]}
</b>
<span className="text-xs leading-relaxed text-gray-600">
{GOAL_DESCRIPTIONS[g]}
</span>
</button>
))}
</div>
</div>
{isEpc && (
<div className="mb-5 pb-5">
<label className={fieldLabel}>Target band</label>
<div className="flex max-w-[480px] gap-1.5">
{EPC_BANDS.map((b) => (
<button
key={b}
aria-pressed={band === b}
className="relative h-[52px] rounded-xl border-2 font-manrope text-lg font-extrabold transition-all"
style={
band === b
? {
background: EPC_SOFT[b],
color: bandInk(b),
flexGrow: 1.7,
transform: "translateY(-3px)",
borderColor: "#14163d",
boxShadow: "inset 0 -12px 16px rgba(0,0,0,.10)",
}
: {
background: bandTint(b),
color: "#3f4757",
flexGrow: 1,
borderColor: "transparent",
}
}
onClick={() => setBand(b)}
>
{b}
{band === b && (
<span className="absolute -bottom-[21px] left-1/2 -translate-x-1/2 text-[9px] font-extrabold uppercase tracking-[.14em] text-gray-600">
target
</span>
)}
</button>
))}
</div>
{errors.band && <div className="mt-2 text-[13px] text-red-800">{errors.band}</div>}
</div>
)}
<div>
<label className={fieldLabel} htmlFor="f-budget">
Budget per property (£){isEpc ? " — optional" : ""}
</label>
<input
id="f-budget"
type="number"
min={0}
step={100}
className={`${inputCls} max-w-[220px]`}
value={budget}
placeholder="No cap"
onChange={(e) => setBudget(e.target.value)}
/>
<div className="mt-2 text-xs text-gray-400">
Leave empty for no cap the engine recommends the maximum
interventions.
</div>
</div>
</div>
)}
{step === 2 && (
<div className="relative">
<h3 className="font-manrope text-lg font-extrabold text-brandblue">
Which measures are on the table?
</h3>
<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
</span>
{PRESETS.map((p) => (
<button
key={p.label}
title={p.hint}
className={`${btn} !rounded-full !px-3.5 !py-1 !text-xs`}
onClick={() => setExcluded(new Set(p.excl))}
>
{p.label}
</button>
))}
</div>
<div className="mb-5 flex items-center gap-2.5">
<button className={btn} onClick={() => setExcluded(new Set(measuresList))}>
Exclude all
</button>
<button className={btn} onClick={() => setExcluded(new Set())}>
Allow all
</button>
<span className="ml-auto text-[13px] tabular-nums text-gray-600">
{excluded.size === 0
? `All ${measuresList.length} measures allowed`
: `${excluded.size} excluded · ${measuresList.length - excluded.size} allowed`}
</span>
</div>
{MEASURE_GROUPS.map(([group, keys]) => (
<div key={group} className="mb-5">
<span className="mb-2 block font-manrope text-[11px] font-extrabold uppercase tracking-[.1em] text-gray-400">
{group}
</span>
<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"
: 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) => {
const nextSet = new Set(prev);
if (nextSet.has(k)) nextSet.delete(k);
else nextSet.add(k);
return nextSet;
})
}
>
<span className={off ? "text-gray-400" : "font-bold text-brandmidblue"}>
{off ? "✕" : "✓"}
</span>
{measuresDisplayLabels[k]}
{disruptiveReason && !off && (
<span className="font-manrope text-[9px] font-extrabold uppercase tracking-[.08em] text-amber-600">
disruptive
</span>
)}
</button>
);
})}
</div>
</div>
))}
{errors.excl && <div className="text-[13px] text-red-800">{errors.excl}</div>}
</div>
)}
{step === 3 && (
<div className="relative">
<h3 className="font-manrope text-lg font-extrabold text-brandblue">
Review and save
</h3>
<p className="mb-5 mt-1 max-w-[60ch] text-sm text-gray-600">
Check the configuration it can&apos;t be changed once saved (you
can rename later). A different question is a new scenario.
</p>
{(() => {
const allowedDisruptive = (
Object.keys(DISRUPTIVE_MEASURES) as MeasureKey[]
).filter((k) => !excluded.has(k));
return allowedDisruptive.length > 0 ? (
<div className="mb-5 max-w-[62ch] rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-[13px] text-amber-900">
This scenario still allows{" "}
<b>
{allowedDisruptive
.map((k) => measuresDisplayLabels[k])
.join(", ")}
</b>{" "}
disruptive measure{allowedDisruptive.length > 1 ? "s" : ""}{" "}
landlords often exclude. Go back a step if you&apos;d rather
rule {allowedDisruptive.length > 1 ? "them" : "it"} out.
</div>
) : null;
})()}
{duplicateView && (
<div
role="alert"
className="mb-5 flex flex-wrap items-center justify-between gap-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-[13px] text-amber-900"
>
<div className="max-w-[52ch]">
<b>Identical to {duplicateView.name}</b> 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."}
</div>
<Link
href={`/portfolio/${portfolioId}/scenarios/${duplicateView.id}`}
className={`${btn} flex-none`}
>
View {duplicateView.name}
</Link>
</div>
)}
<div className="rounded-xl border border-gray-100 bg-white">
{[
["Name", <b key="n" className="font-semibold">{name.trim()}</b>],
[
"Goal",
<span key="g" className="flex items-center gap-2.5 font-semibold">
{isEpc && band ? <BandChip band={band} size={28} /> : <GoalIcon size={28} />}
{isEpc ? `Bring every property to EPC ${band}` : `${GOAL_SHORT_LABELS[goal]} — as far as possible`}
</span>,
],
["Housing type", housing],
[
"Budget / property",
budgetNumber ? (
formatMoney(budgetNumber)
) : (
<span key="b" className="text-gray-400">No cap maximum interventions</span>
),
],
[
"Exclusions",
excluded.size === 0 ? (
<span key="e" className="text-gray-400">None every measure is on the table</span>
) : (
<span key="e" className="flex flex-wrap gap-1.5">
{[...excluded].sort().map((k) => (
<span
key={k}
className="rounded-full border border-red-100 bg-white px-3 py-0.5 text-[13px] text-red-800 line-through"
>
{measuresDisplayLabels[k as MeasureKey] ?? k}
</span>
))}
</span>
),
],
].map(([label, value]) => (
<div
key={label as string}
className="grid grid-cols-[190px_1fr] items-start gap-4 border-b border-gray-100 px-4.5 py-3.5 last:border-b-0 px-5"
>
<div className="pt-0.5 font-manrope text-[11px] font-extrabold uppercase tracking-[.1em] text-gray-400">
{label}
</div>
<div className="text-sm text-brandblue">{value}</div>
</div>
))}
</div>
{errors.excl && <div className="mt-3 text-[13px] text-red-800">{errors.excl}</div>}
</div>
)}
<div className="relative mt-7 flex items-center justify-between border-t border-gray-100 pt-5">
<button
className={`px-2 py-2 text-sm font-semibold text-brandmidblue ${step === 1 ? "invisible" : ""}`}
onClick={() => setStep(step - 1)}
>
Back
</button>
{step < 3 ? (
<button className={btnPrimary} style={gradPrimary} onClick={next}>
Continue
</button>
) : (
<button
className={btnPrimary}
style={gradPrimary}
disabled={save.isLoading}
onClick={() => save.mutate()}
>
{save.isLoading ? "Saving…" : "Save scenario"}
</button>
)}
</div>
</div>
</div>
</div>
);
}

View file

@ -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 (
<NewScenarioJourney
portfolioId={slug}
existing={existing}
defaultHousing={existing[0]?.housingType ?? "Social"}
template={template}
/>
);
}

View file

@ -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 (
<div className="max-w-8xl mx-auto px-6 pb-16 pt-6">
<div className="mb-2 flex items-end justify-between gap-4">
<div className="min-w-0">
<span className="font-manrope text-[11px] font-extrabold uppercase tracking-[.14em] text-brandmidblue">
Scenarios
</span>
<h2 className="mt-0.5 font-manrope text-2xl font-extrabold tracking-tight text-brandblue">
Retrofit scenarios
</h2>
<div
className="mt-3 h-[3px] max-w-[340px] rounded-full"
style={{
background:
"linear-gradient(to right,#14163d 0%,#3943b7 34%,rgba(57,67,183,0) 90%)",
}}
/>
</div>
<Link
href={`/portfolio/${slug}/scenarios/new`}
className="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)]"
style={{ background: "linear-gradient(135deg,#3943b7,#2d348f)" }}
>
+ New scenario
</Link>
</div>
<p className="mb-6 max-w-[62ch] text-sm text-gray-600">
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.
</p>
{scenarios.length === 0 ? (
<div className="mx-auto mt-9 max-w-[560px] rounded-2xl border-[1.5px] border-dashed border-gray-200 bg-white px-6 py-14 text-center">
<div className="mb-5 inline-flex gap-1">
{["G", "F", "E", "D", "C", "B", "A"].map((b) => (
<span
key={b}
className="inline-block h-[7px] w-[26px] rounded-full"
style={{ background: EPC_SOFT[b] }}
/>
))}
</div>
<h3 className="mb-2 font-manrope text-lg font-extrabold text-brandblue">
No scenarios yet
</h3>
<p className="mx-auto mb-5 max-w-[44ch] text-sm text-gray-600">
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.
</p>
<Link
href={`/portfolio/${slug}/scenarios/new`}
className="inline-block rounded-xl px-4 py-2.5 text-sm font-semibold text-white"
style={{ background: "linear-gradient(135deg,#3943b7,#2d348f)" }}
>
Create your first scenario
</Link>
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(330px,1fr))] gap-4">
{scenarios.map((s) => {
const isEpc = s.goal === PORTFOLIO_GOALS.EPC && s.goalValue;
return (
<Link
key={String(s.id)}
href={`/portfolio/${slug}/scenarios/${s.id}`}
className="relative flex flex-col gap-3 overflow-hidden rounded-2xl border border-gray-100 bg-white p-5 pb-4 shadow-sm transition hover:-translate-y-0.5 hover:shadow-[0_10px_28px_rgba(20,22,61,.10)]"
>
<span
aria-hidden
className="pointer-events-none absolute -right-14 -top-14 h-[170px] w-[170px] rounded-full opacity-[.14] blur-[46px]"
style={{ background: isEpc ? EPC_SOFT[s.goalValue!] : "#3943b7" }}
/>
<div className="relative flex items-center gap-3">
{isEpc ? <BandChip band={s.goalValue!} /> : <GoalIcon />}
<div className="min-w-0">
<h3 className="truncate font-manrope text-base font-bold text-brandblue">
{s.name ?? `Scenario ${s.id}`}
</h3>
<div className="text-[13px] text-gray-600">
{isEpc
? `Target EPC ${s.goalValue}`
: GOAL_SHORT_LABELS[s.goal] ?? s.goal}{" "}
· {s.housingType}
</div>
</div>
</div>
<div className="h-1 overflow-hidden rounded-full bg-gray-100">
<i
className="block h-full rounded-full"
style={
isEpc
? { width: meterWidth(s.goalValue!), background: meterGradient(s.goalValue!) }
: { width: "100%", background: "linear-gradient(to right,#14163d,#3943b7)" }
}
/>
</div>
<div className="relative rounded-xl border border-gray-100 bg-gray-50 px-3 py-2 text-[13px] text-gray-600">
{constraintLine(s.exclusions)}
{s.budget ? (
<>
{" "}
· <b className="font-semibold text-brandblue">{formatMoney(s.budget)}</b>
/property cap
</>
) : null}
</div>
<div className="relative flex items-center justify-between text-xs tabular-nums text-gray-400">
<StatusPill status={s.status} />
<span>
{s.status === "modelled"
? `${s.plansCount.toLocaleString("en-GB")} properties · `
: ""}
Created {formatDate(s.createdAt)}
</span>
</div>
</Link>
);
})}
</div>
)}
</div>
);
}

View file

@ -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<string, string> = {
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<string, string> = {
[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<string, string> = {
[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 (
<span
className="inline-flex flex-none items-center justify-center rounded-lg font-manrope font-extrabold"
style={{
width: size,
height: size,
fontSize: size / 2,
background: EPC_SOFT[band],
color: bandInk(band),
boxShadow: "inset 0 -10px 14px rgba(0,0,0,.12)",
}}
>
{band}
</span>
);
}
export function GoalIcon({ size = 34 }: { size?: number }) {
return (
<span
className="inline-flex flex-none items-center justify-center rounded-lg text-white font-bold"
style={{
width: size,
height: size,
background: "linear-gradient(135deg,#14163d,#3943b7)",
}}
>
</span>
);
}
export function StatusPill({
status,
className = "",
}: {
status: ScenarioStatus;
className?: string;
}) {
const modelled = status === "modelled";
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold ${
modelled ? "bg-brandlightblue text-brandmidblue" : "bg-amber-50 text-amber-800"
} ${className}`}
>
<span
className={`h-1.5 w-1.5 flex-none rounded-full ${modelled ? "bg-brandmidblue" : "bg-brandgold"}`}
/>
{modelled ? "Modelled" : "Awaiting modelling"}
</span>
);
}
export function constraintLine(exclusions: string[]): React.ReactNode {
const summary = constraintSummary(exclusions);
if (summary.kind === "all")
return (
<>
<b className="font-semibold text-brandblue">All measures</b> on the table
</>
);
if (summary.kind === "only")
return (
<>
Only: <b className="font-semibold text-brandblue">{summary.measures.join(", ")}</b>
</>
);
return (
<>
Excludes{" "}
<b className="font-semibold text-brandblue">
{summary.measures.length} measure{summary.measures.length > 1 ? "s" : ""}
</b>
</>
);
}
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");

View file

@ -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"]);
});
});

215
src/lib/scenarios/model.ts Normal file
View file

@ -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<Record<keyof ScenarioConfigInput, string>> };
export function validateScenarioConfig(
input: ScenarioConfigInput,
): ValidationResult {
const errors: Partial<Record<keyof ScenarioConfigInput, string>> = {};
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 (AG) 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<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. */
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
);
}

View file

@ -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<ScenarioListItem[]> {
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<ScenarioListItem | null> {
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<number> {
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<bigint> {
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<boolean> {
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<DeleteScenarioResult> {
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" };
}