feat(modelling-runs): trigger journey — server module, routes, run page

Builds the bulk-trigger-modelling journey per ADR-0008 on the TDD'd core:

- Server module: triggerModellingRun (app-authored task + config subtask,
  BulkUpload trigger convention; dispatch to /v1/plan/trigger-run; failed
  dispatch marks the task failed), previewModellingRun (dry-run count proxy
  to /v1/plan/preview-run), listModellingRuns (one grouped portfolio-scoped
  query; config parsed back, progress from execution-subtask counts),
  scenarioIdsWithActiveRuns (third badge state).
- Routes: POST/GET /api/portfolio/[id]/modelling-runs, POST .../preview,
  GET /api/portfolio/[id]/postcodes (grouped distinct + counts).
- Page /portfolio/[slug]/modelling/run to the approved mockup in the
  scenarios-tab design language: scenario picker with the three badges and
  in-flight warning, three filter columns (postcodes from DB, canonical
  enums + Unknown), engine-counted summary tray with re-model callouts,
  large-run confirm (10k gate), done state with ETA, Recent runs table
  with live progress. TanStack Query v4; no useEffect/useMemo.
- "Run modelling" button beside Add properties in PropertyTable.

Backend contract (Model team): the distributor at /v1/plan/trigger-run
accepts {task_id, portfolio_id, scenario_ids, filters, ...} and attaches
execution subtasks to the given task; /v1/plan/preview-run returns
{matched_properties, per_scenario[]} through the same resolution code path.

Verified live against portfolio 814: postcodes, validation errors, unknown
scenarios, dispatch failure marks the task failed and history shows it;
simulated backend execution subtasks drove status to
{in_progress, ready 2, total 4} and the scenario badge to "Modelling in
progress" in the rendered page; portfolio page shows the button. tsc, lint
and all 365 vitest tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-06 17:08:57 +00:00
parent 35eb681f8f
commit 6c479542a0
8 changed files with 1088 additions and 0 deletions

View file

@ -0,0 +1,62 @@
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,
sessionToken:
request.cookies.get("__Secure-next-auth.session-token")?.value ??
request.cookies.get("next-auth.session-token")?.value,
});
if (!result.ok) {
return NextResponse.json({ error: result.message }, { status: result.status });
}
return NextResponse.json(result.data);
} 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

@ -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,582 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { PlayIcon } from "@heroicons/react/24/solid";
import {
RUN_FILTER_BUILT_FORMS,
RUN_FILTER_PROPERTY_TYPES,
RunFilters,
RunStatus,
isLargeRun,
MAX_FILTER_POSTCODES,
} 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: "Dispatched",
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 router = useRouter();
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],
refetchInterval: 30_000,
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 eta = planTotal ? Math.max(5, Math.round(planTotal / 300) * 5) : 5;
return (
<div className="mx-auto max-w-4xl px-6 pb-48 pt-10">
<div className="mb-4 text-xs text-gray-400">
<Link href={`/portfolio/${portfolioId}`} className="hover:text-brandblue">
{portfolioName} / Portfolio
</Link>{" "}
/ Run modelling
</div>
<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 className="mt-6 rounded-2xl border border-gray-100 bg-white px-8 py-12 text-center shadow-sm">
<div className="mb-4 inline-flex h-14 w-14 items-center justify-center rounded-full bg-brandlightblue text-brandmidblue">
<PlayIcon className="h-6 w-6" />
</div>
<h2 className="font-manrope text-2xl font-extrabold text-brandblue">Modelling started</h2>
<p className="mx-auto mt-3 max-w-md text-sm leading-relaxed text-gray-600">
We&apos;re modelling <b>{done.properties.toLocaleString()}</b>{" "}
{done.properties === 1 ? "property" : "properties"} against <b>{done.scenarios}</b>{" "}
scenario{done.scenarios > 1 ? "s" : ""} {" "}
{(done.properties * done.scenarios).toLocaleString()} plan
{done.properties * done.scenarios === 1 ? "" : "s"} in total. Results for a run this
size are typically ready within <b>~{eta} minutes</b>. You can check on this run any
time under Recent runs.
</p>
<div className="mt-6 flex items-center justify-center gap-3">
<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"
>
Run more modelling
</button>
<button
onClick={() => router.push(`/portfolio/${portfolioId}`)}
className="rounded-xl px-4 py-2 text-sm font-semibold text-white"
style={{ background: "linear-gradient(135deg,#3943b7,#2d348f)" }}
>
View portfolio
</button>
</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}>
<button
onClick={() => toggle(selScenarios, setSelScenarios, s.id)}
className={`mt-2.5 flex w-full 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}
readOnly
tabIndex={-1}
className="pointer-events-none 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} />
</button>
{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">
<span className="font-manrope text-[11px] font-extrabold uppercase tracking-[.14em] text-brandmidblue">
Step 2 · Properties
</span>
<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">
<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 */}
<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.status.state === "in_progress" ? (
<>
<span className="whitespace-nowrap text-[12px] text-gray-500">
{r.status.ready.toLocaleString()} of {r.status.total.toLocaleString()} ready
</span>
<span className="mt-1 block h-1 w-24 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>
</>
) : (
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>
</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"
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 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-5 flex justify-end gap-2.5">
<button
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

@ -9,6 +9,13 @@ import {
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[];

View file

@ -0,0 +1,244 @@
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";
// The config subtask that makes a run's request durable (ADR-0008); execution
// subtasks attached by the distributor carry other service values.
export const RUN_CONFIG_SERVICE = "modelling_run_config";
// Distributor entry point on the Model backend. It fans the run out to
// workers, attaching execution subtasks to the task_id we pass (ADR-0008).
const DISTRIBUTOR_ENDPOINT = "/v1/plan/trigger-run";
// Dry-run sibling: same filter resolution, counts only.
const PREVIEW_ENDPOINT = "/v1/plan/preview-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: app-authored task and config subtask
* (BulkUpload trigger convention), then hand task_id to the distributor. 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();
await db.insert(subTasks).values({
taskId: task.id,
status: "waiting",
service: RUN_CONFIG_SERVICE,
inputs: JSON.stringify(record.configInputs),
});
const dispatch = await callBackend(
DISTRIBUTOR_ENDPOINT,
{ task_id: task.id, ...record.configInputs },
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 }[];
}
/** Proxy the backend dry-run: same resolution code path as the real run. */
export async function previewModellingRun(args: {
portfolioId: string;
scenarioIds: string[];
filters: RunFilters;
sessionToken: string | undefined;
}): Promise<BackendResult<PreviewCounts>> {
const { configInputs } = buildRunRecord({
...args,
previewedPropertyCount: 0,
triggeredBy: "preview",
});
const res = await callBackend<{
matched_properties: number;
per_scenario?: { scenario_id: number; already_modelled: number }[];
}>(
PREVIEW_ENDPOINT,
{
portfolio_id: configInputs.portfolio_id,
scenario_ids: configInputs.scenario_ids,
filters: configInputs.filters,
},
args.sessionToken,
);
if (!res.ok) return res;
return {
ok: true,
data: {
matchedProperties: res.data.matched_properties,
perScenario: (res.data.per_scenario ?? []).map((p) => ({
scenarioId: String(p.scenario_id),
alreadyModelled: p.already_modelled,
})),
},
};
}
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 app-authored
* config parsed back out and progress from execution-subtask counts. 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: sql<string | null>`max(${subTasks.inputs}) filter (where ${subTasks.service} = ${RUN_CONFIG_SERVICE})`,
total: sql<number>`count(${subTasks.id}) filter (where ${subTasks.service} is distinct from ${RUN_CONFIG_SERVICE})::int`,
completed: sql<number>`count(case when lower(${subTasks.status}) in ('completed','complete') and ${subTasks.service} is distinct from ${RUN_CONFIG_SERVICE} then 1 end)::int`,
failed: sql<number>`count(case when lower(${subTasks.status}) in ('failed','failure','error') and ${subTasks.service} is distinct from ${RUN_CONFIG_SERVICE} 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;
}