Merge pull request #360 from Hestia-Homes/feature/bulk-trigger-modelling

feat: bulk-trigger modelling runs with property filters
This commit is contained in:
KhalimCK 2026-07-07 18:55:20 +01:00 committed by GitHub
commit 4a437ec526
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1747 additions and 9 deletions

View file

@ -104,6 +104,14 @@ _Avoid_: invasive (unused here), wet trades (a narrower, overlapping set: plaste
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)
**Modelling run**:
One user-initiated act of triggering modelling: a set of Scenarios crossed with one filter-defined property selection within a Portfolio. A durable record of *what was asked* — the Run filters, the Scenarios, who triggered it, when, and the matched-property count shown at preview — so Plans can be traced back to the selection that produced them, alongside how the work is going. A Scenario is the *question*; a Modelling run *asks* it (possibly again, possibly for a different property selection). Re-running an already-modelled property appends a newer Plan; latest wins on read.
_Avoid_: job, batch, trigger request, modelling task (the pipeline's execution records)
**Run filter**:
The property-selecting constraints of a Modelling run: postcode, property type, and built form (each multi-select; tags later). An absent filter means *unconstrained* — "all postcodes" is the absence of a postcode filter, never an enumeration. Type and built form are the canonical enum vocabularies plus **Unknown**, which selects properties with no resolvable value at all. Resolution follows the Landlord-override rule: an override fact beats an EPC-derived value; the modelling backend is the single authority for resolving filters to properties (the app never re-implements the rule — matched counts come from the backend).
_Avoid_: property query, segment, search (the postcode-search journey is unrelated)
## Lifecycle
A **BulkUpload** moves through these statuses:

View file

@ -0,0 +1,118 @@
# 8. Modelling runs pass filters to the distributor; the backend owns resolution
Date: 2026-07-06
## Status
Accepted
Numbering note: 00040006 are referenced by CONTEXT.md but were lost to the old
`docs/adr/**` gitignore rule; 0008 avoids all collisions (0003 = app-authored
scenarios, 0007 = postcode search).
## Context
Users can now add properties (postcode search, bulk upload) and author
Scenarios (ADR-0003) before any modelling exists. The missing step is
triggering modelling for *chosen* properties: many Scenarios × a subset of the
portfolio, potentially 50,000 properties. The entry point on the Model side is
a **distributor** lambda — it fans work out to modelling workers, it does not
model synchronously. ADR-0003 recorded a known follow-up: the in-flight
"modelling running" state is not representable by derivation and needs an
explicit marker when trigger wiring lands. This is that wiring.
## Decision
- **A Modelling run is recorded as an app-created task** — the BulkUpload
trigger convention (`triggerAddressMatching`): Next.js inserts the `tasks`
row (service `modelling_run`, `source_id` = portfolio, status `waiting`)
whose **`inputs` column** (added for this — mirrors `sub_task.inputs`)
carries `{portfolio_id, scenario_ids, filters, previewed_property_count,
triggered_by}` as JSON, then passes `task_id` to the distributor, which
attaches its execution sub-tasks to the same task. **One new column, no new
table.** Run history and the per-scenario in-flight badge read the
portfolio's own `modelling_run` tasks (portfolio-scoped, a handful of rows)
and parse the app-authored inputs JSON; execution progress falls out of the
existing subtask-count summary pattern. Sub-task granularity is the
distributor's **batches**, so progress is shown as a percentage — batch
counts are internally consistent but never comparable to property or plan
numbers.
- **The distributor (`POST /v1/modelling/trigger-run`) receives
`{task_id, portfolio_id, scenario_ids, filters}`** — filters, never
property-id lists. The payload stays tiny at any portfolio size; the task's
`inputs` is the durable copy of the same request (plus provenance the
distributor doesn't need).
- **Dispatch failure marks the task `failed`** (an improvement on the
bulk-upload flow, which leaves failed triggers at `waiting`), so history
never shows a ghost run as pending.
- **The modelling backend is the single authority for resolving filters to
properties.** Resolution precedence: landlord-override fact → EPC-derived
value (new-approach EPC graph, lodged over predicted, or legacy property-row
columns) → **Unknown**. The app never re-implements this rule.
- **Preview counts are computed in-app** (amended 2026-07-06; originally a
backend dry-run endpoint): one grouped SQL statement resolves the filters —
override snapshot → EPC-derived (`propertyTypeSql`/`builtFormTypeSql` in
epcSources: new-approach graph, lodged over predicted, RdSAP codes mapped;
legacy property-row columns) → Unknown — and intersects the matched set
with `plan` for per-scenario already-modelled counts ("120 of 214 will be
re-modelled"). Both the app and the distributor read this database and
implement this documented rule; divergence is a bug in whichever side moved
without the other. Plan arithmetic is exact: one plan per
(scenario, property) per run.
- **Filter options are resolution-free in the app**: the postcode multi-select
is one `SELECT DISTINCT postcode` per portfolio; type/built-form options are
the static canonical enums plus Unknown. Filters are bounded (cap the
postcode multi-select) as a UX guard; "all" is an absent filter, never an
enumeration.
- **Run status is the task's status** — no correlation machinery needed: the
run and the execution share one record by construction. Scenario badges
gain a third derived state — "Modelling in progress" — and a per-portfolio
run history lists each run's filters, counts, initiator and derived status
with batch-based percentage progress.
- **Concurrent runs warn, never block**: triggering a scenario with a run in
flight shows who started it and when, but proceeds. Plans append and
latest-per-property wins on read, so overlap is safe; a hard lock could leak
on a dead run.
## Alternatives considered
- **A dedicated `modelling_run` table (+ scenarios join table)**: relational
scenario linkage and typed columns for who/filters/counts, but a migration,
a second record system beside tasks, and correlation machinery (either the
backend stamping run ids into tasks, or the app storing returned task ids).
The task system already records app-initiated pipeline work with inputs,
progress and status — and the badge/history queries are portfolio-scoped
over a handful of rows, so the JSON parse costs nothing that matters.
- **Pass resolved property ids to the distributor**: makes preview-vs-run
drift impossible by construction, but ~50k ids is a payload the async entry
point can't take, and it moves the resolution rule into the app — the
backend already owns it for modelling itself.
- **App-side count queries mirroring the resolution rule**: no backend
dependency for the preview, but the overrides-else-EPC rule would live in
two codebases and the shown count could disagree with what runs.
- **Status columns on the run row (two-writer, as bulk uploads)**: simple
reads, but a second source of truth that can silently never arrive; task
correlation reuses records the pipeline writes anyway.
- **Blocking concurrent runs**: prevents accidental double-spend but a stalled
pipeline would lock the scenario until someone clears task rows by hand.
## Consequences
- One backend ask must land with the distributor: accept an app-created
`task_id` and attach execution subtasks to it (rather than creating its own
task, as the current `plan_engine` entrypoint does). Its filter resolution
must match the rule above — the app's preview is the reference
implementation.
- The preview's already-modelled callout and matched counts are only as fresh
as trigger time; a property added between preview and trigger is picked up
by the run (filters re-resolve) — the run row records the previewed count so
discrepancies are visible after the fact.
- Reporting/scenario UIs gain a third derived scenario state; anything
assuming "has a run row ⇒ has plans" is wrong (a dead run may produce none).
- The tagging system (future) extends Run filters without touching the
contract shape: tags become one more optional filter key.
- Known follow-up: **email notification on run completion**. The dispatch
confirmation sets a time expectation only; "we'll email you when results
are ready" was deliberately kept out of the UI copy until a completion
notification exists. The run_id → task correlation gives the completion
signal to hang it on.

View file

@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { z } from "zod";
import { normaliseRunFilters } from "@/lib/modellingRuns/model";
import { previewModellingRun } from "@/lib/modellingRuns/server";
const bodySchema = z.object({
scenarioIds: z.array(z.string().regex(/^\d+$/)).min(1).max(50),
filters: z
.object({
postcodes: z.array(z.string()).optional(),
propertyTypes: z.array(z.string()).optional(),
builtForms: z.array(z.string()).optional(),
})
.default({}),
});
// POST — dry-run counts from the backend (same resolution code path as the
// real run; the preview is a promise, not an estimate — ADR-0008).
export async function POST(
request: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const { portfolioId } = await props.params;
if (!/^\d+$/.test(portfolioId)) {
return NextResponse.json({ error: "Invalid portfolio id" }, { status: 400 });
}
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await request.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
const normalised = normaliseRunFilters(body.filters);
if (!normalised.ok) {
return NextResponse.json({ error: normalised.error }, { status: 400 });
}
try {
const result = await previewModellingRun({
portfolioId,
scenarioIds: [...new Set(body.scenarioIds)],
filters: normalised.filters,
});
return NextResponse.json(result);
} catch (err) {
console.error("POST /modelling-runs/preview error:", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { z } from "zod";
import { normaliseRunFilters } from "@/lib/modellingRuns/model";
import {
listModellingRuns,
triggerModellingRun,
} from "@/lib/modellingRuns/server";
const filtersSchema = z.object({
postcodes: z.array(z.string()).optional(),
propertyTypes: z.array(z.string()).optional(),
builtForms: z.array(z.string()).optional(),
});
const bodySchema = z.object({
scenarioIds: z.array(z.string().regex(/^\d+$/)).min(1).max(50),
filters: filtersSchema.default({}),
previewedPropertyCount: z.number().int().nonnegative(),
});
function sessionToken(request: NextRequest): string | undefined {
return (
request.cookies.get("__Secure-next-auth.session-token")?.value ??
request.cookies.get("next-auth.session-token")?.value
);
}
// POST — record + dispatch a Modelling run (ADR-0008)
export async function POST(
request: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const { portfolioId } = await props.params;
if (!/^\d+$/.test(portfolioId)) {
return NextResponse.json({ error: "Invalid portfolio id" }, { status: 400 });
}
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await request.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
const normalised = normaliseRunFilters(body.filters);
if (!normalised.ok) {
return NextResponse.json({ error: normalised.error }, { status: 400 });
}
try {
const outcome = await triggerModellingRun({
portfolioId,
scenarioIds: [...new Set(body.scenarioIds)],
filters: normalised.filters,
previewedPropertyCount: body.previewedPropertyCount,
triggeredBy: session.user.email,
sessionToken: sessionToken(request),
});
switch (outcome.kind) {
case "ok":
return NextResponse.json({ taskId: outcome.taskId }, { status: 202 });
case "unknown_scenarios":
return NextResponse.json(
{ error: `Unknown scenarios for this portfolio: ${outcome.missing.join(", ")}` },
{ status: 400 },
);
case "dispatch_failed":
return NextResponse.json(
{ error: "Couldn't start modelling — please try again" },
{ status: outcome.status },
);
}
} catch (err) {
console.error("POST /modelling-runs error:", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
// GET — run history for the portfolio
export async function GET(
_request: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const { portfolioId } = await props.params;
if (!/^\d+$/.test(portfolioId)) {
return NextResponse.json({ error: "Invalid portfolio id" }, { status: 400 });
}
try {
return NextResponse.json({ runs: await listModellingRuns(portfolioId) });
} catch (err) {
console.error("GET /modelling-runs error:", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { db } from "@/app/db/db";
import { property } from "@/app/db/schema/property";
import { and, asc, count, eq, isNotNull } from "drizzle-orm";
// GET — distinct postcodes (with counts) for the Run-filter options. One
// grouped, portfolio-scoped query (ix_property_portfolio).
export async function GET(
_request: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const { portfolioId } = await props.params;
if (!/^\d+$/.test(portfolioId)) {
return NextResponse.json({ error: "Invalid portfolio id" }, { status: 400 });
}
try {
const rows = await db
.select({ postcode: property.postcode, n: count() })
.from(property)
.where(
and(
eq(property.portfolioId, BigInt(portfolioId)),
eq(property.markedForDeletion, false),
isNotNull(property.postcode),
),
)
.groupBy(property.postcode)
.orderBy(asc(property.postcode));
return NextResponse.json({
postcodes: rows.map((r) => ({ postcode: r.postcode, count: r.n })),
});
} catch (err) {
console.error("GET /postcodes error:", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -90,7 +90,12 @@ export function NewScenarioJourney({
});
if (!res.ok) throw new Error((await res.json()).error ?? "Save failed");
},
onSuccess: () => router.push(`/portfolio/${portfolioId}/scenarios`),
onSuccess: () => {
router.push(`/portfolio/${portfolioId}/scenarios`);
// The scenarios list is server-rendered; without a refresh the client
// router cache serves the pre-save page ("No scenarios yet").
router.refresh();
},
onError: (e: Error) => setErrors({ excl: e.message }),
});

View file

@ -40,13 +40,23 @@ export default async function ScenariosPage(props: {
}}
/>
</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 className="flex flex-none items-center gap-2.5">
{scenarios.length > 0 && (
<Link
href={`/portfolio/${slug}/modelling/run`}
className="rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm font-semibold text-brandblue transition hover:bg-gray-50"
>
Run modelling
</Link>
)}
<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>
</div>
<p className="mb-6 max-w-[62ch] text-sm text-gray-600">
Each scenario is a question you ask the modelling engine. Its

View file

@ -14,6 +14,7 @@ import {
PlusIcon,
MagnifyingGlassIcon,
DocumentArrowUpIcon,
PlayIcon,
} from "@heroicons/react/24/outline";
import { useRouter } from "next/navigation";
import { HomeIcon } from "@heroicons/react/24/outline";
@ -672,6 +673,15 @@ export default function PropertyTable({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Run modelling */}
<button
onClick={() => router.push(`/portfolio/${portfolioId}/modelling/run`)}
className="flex items-center gap-1.5 h-8 px-3 rounded-lg bg-primary text-white text-xs font-semibold hover:opacity-90 transition"
>
<PlayIcon className="h-3.5 w-3.5" />
Run modelling
</button>
</div>
</div>

View file

@ -0,0 +1,647 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeftIcon, CheckCircleIcon } from "@heroicons/react/24/solid";
import {
RUN_FILTER_BUILT_FORMS,
RUN_FILTER_PROPERTY_TYPES,
RunFilters,
RunStatus,
estimateRunMinutes,
historyRefetchInterval,
isLargeRun,
MAX_FILTER_POSTCODES,
selectionSummary,
} from "@/lib/modellingRuns/model";
export interface ScenarioOption {
id: string;
name: string;
goal: string;
goalValue: string | null;
housingType: string;
budget: number | null;
status: "modelled" | "awaiting" | "running";
modelledCount: number;
}
interface PreviewResponse {
matchedProperties: number;
perScenario: { scenarioId: string; alreadyModelled: number }[];
}
interface RunHistoryRow {
taskId: string;
triggeredAt: string;
triggeredBy: string;
scenarios: { id: string; name: string | null }[];
selection: string;
previewedPropertyCount: number;
planCount: number;
status: RunStatus;
}
const STATUS_LABELS: Record<RunStatus["state"], string> = {
dispatched: "Starting up",
in_progress: "In progress",
complete: "Complete",
failed: "Failed",
};
function RunBadge({ status }: { status: ScenarioOption["status"] }) {
if (status === "running") {
return (
<span className="ml-auto inline-flex flex-none items-center gap-1.5 rounded-full bg-brandblue px-2.5 py-0.5 text-xs font-semibold text-white">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-300" />
Modelling in progress
</span>
);
}
const modelled = status === "modelled";
return (
<span
className={`ml-auto inline-flex flex-none 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"
}`}
>
<span className={`h-1.5 w-1.5 flex-none rounded-full ${modelled ? "bg-brandmidblue" : "bg-brandgold"}`} />
{modelled ? "Modelled" : "Awaiting modelling"}
</span>
);
}
function FilterColumn({
title,
options,
selected,
onToggle,
}: {
title: string;
options: { value: string; count?: number }[];
selected: Set<string>;
onToggle: (value: string) => void;
}) {
return (
<div>
<div className="mb-2.5 flex items-baseline gap-2">
<span className="font-manrope text-[11px] font-extrabold uppercase tracking-[.1em] text-gray-600">
{title}
</span>
<span className="text-[11.5px] text-gray-400">
· {selected.size ? `${selected.size} selected` : "all"}
</span>
</div>
<div className="flex max-h-56 flex-col gap-1 overflow-y-auto pr-1.5">
{options.map((o) => {
const on = selected.has(o.value);
return (
<label
key={o.value}
className={`flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-[13.5px] transition ${
on ? "bg-brandlightblue" : "hover:bg-gray-50"
} ${o.value === "Unknown" ? "mt-1 border-t border-dashed border-gray-200 pt-2.5" : ""}`}
>
<input
type="checkbox"
checked={on}
onChange={() => onToggle(o.value)}
className="h-[15px] w-[15px] accent-brandmidblue"
/>
{o.value}
{o.count != null && (
<span className="ml-auto text-[11.5px] tabular-nums text-gray-400">{o.count}</span>
)}
</label>
);
})}
</div>
</div>
);
}
export default function RunModellingClient({
portfolioId,
portfolioName,
scenarios,
}: {
portfolioId: string;
portfolioName: string;
scenarios: ScenarioOption[];
}) {
const queryClient = useQueryClient();
const [selScenarios, setSelScenarios] = useState<Set<string>>(new Set());
const [selPcs, setSelPcs] = useState<Set<string>>(new Set());
const [selTypes, setSelTypes] = useState<Set<string>>(new Set());
const [selForms, setSelForms] = useState<Set<string>>(new Set());
const [confirmOpen, setConfirmOpen] = useState(false);
const [done, setDone] = useState<{ properties: number; scenarios: number } | null>(null);
const filters: RunFilters = {
...(selPcs.size ? { postcodes: [...selPcs].sort() } : {}),
...(selTypes.size ? { propertyTypes: [...selTypes] } : {}),
...(selForms.size ? { builtForms: [...selForms] } : {}),
};
const scenarioIds = [...selScenarios].sort();
const postcodesQuery = useQuery<{ postcode: string; count: number }[], Error>({
queryKey: ["portfolio-postcodes", portfolioId],
staleTime: 5 * 60 * 1000,
queryFn: async () => {
const res = await fetch(`/api/portfolio/${portfolioId}/postcodes`);
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? "Couldn't load postcodes");
return body.postcodes;
},
});
const preview = useQuery<PreviewResponse, Error>({
queryKey: ["run-preview", portfolioId, scenarioIds, filters],
enabled: scenarioIds.length > 0 && !done,
keepPreviousData: true,
retry: false,
queryFn: async () => {
const res = await fetch(`/api/portfolio/${portfolioId}/modelling-runs/preview`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scenarioIds, filters }),
});
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? "Couldn't count the selection");
return body;
},
});
const history = useQuery<RunHistoryRow[], Error>({
queryKey: ["modelling-runs", portfolioId],
// v4 signature: (data, query). Fast while a run is moving, slow at rest.
refetchInterval: (data) => historyRefetchInterval(data),
queryFn: async () => {
const res = await fetch(`/api/portfolio/${portfolioId}/modelling-runs`);
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? "Couldn't load run history");
return body.runs;
},
});
const run = useMutation<unknown, Error, void>({
mutationFn: async () => {
const res = await fetch(`/api/portfolio/${portfolioId}/modelling-runs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scenarioIds,
filters,
previewedPropertyCount: preview.data?.matchedProperties ?? 0,
}),
});
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? "Couldn't start modelling — please try again");
return body;
},
onSuccess: () => {
setDone({ properties: preview.data?.matchedProperties ?? 0, scenarios: scenarioIds.length });
setSelScenarios(new Set());
queryClient.invalidateQueries(["modelling-runs", portfolioId]);
},
});
const toggle = (set: Set<string>, setter: (s: Set<string>) => void, value: string) => {
const next = new Set(set);
if (next.has(value)) next.delete(value);
else next.add(value);
setter(next);
};
const matched = preview.data?.matchedProperties;
const planTotal = matched != null ? matched * scenarioIds.length : null;
const remodel = (preview.data?.perScenario ?? []).filter((p) => p.alreadyModelled > 0);
const scenarioById = new Map(scenarios.map((s) => [s.id, s]));
const startRun = () => {
if (matched != null && isLargeRun(matched)) setConfirmOpen(true);
else run.mutate();
};
const filterCount = selPcs.size + selTypes.size + selForms.size;
const clearFilters = () => {
setSelPcs(new Set());
setSelTypes(new Set());
setSelForms(new Set());
};
return (
<div className="mx-auto max-w-4xl px-6 pb-48 pt-10">
<Link
href={`/portfolio/${portfolioId}`}
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-semibold text-gray-600 transition hover:text-brandblue focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brandmidblue"
>
<ArrowLeftIcon className="h-4 w-4" aria-hidden />
Back to {portfolioName}
</Link>
<h1 className="font-manrope text-3xl font-extrabold tracking-tight text-brandblue">
Run your scenarios.
</h1>
<p className="mt-2 max-w-xl text-sm leading-relaxed text-gray-600">
Choose the scenarios you want answered, then the homes to include. The counts you see are
checked against your portfolio as you go what you see is exactly what gets modelled.
</p>
{done ? (
<div
role="status"
className="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50/70 px-5 py-4"
>
<div className="flex flex-wrap items-start gap-3">
<CheckCircleIcon className="mt-0.5 h-6 w-6 flex-none text-emerald-600" aria-hidden />
<div className="min-w-0 flex-1">
<h2 className="font-manrope text-base font-extrabold text-brandblue">
Modelling started you don&apos;t need to stay on this page
</h2>
<p className="mt-1 max-w-[60ch] text-[13px] leading-relaxed text-gray-600">
We&apos;re modelling <b>{done.properties.toLocaleString()}</b>{" "}
{done.properties === 1 ? "home" : "homes"} against <b>{done.scenarios}</b>{" "}
scenario{done.scenarios === 1 ? "" : "s"} {" "}
<b>{(done.properties * done.scenarios).toLocaleString()}</b> plan
{done.properties * done.scenarios === 1 ? "" : "s"}. Results for a run this size
are typically ready within{" "}
<b>~{estimateRunMinutes(done.properties * done.scenarios)} minutes</b> progress
updates under Recent runs below as it happens.
</p>
</div>
<div className="flex flex-none items-center gap-2.5 self-center">
<button
onClick={() => setDone(null)}
className="rounded-xl border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-brandblue transition hover:bg-gray-50"
>
Set up another run
</button>
<Link
href={`/portfolio/${portfolioId}`}
className="rounded-xl px-4 py-2 text-sm font-semibold text-white"
style={{ background: "linear-gradient(135deg,#3943b7,#2d348f)" }}
>
Back to portfolio
</Link>
</div>
</div>
</div>
) : (
<>
{/* Step 1 — scenarios */}
<div className="mt-7 rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">
<span className="font-manrope text-[11px] font-extrabold uppercase tracking-[.14em] text-brandmidblue">
Step 1 · Scenarios
</span>
<h2 className="mt-0.5 font-manrope text-lg font-extrabold text-brandblue">
Which questions are we asking?
</h2>
<p className="text-[13px] text-gray-500">
Every selected scenario runs against the same property selection.
</p>
{scenarios.length === 0 ? (
<div className="mt-4 rounded-xl border-[1.5px] border-dashed border-gray-200 px-4 py-8 text-center text-sm text-gray-500">
No scenarios yet {" "}
<Link
href={`/portfolio/${portfolioId}/scenarios/new`}
className="font-semibold text-brandmidblue hover:underline"
>
create your first scenario
</Link>{" "}
and come back to run it.
</div>
) : (
scenarios.map((s) => {
const on = selScenarios.has(s.id);
return (
<div key={s.id}>
<label
className={`mt-2.5 flex w-full cursor-pointer items-center gap-3.5 rounded-xl border px-4 py-3 text-left transition ${
on
? "border-brandmidblue bg-white shadow-sm"
: "border-transparent bg-gray-50 hover:bg-gray-100"
}`}
>
<input
type="checkbox"
checked={on}
onChange={() => toggle(selScenarios, setSelScenarios, s.id)}
className="h-4 w-4 shrink-0 accent-brandmidblue"
/>
<span className="min-w-0">
<span className="block truncate font-manrope text-sm font-bold text-brandblue">
{s.name}
</span>
<span className="block text-xs text-gray-500">
{s.goal === "Increasing EPC" && s.goalValue
? `Target EPC ${s.goalValue}`
: s.goal}{" "}
· {s.housingType}
{s.budget ? ` · £${s.budget.toLocaleString()} per home` : ""}
</span>
</span>
<RunBadge status={s.status} />
</label>
{on && s.status === "running" && (
<div className="ml-11 mt-1.5 rounded-lg border border-amber-200 bg-amber-50 px-3 py-1.5 text-xs text-amber-800">
This scenario is already being modelled. You can still include it the
newest results are the ones you&apos;ll see.
</div>
)}
</div>
);
})
)}
</div>
{/* Step 2 — properties */}
<div className="mt-4 rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="flex items-start justify-between gap-3">
<span className="font-manrope text-[11px] font-extrabold uppercase tracking-[.14em] text-brandmidblue">
Step 2 · Properties
</span>
{filterCount > 0 && (
<button
onClick={clearFilters}
className="text-[13px] font-semibold text-brandmidblue transition hover:text-brandblue hover:underline"
>
Remove all filters ({filterCount})
</button>
)}
</div>
<h2 className="mt-0.5 font-manrope text-lg font-extrabold text-brandblue">
Which homes?
</h2>
<p className="text-[13px] text-gray-500">
Leave a filter untouched to include everything. What you&apos;ve told us about a home
takes priority over its EPC; &ldquo;Unknown&rdquo; picks out homes where we have
neither.
</p>
<div className="mt-4 grid grid-cols-1 gap-6 sm:grid-cols-3">
{postcodesQuery.isError ? (
<div>
<span className="font-manrope text-[11px] font-extrabold uppercase tracking-[.1em] text-gray-600">
Postcode
</span>
<p className="mt-2.5 text-[13px] leading-relaxed text-red-700">
We couldn&apos;t load your portfolio&apos;s postcodes.{" "}
<button
onClick={() => postcodesQuery.refetch()}
className="font-semibold underline hover:text-red-800"
>
Try again
</button>
</p>
</div>
) : postcodesQuery.isLoading ? (
<div>
<span className="font-manrope text-[11px] font-extrabold uppercase tracking-[.1em] text-gray-600">
Postcode
</span>
<p className="mt-2.5 text-[13px] text-gray-500">Loading postcodes</p>
</div>
) : (
<FilterColumn
title="Postcode"
options={(postcodesQuery.data ?? []).map((p) => ({
value: p.postcode,
count: p.count,
}))}
selected={selPcs}
onToggle={(v) => toggle(selPcs, setSelPcs, v)}
/>
)}
<FilterColumn
title="Property type"
options={RUN_FILTER_PROPERTY_TYPES.map((value) => ({ value }))}
selected={selTypes}
onToggle={(v) => toggle(selTypes, setSelTypes, v)}
/>
<FilterColumn
title="Built form"
options={RUN_FILTER_BUILT_FORMS.map((value) => ({ value }))}
selected={selForms}
onToggle={(v) => toggle(selForms, setSelForms, v)}
/>
</div>
<p className="mt-3 text-[11.5px] leading-relaxed text-gray-400">
Filters are limited to {MAX_FILTER_POSTCODES} postcodes per run. Tags are coming
you&apos;ll be able to save a selection and reuse it.
</p>
</div>
</>
)}
{/* Recent runs always visible: after a run is triggered this is where
its live status lands, so the banner above can say "watch below" */}
<div className="mt-4 rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="flex items-baseline gap-3">
<h2 className="font-manrope text-lg font-extrabold text-brandblue">Recent runs</h2>
<span className="text-[13px] text-gray-500">
what was asked, by whom, and how it went
</span>
</div>
{history.isLoading ? (
<p className="mt-4 text-sm text-gray-400">Loading</p>
) : !history.data?.length ? (
<p className="mt-4 text-sm text-gray-400">
No runs yet your first appears here the moment you trigger it.
</p>
) : (
<div className="overflow-x-auto">
<table className="mt-3 w-full border-collapse text-[13px]">
<thead>
<tr>
{["When", "Scenarios", "Selection", "Properties", "Plans", "Status"].map(
(h) => (
<th
key={h}
className="border-b border-gray-100 px-3 py-2 text-left font-manrope text-[10.5px] font-extrabold uppercase tracking-[.09em] text-gray-400"
>
{h}
</th>
),
)}
</tr>
</thead>
<tbody>
{history.data.map((r) => (
<tr key={r.taskId}>
<td className="whitespace-nowrap border-b border-gray-100 px-3 py-2.5 align-top">
{new Date(r.triggeredAt).toLocaleString("en-GB", {
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
})}
<span className="block text-[11.5px] text-gray-400">{r.triggeredBy}</span>
</td>
<td className="border-b border-gray-100 px-3 py-2.5 align-top">
{r.scenarios.map((s) => s.name ?? s.id).join(" · ") || "—"}
</td>
<td className="max-w-[220px] border-b border-gray-100 px-3 py-2.5 align-top text-[12px] text-gray-500">
{r.selection}
</td>
<td className="border-b border-gray-100 px-3 py-2.5 align-top tabular-nums">
{r.previewedPropertyCount.toLocaleString()}
</td>
<td className="border-b border-gray-100 px-3 py-2.5 align-top tabular-nums">
{r.planCount.toLocaleString()}
</td>
<td className="border-b border-gray-100 px-3 py-2.5 align-top">
<span
className={`whitespace-nowrap rounded-full px-2.5 py-0.5 font-manrope text-[10px] font-extrabold uppercase tracking-[.06em] ${
r.status.state === "complete"
? "bg-emerald-50 text-emerald-700"
: r.status.state === "failed"
? "bg-red-50 text-red-700"
: "bg-brandblue text-white"
}`}
>
{STATUS_LABELS[r.status.state]}
</span>
{/* Work is chunked into batches, so show a percentage
the counts never line up with property/plan numbers */}
{r.status.state === "in_progress" && (
<>
<span className="mt-1 block text-[11px] tabular-nums text-gray-400">
{Math.round((100 * r.status.ready) / Math.max(1, r.status.total))}% done
</span>
<span className="mt-0.5 block h-1 w-20 overflow-hidden rounded-full bg-gray-100">
<span
className="block h-full rounded-full bg-brandmidblue"
style={{ width: `${Math.round((100 * r.status.ready) / Math.max(1, r.status.total))}%` }}
/>
</span>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Summary tray */}
{!done && scenarioIds.length > 0 && (
<div className="fixed bottom-6 left-1/2 z-40 w-[min(56rem,calc(100vw-2rem))] -translate-x-1/2 rounded-2xl border border-gray-200 bg-white/95 px-5 py-4 shadow-2xl backdrop-blur">
<div className="flex flex-wrap items-center gap-3">
<span
className={`font-manrope text-xl font-extrabold tabular-nums text-brandblue ${preview.isFetching ? "opacity-40" : ""}`}
>
{matched != null ? matched.toLocaleString() : "—"}
<span className="ml-1 text-xs font-semibold text-gray-400">properties</span>
</span>
<span className="text-gray-300">×</span>
<span className="font-manrope text-xl font-extrabold tabular-nums text-brandblue">
{scenarioIds.length}
<span className="ml-1 text-xs font-semibold text-gray-400">
scenario{scenarioIds.length > 1 ? "s" : ""}
</span>
</span>
<span className="text-gray-300">=</span>
<span
className={`font-manrope text-xl font-extrabold tabular-nums text-brandblue ${preview.isFetching ? "opacity-40" : ""}`}
>
{planTotal != null ? planTotal.toLocaleString() : "—"}
<span className="ml-1 text-xs font-semibold text-gray-400">plans</span>
</span>
<span className="flex items-center gap-1.5 text-[11px] text-gray-400">
<span
className={`h-1.5 w-1.5 rounded-full ${preview.isFetching ? "animate-pulse bg-gray-400" : preview.isError ? "bg-red-500" : "bg-emerald-600"}`}
/>
{preview.isFetching
? "Counting…"
: preview.isError
? preview.error.message
: "Checked against your portfolio just now"}
</span>
<button
onClick={startRun}
disabled={preview.isFetching || preview.isError || !matched || run.isLoading}
className="ml-auto rounded-xl px-4 py-2 text-sm font-semibold text-white transition disabled:opacity-40"
style={{ background: "linear-gradient(135deg,#3943b7,#2d348f)" }}
>
{run.isLoading
? "Starting…"
: matched
? `Run modelling — ${planTotal!.toLocaleString()} plan${planTotal === 1 ? "" : "s"}`
: "No properties match"}
</button>
</div>
{(remodel.length > 0 || run.isError) && (
<div className="mt-2.5 flex flex-col gap-1">
{remodel.map((p) => (
<span
key={p.scenarioId}
className="inline-block w-fit rounded-lg border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs text-amber-800"
>
<b>{p.alreadyModelled.toLocaleString()}</b> of{" "}
{matched?.toLocaleString() ?? "—"} have already been modelled in &ldquo;
{scenarioById.get(p.scenarioId)?.name ?? p.scenarioId}&rdquo; they&apos;ll be
modelled again and the newest results shown
</span>
))}
{run.isError && <span className="text-xs text-red-600">{run.error.message}</span>}
</div>
)}
</div>
)}
{/* Large-run confirm */}
{confirmOpen && (
<div
role="dialog"
aria-modal="true"
aria-labelledby="large-run-title"
onKeyDown={(e) => {
if (e.key === "Escape") setConfirmOpen(false);
}}
className="fixed inset-0 z-50 flex items-center justify-center bg-brandblue/35 p-5 backdrop-blur-sm"
>
<div className="max-w-md rounded-2xl bg-white p-7 shadow-2xl">
<h3 id="large-run-title" className="font-manrope text-xl font-extrabold text-brandblue">
This is a large run
</h3>
<p className="mt-2 text-sm leading-relaxed text-gray-600">
You&apos;re about to model <b>{matched?.toLocaleString()}</b> homes across{" "}
{scenarioIds.length} scenario{scenarioIds.length > 1 ? "s" : ""} {" "}
<b>{planTotal?.toLocaleString()}</b> plans. A run this size can&apos;t be stopped
once it starts.
</p>
<div className="mt-3 rounded-xl border border-gray-100 bg-gray-50 px-3.5 py-2.5 text-[13px] leading-relaxed text-gray-600">
<span className="block">
<b className="font-semibold text-brandblue">Scenarios:</b>{" "}
{scenarioIds.map((id) => scenarioById.get(id)?.name ?? id).join(" · ")}
</span>
<span className="mt-1 block">
<b className="font-semibold text-brandblue">Homes:</b> {selectionSummary(filters)}
</span>
</div>
<div className="mt-5 flex justify-end gap-2.5">
<button
autoFocus
onClick={() => setConfirmOpen(false)}
className="rounded-xl border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-brandblue hover:bg-gray-50"
>
Go back
</button>
<button
onClick={() => {
setConfirmOpen(false);
run.mutate();
}}
className="rounded-xl px-4 py-2 text-sm font-semibold text-white"
style={{ background: "linear-gradient(135deg,#3943b7,#2d348f)" }}
>
Yes, run modelling
</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,38 @@
import { getPortfolio } from "../../utils";
import { listScenariosWithStatus } from "@/lib/scenarios/queries";
import { scenarioIdsWithActiveRuns } from "@/lib/modellingRuns/server";
import RunModellingClient, { ScenarioOption } from "./RunModellingClient";
export const dynamic = "force-dynamic";
export default async function Page(props: { params: Promise<{ slug: string }> }) {
const { slug } = await props.params;
const [{ name }, scenarios, activeIds] = await Promise.all([
getPortfolio(slug),
listScenariosWithStatus(BigInt(slug)),
scenarioIdsWithActiveRuns(slug),
]);
const options: ScenarioOption[] = scenarios.map((s) => ({
id: s.id.toString(),
name: s.name ?? `Scenario ${s.id}`,
goal: s.goal,
goalValue: s.goalValue,
housingType: s.housingType,
budget: s.budget,
status: activeIds.has(s.id.toString())
? "running"
: s.status === "modelled"
? "modelled"
: "awaiting",
modelledCount: s.plansCount,
}));
return (
<RunModellingClient
portfolioId={slug}
portfolioName={name ?? "Portfolio"}
scenarios={options}
/>
);
}

View file

@ -93,6 +93,12 @@ export default function AddPropertiesClient({
setDone(totals);
// Added properties change the search's alreadyInPortfolio flags
queryClient.invalidateQueries(["postcode-search", portfolioId]);
// …and the portfolio's property table. Remove, don't invalidate: the
// table isn't mounted here, and useProperties sets refetchOnMount:
// false, so a stale-marked query would never actually refetch — only
// an empty cache guarantees a fresh fetch when the table mounts.
queryClient.removeQueries(["properties", portfolioId]);
router.refresh();
},
});

View file

@ -0,0 +1,175 @@
import { describe, expect, it } from "vitest";
import {
buildRunRecord,
deriveRunStatus,
estimateRunMinutes,
historyRefetchInterval,
isLargeRun,
normaliseRunFilters,
parseRunConfig,
selectionSummary,
type RunStatus,
} from "./model";
describe("normaliseRunFilters", () => {
it("canonicalises postcodes, dedupes, and drops empty selections (absent = all)", () => {
const out = normaliseRunFilters({
postcodes: ["m20 4tf", "M20 4TF", "b93 8su"],
propertyTypes: [],
builtForms: undefined,
});
expect(out).toEqual({
ok: true,
filters: { postcodes: ["B93 8SU", "M20 4TF"] },
});
});
it("accepts canonical types/built forms plus Unknown, rejects anything else", () => {
const ok = normaliseRunFilters({
propertyTypes: ["House", "Unknown"],
builtForms: ["Semi-Detached"],
});
expect(ok).toEqual({
ok: true,
filters: { propertyTypes: ["House", "Unknown"], builtForms: ["Semi-Detached"] },
});
expect(normaliseRunFilters({ propertyTypes: ["Castle"] }).ok).toBe(false);
expect(normaliseRunFilters({ builtForms: ["Terraced"] }).ok).toBe(false);
});
it("caps the postcode selection at 40 — 'all postcodes' is absence, not enumeration", () => {
const many = Array.from({ length: 41 }, (_, i) => `M${Math.floor(i / 10)}${i % 10} 4TF`);
const out = normaliseRunFilters({ postcodes: many });
expect(out.ok).toBe(false);
if (!out.ok) expect(out.error).toMatch(/40/);
expect(normaliseRunFilters({ postcodes: many.slice(0, 40) }).ok).toBe(true);
});
});
describe("buildRunRecord", () => {
it("produces the app-authored task row carrying the run config as its inputs (ADR-0008)", () => {
const record = buildRunRecord({
portfolioId: "814",
scenarioIds: ["12", "34"],
filters: { postcodes: ["B93 8SU"], propertyTypes: ["House"] },
previewedPropertyCount: 214,
triggeredBy: "khalim@domna.homes",
});
expect(record.task).toEqual({
taskSource: "Modelling run 2 scenarios",
service: "modelling_run",
source: "portfolio_id",
sourceId: "814",
status: "waiting",
inputs: JSON.stringify({
portfolio_id: 814,
scenario_ids: [12, 34],
filters: { postcodes: ["B93 8SU"], property_types: ["House"] },
previewed_property_count: 214,
triggered_by: "khalim@domna.homes",
}),
});
// What the distributor receives — provenance fields stay on the task only
expect(record.dispatchPayload).toEqual({
portfolio_id: 814,
scenario_ids: [12, 34],
filters: { postcodes: ["B93 8SU"], property_types: ["House"] },
});
});
});
describe("parseRunConfig", () => {
it("round-trips a stored run record back to the app's shape", () => {
const { task } = buildRunRecord({
portfolioId: "814",
scenarioIds: ["12"],
filters: { builtForms: ["Detached", "Unknown"] },
previewedPropertyCount: 57,
triggeredBy: "priya@domna.homes",
});
expect(parseRunConfig(task.inputs)).toEqual({
scenarioIds: ["12"],
filters: { builtForms: ["Detached", "Unknown"] },
previewedPropertyCount: 57,
triggeredBy: "priya@domna.homes",
});
});
it("returns null for missing or malformed inputs — never throws on old rows", () => {
expect(parseRunConfig(null)).toBeNull();
expect(parseRunConfig("not json")).toBeNull();
expect(parseRunConfig('{"unrelated":true}')).toBeNull();
});
});
describe("selectionSummary", () => {
it("describes the filters in the run-history format; no filters = All properties", () => {
expect(selectionSummary({})).toBe("All properties");
expect(
selectionSummary({
postcodes: ["B93 8SU", "M20 4TF"],
propertyTypes: ["House"],
builtForms: ["Detached", "Unknown"],
}),
).toBe("Postcodes: B93 8SU, M20 4TF · Type: House · Built form: Detached, Unknown");
});
});
describe("deriveRunStatus", () => {
const base = { taskStatus: "waiting", jobCompleted: null, total: 0, completed: 0, failed: 0 };
it("maps the task lifecycle to the four display states", () => {
// dispatched: accepted, no execution subtasks yet
expect(deriveRunStatus(base)).toEqual({ state: "dispatched" });
// in progress once execution subtasks exist, with progress
expect(
deriveRunStatus({ ...base, taskStatus: "in progress", total: 104, completed: 61 }),
).toEqual({ state: "in_progress", ready: 61, total: 104 });
// complete when the task says so
expect(
deriveRunStatus({ ...base, taskStatus: "completed", jobCompleted: new Date(), total: 104, completed: 104 }),
).toEqual({ state: "complete" });
// failed statuses win regardless of counts
expect(
deriveRunStatus({ ...base, taskStatus: "Failed", total: 104, completed: 40, failed: 3 }),
).toEqual({ state: "failed" });
});
});
describe("isLargeRun", () => {
it("gates runs of 10,000+ matched properties behind an extra confirm", () => {
expect(isLargeRun(9999)).toBe(false);
expect(isLargeRun(10000)).toBe(true);
});
});
describe("estimateRunMinutes", () => {
it("scales with plan count — roughly 300 plans per 5 minutes", () => {
expect(estimateRunMinutes(3_000)).toBe(50);
expect(estimateRunMinutes(12_000)).toBe(200);
});
it("never promises less than 5 minutes, even for tiny or zero-plan runs", () => {
expect(estimateRunMinutes(0)).toBe(5);
expect(estimateRunMinutes(1)).toBe(5);
expect(estimateRunMinutes(300)).toBe(5);
});
});
describe("historyRefetchInterval", () => {
const runs = (...states: RunStatus[]) => states.map((status) => ({ status }));
it("polls fast while any run is still moving (dispatched or in progress)", () => {
expect(historyRefetchInterval(runs({ state: "complete" }, { state: "dispatched" }))).toBe(
5_000,
);
expect(
historyRefetchInterval(runs({ state: "in_progress", ready: 1, total: 4 })),
).toBe(5_000);
});
it("settles to a slow poll when every run is finished, or history is unknown", () => {
expect(historyRefetchInterval(runs({ state: "complete" }, { state: "failed" }))).toBe(30_000);
expect(historyRefetchInterval([])).toBe(30_000);
expect(historyRefetchInterval(undefined)).toBe(30_000);
});
});

View file

@ -0,0 +1,237 @@
/**
* Modelling-run domain model pure logic for the bulk-trigger-modelling
* journey. See CONTEXT.md (Modelling run, Run filter) and ADR-0008.
*/
import { normalisePostcode } from "@/lib/postcodeSearch/model";
import {
BuiltFormTypeValues,
PropertyTypeValues,
} from "@/app/db/schema/landlord_overrides";
/**
* The Run-filter vocabularies: the canonical enum lists, which already end in
* "Unknown" the no-resolvable-value bucket (CONTEXT.md, Run filter).
*/
export const RUN_FILTER_PROPERTY_TYPES: readonly string[] = PropertyTypeValues;
export const RUN_FILTER_BUILT_FORMS: readonly string[] = BuiltFormTypeValues;
/** An absent key means unconstrained — "all" is never an enumeration. */
export interface RunFilters {
postcodes?: string[];
propertyTypes?: string[];
builtForms?: string[];
}
export type NormaliseFiltersResult =
| { ok: true; filters: RunFilters }
| { ok: false; error: string };
/** UX guard, not a transport limit — the config is stored, not carried. */
export const MAX_FILTER_POSTCODES = 40;
export function normaliseRunFilters(input: {
postcodes?: string[];
propertyTypes?: string[];
builtForms?: string[];
}): NormaliseFiltersResult {
const filters: RunFilters = {};
if (input.postcodes?.length) {
const seen = new Set<string>();
for (const raw of input.postcodes) {
const pc = normalisePostcode(raw);
if (!pc) return { ok: false, error: `Invalid postcode '${raw}'` };
seen.add(pc);
}
filters.postcodes = [...seen].sort();
if (filters.postcodes.length > MAX_FILTER_POSTCODES) {
return {
ok: false,
error: `Filters are limited to ${MAX_FILTER_POSTCODES} postcodes per run — leave the filter empty to include all postcodes`,
};
}
}
// The filter vocabularies are the canonical enum lists (which already end
// in "Unknown" — the no-resolvable-value bucket, see CONTEXT.md Run filter).
for (const [key, allowed] of [
["propertyTypes", PropertyTypeValues],
["builtForms", BuiltFormTypeValues],
] as const) {
const values = input[key];
if (!values?.length) continue;
const bad = values.find((v) => !allowed.includes(v));
if (bad !== undefined) {
return { ok: false, error: `Invalid ${key === "propertyTypes" ? "property type" : "built form"} '${bad}'` };
}
filters[key] = [...new Set(values)];
}
return { ok: true, filters };
}
export interface RunConfigView {
scenarioIds: string[];
filters: RunFilters;
previewedPropertyCount: number;
triggeredBy: string;
}
/**
* Read a stored config-subtask `inputs` back into the app's shape. Null for
* anything that isn't a modelling-run config (malformed, legacy, absent)
* history reads must never throw on old rows.
*/
export function parseRunConfig(inputs: string | null): RunConfigView | null {
if (!inputs) return null;
try {
const raw = JSON.parse(inputs) as Partial<RunConfigInputs>;
if (!Array.isArray(raw.scenario_ids) || typeof raw.triggered_by !== "string") {
return null;
}
const filters: RunFilters = {};
if (raw.filters?.postcodes?.length) filters.postcodes = raw.filters.postcodes;
if (raw.filters?.property_types?.length) filters.propertyTypes = raw.filters.property_types;
if (raw.filters?.built_forms?.length) filters.builtForms = raw.filters.built_forms;
return {
scenarioIds: raw.scenario_ids.map(String),
filters,
previewedPropertyCount: raw.previewed_property_count ?? 0,
triggeredBy: raw.triggered_by,
};
} catch {
return null;
}
}
/** Above this, the UI asks for an explicit extra confirmation before running. */
export const LARGE_RUN_THRESHOLD = 10_000;
export function isLargeRun(matchedPropertyCount: number): boolean {
return matchedPropertyCount >= LARGE_RUN_THRESHOLD;
}
export type RunStatus =
| { state: "dispatched" }
| { state: "in_progress"; ready: number; total: number }
| { state: "complete" }
| { state: "failed" };
/**
* Display state of a run from its task + execution-subtask counts (the config
* subtask is excluded by the caller). Status strings follow the task system's
* loose conventions (case-insensitive; completion also signalled by
* job_completed).
*/
export function deriveRunStatus(input: {
taskStatus: string;
jobCompleted: Date | null;
total: number;
completed: number;
failed: number;
}): RunStatus {
const s = input.taskStatus.toLowerCase();
if (["failed", "failure", "error"].includes(s)) return { state: "failed" };
if (input.jobCompleted || ["completed", "complete"].includes(s)) {
return { state: "complete" };
}
if (input.total === 0) return { state: "dispatched" };
return { state: "in_progress", ready: input.completed, total: input.total };
}
/**
* Rough results ETA shown after a run is triggered: the distributor works
* through ~300 plans per 5-minute batch window, floored at 5 minutes so a
* tiny run never promises the impossible. A hedge ("typically ready
* within"), not a contract.
*/
export function estimateRunMinutes(planCount: number): number {
return Math.max(5, Math.round(planCount / 300) * 5);
}
/**
* Run-history poll cadence: fast while any run is still moving so progress
* feels live, slow once everything has settled. Shape matches TanStack v4's
* refetchInterval callback, which receives the query data first.
*/
export function historyRefetchInterval(
runs: { status: RunStatus }[] | undefined,
): number {
const active = runs?.some(
(r) => r.status.state === "dispatched" || r.status.state === "in_progress",
);
return active ? 5_000 : 30_000;
}
/** Human summary of a Run filter, as shown in run history ("All properties"). */
export function selectionSummary(filters: RunFilters): string {
const bits: string[] = [];
if (filters.postcodes?.length) bits.push(`Postcodes: ${filters.postcodes.join(", ")}`);
if (filters.propertyTypes?.length) bits.push(`Type: ${filters.propertyTypes.join(", ")}`);
if (filters.builtForms?.length) bits.push(`Built form: ${filters.builtForms.join(", ")}`);
return bits.length ? bits.join(" · ") : "All properties";
}
export interface RunRequest {
portfolioId: string;
scenarioIds: string[];
filters: RunFilters;
previewedPropertyCount: number;
triggeredBy: string;
}
/** Backend-facing (snake_case) copy of a run's configuration. */
export interface RunConfigInputs {
portfolio_id: number;
scenario_ids: number[];
filters: {
postcodes?: string[];
property_types?: string[];
built_forms?: string[];
};
previewed_property_count: number;
triggered_by: string;
}
/**
* The row a Modelling run persists (ADR-0008): an app-authored task whose
* `inputs` carries the run's config the durable, history-readable record.
* Sub-tasks are the distributor's: per-property execution work.
*/
export function buildRunRecord(req: RunRequest): {
task: {
taskSource: string;
service: "modelling_run";
source: "portfolio_id";
sourceId: string;
status: "waiting";
inputs: string;
};
/** What the distributor receives — provenance fields stay on the task. */
dispatchPayload: Omit<RunConfigInputs, "previewed_property_count" | "triggered_by">;
} {
const filters: RunConfigInputs["filters"] = {};
if (req.filters.postcodes) filters.postcodes = req.filters.postcodes;
if (req.filters.propertyTypes) filters.property_types = req.filters.propertyTypes;
if (req.filters.builtForms) filters.built_forms = req.filters.builtForms;
const configInputs: RunConfigInputs = {
portfolio_id: Number(req.portfolioId),
scenario_ids: req.scenarioIds.map(Number),
filters,
previewed_property_count: req.previewedPropertyCount,
triggered_by: req.triggeredBy,
};
return {
task: {
taskSource: `Modelling run ${req.scenarioIds.length} scenario${req.scenarioIds.length === 1 ? "" : "s"}`,
service: "modelling_run",
source: "portfolio_id",
sourceId: req.portfolioId,
status: "waiting",
inputs: JSON.stringify(configInputs),
},
dispatchPayload: {
portfolio_id: configInputs.portfolio_id,
scenario_ids: configInputs.scenario_ids,
filters,
},
};
}

View file

@ -0,0 +1,273 @@
import { db } from "@/app/db/db";
import { tasks } from "@/app/db/schema/tasks/tasks";
import { subTasks } from "@/app/db/schema/tasks/subtask";
import { scenario } from "@/app/db/schema/recommendations";
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import {
RunFilters,
RunStatus,
buildRunRecord,
deriveRunStatus,
parseRunConfig,
selectionSummary,
} from "./model";
import { builtFormTypeSql, propertyTypeSql } from "@/lib/services/epcSources";
// Distributor entry point on the Model backend. It fans the run out to
// workers, attaching execution sub-tasks to the task_id we pass (ADR-0008).
// NOTE: sub-task granularity is BATCHES, not properties — progress counts are
// internally consistent but never comparable to the previewed property count.
const DISTRIBUTOR_ENDPOINT = "/v1/modelling/trigger-run";
type BackendResult<T> = { ok: true; data: T } | { ok: false; status: number; message: string };
async function callBackend<T>(
endpoint: string,
payload: Record<string, unknown>,
sessionToken: string | undefined,
): Promise<BackendResult<T>> {
const url = process.env.FASTAPI_API_URL;
const key = process.env.FASTAPI_API_KEY;
if (!url || !key) {
console.error("FASTAPI_API_URL or FASTAPI_API_KEY not set");
return { ok: false, status: 500, message: "Server misconfiguration" };
}
try {
const res = await fetch(`${url}${endpoint}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": key,
Authorization: `Bearer ${sessionToken}`,
},
body: JSON.stringify(payload),
});
if (!res.ok) {
const errText = await res.text().catch(() => "");
console.error(`FastAPI ${endpoint} failed:`, res.status, errText);
return { ok: false, status: 502, message: "Modelling backend request failed" };
}
return { ok: true, data: (await res.json()) as T };
} catch (err) {
console.error(`Failed to reach FastAPI ${endpoint}:`, err);
return { ok: false, status: 502, message: "Modelling backend request failed" };
}
}
export type TriggerRunOutcome =
| { kind: "ok"; taskId: string }
| { kind: "unknown_scenarios"; missing: string[] }
| { kind: "dispatch_failed"; status: number; message: string };
/**
* Record + dispatch a Modelling run: one app-authored task whose `inputs`
* carries the run's config (BulkUpload trigger convention), then hand
* task_id to the distributor sub_tasks are its, one per property. A
* failed dispatch marks the task failed so history never shows a ghost run
* as pending (ADR-0008).
*/
export async function triggerModellingRun(args: {
portfolioId: string;
scenarioIds: string[];
filters: RunFilters;
previewedPropertyCount: number;
triggeredBy: string;
sessionToken: string | undefined;
}): Promise<TriggerRunOutcome> {
const known = await db
.select({ id: scenario.id })
.from(scenario)
.where(
and(
eq(scenario.portfolioId, BigInt(args.portfolioId)),
inArray(scenario.id, args.scenarioIds.map(BigInt)),
),
);
const knownIds = new Set(known.map((r) => r.id.toString()));
const missing = args.scenarioIds.filter((id) => !knownIds.has(id));
if (missing.length > 0) return { kind: "unknown_scenarios", missing };
const record = buildRunRecord(args);
const now = new Date();
const [task] = await db
.insert(tasks)
.values({ ...record.task, jobStarted: now })
.returning();
// Minimal dispatch payload — previewed count and triggered_by are app
// provenance, already durable on the task's inputs.
const dispatch = await callBackend(
DISTRIBUTOR_ENDPOINT,
{ task_id: task.id, ...record.dispatchPayload },
args.sessionToken,
);
if (!dispatch.ok) {
await db.update(tasks).set({ status: "failed" }).where(eq(tasks.id, task.id));
return { kind: "dispatch_failed", status: dispatch.status, message: dispatch.message };
}
await db.update(tasks).set({ status: "in progress" }).where(eq(tasks.id, task.id));
return { kind: "ok", taskId: task.id };
}
export interface PreviewCounts {
matchedProperties: number;
perScenario: { scenarioId: string; alreadyModelled: number }[];
}
/**
* Count the properties a Run filter matches, plus how many of them already
* hold plans per selected scenario. Resolution precedence per ADR-0008:
* landlord-override snapshot EPC-derived (new-approach graph, lodged over
* predicted, or legacy property-row columns) Unknown. The distributor
* implements the same documented rule; both read this database.
* One grouped, portfolio-scoped statement no per-scenario probes.
*/
export async function previewModellingRun(args: {
portfolioId: string;
scenarioIds: string[];
filters: RunFilters;
}): Promise<PreviewCounts> {
const pid = BigInt(args.portfolioId);
const inList = (values: (string | number)[]) =>
sql.join(values.map((v) => sql`${v}`), sql`, `);
const conditions = [sql`TRUE`];
if (args.filters.postcodes) {
conditions.push(sql`resolved.postcode IN (${inList(args.filters.postcodes)})`);
}
if (args.filters.propertyTypes) {
conditions.push(sql`resolved.ptype IN (${inList(args.filters.propertyTypes)})`);
}
if (args.filters.builtForms) {
conditions.push(sql`resolved.bform IN (${inList(args.filters.builtForms)})`);
}
const result = await db.execute<{
matched: number;
per_scenario: { scenario_id: string; n: number }[] | null;
}>(sql`
WITH resolved AS (
SELECT p.id, p.postcode,
COALESCE(pot.override_value, ${propertyTypeSql}, 'Unknown') AS ptype,
COALESCE(pobf.override_value, ${builtFormTypeSql}, 'Unknown') AS bform
FROM property p
LEFT JOIN property_overrides pot ON pot.property_id = p.id
AND pot.override_component = 'property_type' AND pot.building_part = 0
LEFT JOIN property_overrides pobf ON pobf.property_id = p.id
AND pobf.override_component = 'built_form_type' AND pobf.building_part = 0
LEFT JOIN epc_property epl ON epl.property_id = p.id AND epl.source = 'lodged'
LEFT JOIN epc_property epp ON epp.property_id = p.id AND epp.source = 'predicted'
WHERE p.portfolio_id = ${pid} AND p.marked_for_deletion = false
),
matched AS (
SELECT resolved.id FROM resolved
WHERE ${sql.join(conditions, sql` AND `)}
),
already AS (
SELECT scenario_id, COUNT(DISTINCT property_id)::int AS n
FROM plan
WHERE portfolio_id = ${pid}
AND scenario_id IN (${inList(args.scenarioIds.map(Number))})
AND property_id IN (SELECT id FROM matched)
GROUP BY scenario_id
)
SELECT
(SELECT COUNT(*)::int FROM matched) AS matched,
(SELECT json_agg(json_build_object('scenario_id', scenario_id::text, 'n', n)) FROM already) AS per_scenario
`);
const row = result.rows[0];
return {
matchedProperties: row?.matched ?? 0,
perScenario: (row?.per_scenario ?? []).map((p) => ({
scenarioId: p.scenario_id,
alreadyModelled: p.n,
})),
};
}
export interface RunHistoryRow {
taskId: string;
triggeredAt: string;
triggeredBy: string;
scenarios: { id: string; name: string | null }[];
selection: string;
previewedPropertyCount: number;
planCount: number;
status: RunStatus;
}
/**
* Run history for a portfolio: its modelling_run tasks with the app-authored
* config parsed from task inputs and progress from sub-task counts (one
* sub-task per property, distributor-attached). One grouped query
* portfolio-scoped, never per-run probes.
*/
export async function listModellingRuns(
portfolioId: string,
limit = 20,
): Promise<RunHistoryRow[]> {
const rows = await db
.select({
taskId: tasks.id,
jobStarted: tasks.jobStarted,
jobCompleted: tasks.jobCompleted,
status: tasks.status,
configInputs: tasks.inputs,
total: sql<number>`count(${subTasks.id})::int`,
completed: sql<number>`count(case when lower(${subTasks.status}) in ('completed','complete') then 1 end)::int`,
failed: sql<number>`count(case when lower(${subTasks.status}) in ('failed','failure','error') then 1 end)::int`,
})
.from(tasks)
.leftJoin(subTasks, eq(subTasks.taskId, tasks.id))
.where(and(eq(tasks.service, "modelling_run"), eq(tasks.sourceId, portfolioId)))
.groupBy(tasks.id)
.orderBy(desc(tasks.jobStarted))
.limit(limit);
const configs = rows.map((r) => parseRunConfig(r.configInputs));
const scenarioIds = [...new Set(configs.flatMap((c) => c?.scenarioIds ?? []))];
const names = scenarioIds.length
? await db
.select({ id: scenario.id, name: scenario.name })
.from(scenario)
.where(inArray(scenario.id, scenarioIds.map(BigInt)))
: [];
const nameById = new Map(names.map((n) => [n.id.toString(), n.name]));
return rows.map((r, i) => {
const config = configs[i];
const scenarios = (config?.scenarioIds ?? []).map((id) => ({
id,
name: nameById.get(id) ?? null,
}));
return {
taskId: r.taskId,
triggeredAt: (r.jobStarted ?? new Date(0)).toISOString(),
triggeredBy: config?.triggeredBy ?? "unknown",
scenarios,
selection: config ? selectionSummary(config.filters) : "—",
previewedPropertyCount: config?.previewedPropertyCount ?? 0,
planCount: (config?.previewedPropertyCount ?? 0) * scenarios.length,
status: deriveRunStatus({
taskStatus: r.status,
jobCompleted: r.jobCompleted,
total: r.total,
completed: r.completed,
failed: r.failed,
}),
};
});
}
/** Scenario ids with a run currently in flight — drives the third badge state. */
export async function scenarioIdsWithActiveRuns(portfolioId: string): Promise<Set<string>> {
const runs = await listModellingRuns(portfolioId, 50);
const active = new Set<string>();
for (const run of runs) {
if (run.status.state === "dispatched" || run.status.state === "in_progress") {
run.scenarios.forEach((s) => active.add(s.id));
}
}
return active;
}

View file

@ -211,7 +211,7 @@ export const PROPERTY_TYPE_LABELS: Record<string, string> = {
"3": "Maisonette",
"4": "Park home",
};
const BUILT_FORM_LABELS: Record<string, string> = {
export const BUILT_FORM_LABELS: Record<string, string> = {
"1": "Detached",
"2": "Semi-Detached",
"3": "End-Terrace",
@ -273,6 +273,16 @@ export const propertyTypeSql = sql`CASE
ELSE p.property_type
END`;
/**
* Built-form label. New: epc_property (lodged, else predicted) RdSAP codes
* mapped via BUILT_FORM_LABELS, text passed through, mirroring
* resolvePropertyDescriptors. Legacy: property.built_form.
*/
export const builtFormTypeSql = sql`CASE
WHEN ${isNewApproachSql} THEN (CASE WHEN epl.id IS NOT NULL THEN ${codeLabelCaseSql(sql`epl.built_form`, BUILT_FORM_LABELS)} ELSE ${codeLabelCaseSql(sql`epp.built_form`, BUILT_FORM_LABELS)} END)
ELSE p.built_form
END`;
/**
* The main building part's construction age band for one epc_property alias, as
* a correlated scalar subquery properties can also have extension parts, so a