mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-12 13:28:55 +00:00
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:
commit
c861d4a0b0
14 changed files with 2156 additions and 0 deletions
22
CONTEXT.md
22
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:
|
||||
|
|
|
|||
76
docs/adr/0003-app-authored-scenarios.md
Normal file
76
docs/adr/0003-app-authored-scenarios.md
Normal 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.
|
||||
|
|
@ -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 });
|
||||
}
|
||||
41
src/app/api/portfolio/[portfolioId]/scenarios/route.ts
Normal file
41
src/app/api/portfolio/[portfolioId]/scenarios/route.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'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't be changed once saved (you
|
||||
can rename later). A different question is a new scenario.
|
||||
</p>
|
||||
{(() => {
|
||||
const allowedDisruptive = (
|
||||
Object.keys(DISRUPTIVE_MEASURES) as MeasureKey[]
|
||||
).filter((k) => !excluded.has(k));
|
||||
return allowedDisruptive.length > 0 ? (
|
||||
<div className="mb-5 max-w-[62ch] rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-[13px] text-amber-900">
|
||||
This scenario still allows{" "}
|
||||
<b>
|
||||
{allowedDisruptive
|
||||
.map((k) => measuresDisplayLabels[k])
|
||||
.join(", ")}
|
||||
</b>{" "}
|
||||
— disruptive measure{allowedDisruptive.length > 1 ? "s" : ""}{" "}
|
||||
landlords often exclude. Go back a step if you'd rather
|
||||
rule {allowedDisruptive.length > 1 ? "them" : "it"} out.
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
{duplicateView && (
|
||||
<div
|
||||
role="alert"
|
||||
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>
|
||||
);
|
||||
}
|
||||
37
src/app/portfolio/[slug]/(portfolio)/scenarios/new/page.tsx
Normal file
37
src/app/portfolio/[slug]/(portfolio)/scenarios/new/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
149
src/app/portfolio/[slug]/(portfolio)/scenarios/page.tsx
Normal file
149
src/app/portfolio/[slug]/(portfolio)/scenarios/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
src/app/portfolio/[slug]/(portfolio)/scenarios/ui.tsx
Normal file
137
src/app/portfolio/[slug]/(portfolio)/scenarios/ui.tsx
Normal 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");
|
||||
282
src/lib/scenarios/model.test.ts
Normal file
282
src/lib/scenarios/model.test.ts
Normal 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
215
src/lib/scenarios/model.ts
Normal 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 (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<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
|
||||
);
|
||||
}
|
||||
156
src/lib/scenarios/queries.ts
Normal file
156
src/lib/scenarios/queries.ts
Normal 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" };
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue