diff --git a/src/app/portfolio/[slug]/(portfolio)/scenarios/page.tsx b/src/app/portfolio/[slug]/(portfolio)/scenarios/page.tsx
index 05113760..ba1afc38 100644
--- a/src/app/portfolio/[slug]/(portfolio)/scenarios/page.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/scenarios/page.tsx
@@ -40,13 +40,23 @@ export default async function ScenariosPage(props: {
}}
/>
-
- + New scenario
-
+
+ {scenarios.length > 0 && (
+
+ Run modelling
+
+ )}
+
+ + New scenario
+
+
Each scenario is a question you ask the modelling engine. Its
diff --git a/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx b/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx
index 68b6c691..3d4808d4 100644
--- a/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx
+++ b/src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx
@@ -2,16 +2,18 @@
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 { 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 {
@@ -42,7 +44,7 @@ interface RunHistoryRow {
}
const STATUS_LABELS: Record = {
- dispatched: "Dispatched",
+ dispatched: "Starting up",
in_progress: "In progress",
complete: "Complete",
failed: "Failed",
@@ -128,7 +130,6 @@ export default function RunModellingClient({
portfolioName: string;
scenarios: ScenarioOption[];
}) {
- const router = useRouter();
const queryClient = useQueryClient();
const [selScenarios, setSelScenarios] = useState>(new Set());
const [selPcs, setSelPcs] = useState>(new Set());
@@ -174,7 +175,8 @@ export default function RunModellingClient({
const history = useQuery({
queryKey: ["modelling-runs", portfolioId],
- refetchInterval: 30_000,
+ // 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();
@@ -220,16 +222,22 @@ export default function RunModellingClient({
if (matched != null && isLargeRun(matched)) setConfirmOpen(true);
else run.mutate();
};
- const eta = planTotal ? Math.max(5, Math.round(planTotal / 300) * 5) : 5;
+ const filterCount = selPcs.size + selTypes.size + selForms.size;
+ const clearFilters = () => {
+ setSelPcs(new Set());
+ setSelTypes(new Set());
+ setSelForms(new Set());
+ };
return (
-
-
- {portfolioName} / Portfolio
- {" "}
- / Run modelling
-
+
+
+ Back to {portfolioName}
+
Run your scenarios.
@@ -239,34 +247,42 @@ export default function RunModellingClient({
{done ? (
-
-
-
Modelling started
-
- We're modelling {done.properties.toLocaleString()} {" "}
- {done.properties === 1 ? "property" : "properties"} against {done.scenarios} {" "}
- 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 ~{eta} minutes . You can check on this run any
- time under Recent runs.
-
-
-
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
-
-
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 →
-
+
+
+
+
+
+ Modelling started — you don't need to stay on this page
+
+
+ We're modelling {done.properties.toLocaleString()} {" "}
+ {done.properties === 1 ? "home" : "homes"} against {done.scenarios} {" "}
+ scenario{done.scenarios === 1 ? "" : "s"} —{" "}
+ {(done.properties * done.scenarios).toLocaleString()} plan
+ {done.properties * done.scenarios === 1 ? "" : "s"}. Results for a run this size
+ are typically ready within{" "}
+ ~{estimateRunMinutes(done.properties * done.scenarios)} minutes — progress
+ updates under Recent runs below as it happens.
+
+
+
+ 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
+
+
+ Back to portfolio
+
+
) : (
@@ -298,9 +314,8 @@ export default function RunModellingClient({
const on = selScenarios.has(s.id);
return (
-
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 ${
+ toggle(selScenarios, setSelScenarios, s.id)}
+ className="h-4 w-4 shrink-0 accent-brandmidblue"
/>
@@ -326,7 +340,7 @@ export default function RunModellingClient({
-
+
{on && s.status === "running" && (
This scenario is already being modelled. You can still include it — the
@@ -341,9 +355,19 @@ export default function RunModellingClient({
{/* Step 2 — properties */}
-
- Step 2 · Properties
-
+
+
+ Step 2 · Properties
+
+ {filterCount > 0 && (
+
+ Remove all filters ({filterCount})
+
+ )}
+
Which homes?
@@ -353,15 +377,39 @@ export default function RunModellingClient({
neither.
-
({
- value: p.postcode,
- count: p.count,
- }))}
- selected={selPcs}
- onToggle={(v) => toggle(selPcs, setSelPcs, v)}
- />
+ {postcodesQuery.isError ? (
+
+
+ Postcode
+
+
+ We couldn't load your portfolio's postcodes.{" "}
+ postcodesQuery.refetch()}
+ className="font-semibold underline hover:text-red-800"
+ >
+ Try again
+
+
+
+ ) : postcodesQuery.isLoading ? (
+
+
+ Postcode
+
+
Loading postcodes…
+
+ ) : (
+ ({
+ value: p.postcode,
+ count: p.count,
+ }))}
+ selected={selPcs}
+ onToggle={(v) => toggle(selPcs, setSelPcs, v)}
+ />
+ )}
({ value }))}
@@ -381,8 +429,12 @@ export default function RunModellingClient({
- {/* Recent runs */}
-
+ >
+ )}
+
+ {/* Recent runs — always visible: after a run is triggered this is where
+ its live status lands, so the banner above can say "watch below" */}
+
Recent runs
@@ -470,9 +522,7 @@ export default function RunModellingClient({
)}
-
- >
- )}
+
{/* Summary tray */}
{!done && scenarioIds.length > 0 && (
@@ -545,10 +595,14 @@ export default function RunModellingClient({
{
+ 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"
>
-
+
This is a large run
@@ -557,8 +611,18 @@ export default function RunModellingClient({
{planTotal?.toLocaleString()} plans. A run this size can't be stopped
once it starts.
+
+
+ Scenarios: {" "}
+ {scenarioIds.map((id) => scenarioById.get(id)?.name ?? id).join(" · ")}
+
+
+ Homes: {selectionSummary(filters)}
+
+
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"
>
diff --git a/src/lib/modellingRuns/model.test.ts b/src/lib/modellingRuns/model.test.ts
index 3767388f..5bd39f8c 100644
--- a/src/lib/modellingRuns/model.test.ts
+++ b/src/lib/modellingRuns/model.test.ts
@@ -2,10 +2,13 @@ import { describe, expect, it } from "vitest";
import {
buildRunRecord,
deriveRunStatus,
+ estimateRunMinutes,
+ historyRefetchInterval,
isLargeRun,
normaliseRunFilters,
parseRunConfig,
selectionSummary,
+ type RunStatus,
} from "./model";
describe("normaliseRunFilters", () => {
@@ -138,3 +141,35 @@ describe("isLargeRun", () => {
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);
+ });
+});
diff --git a/src/lib/modellingRuns/model.ts b/src/lib/modellingRuns/model.ts
index 973bf2b5..179af4a7 100644
--- a/src/lib/modellingRuns/model.ts
+++ b/src/lib/modellingRuns/model.ts
@@ -137,6 +137,30 @@ export function deriveRunStatus(input: {
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[] = [];