fix(modelling): set-and-forget trigger state, visible back link, filter clearing

Post-review UX feedback on the run journey:

- The post-trigger card computed its ETA from the just-cleared scenario
  selection, so it always promised "~5 minutes" whatever the run size.
  ETA now comes from the captured run (estimateRunMinutes, TDD'd) and the
  done state is a compact status banner above a still-visible Recent runs
  table, which polls every 5s while a run is moving (historyRefetchInterval,
  TDD'd) — trigger, glance, leave.
- The only way back was a text-xs gray-400 breadcrumb (~2.5:1, sub-AA);
  replaced with an accessible "← Back to {portfolio}" link.
- "Remove all filters" button on Step 2.
- Scenario rows were a checkbox nested inside a button (state invisible to
  screen readers); now a label wrapping a real checkbox.
- Large-run confirm lists the scenario names and selection being committed,
  is labelled, closes on Escape, and starts focus on "Go back".
- Postcode filter no longer fails silently: loading and error-with-retry
  states.
- Run status "Dispatched" reads "Starting up"; scenarios page gains a
  "Run modelling" entry point.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-07 17:37:16 +00:00
parent 06ad4eec0d
commit 8f7f5b3a88
4 changed files with 205 additions and 72 deletions

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

@ -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<RunStatus["state"], string> = {
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<Set<string>>(new Set());
const [selPcs, setSelPcs] = useState<Set<string>>(new Set());
@ -174,7 +175,8 @@ export default function RunModellingClient({
const history = useQuery<RunHistoryRow[], Error>({
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 (
<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>
<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>
@ -239,34 +247,42 @@ export default function RunModellingClient({
</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
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>
) : (
@ -298,9 +314,8 @@ export default function RunModellingClient({
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 ${
<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"
@ -309,9 +324,8 @@ export default function RunModellingClient({
<input
type="checkbox"
checked={on}
readOnly
tabIndex={-1}
className="pointer-events-none h-4 w-4 shrink-0 accent-brandmidblue"
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">
@ -326,7 +340,7 @@ export default function RunModellingClient({
</span>
</span>
<RunBadge status={s.status} />
</button>
</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
@ -341,9 +355,19 @@ export default function RunModellingClient({
{/* 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>
<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>
@ -353,15 +377,39 @@ export default function RunModellingClient({
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)}
/>
{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 }))}
@ -381,8 +429,12 @@ export default function RunModellingClient({
</p>
</div>
{/* Recent runs */}
<div className="mt-4 rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">
</>
)}
{/* 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">
@ -470,9 +522,7 @@ export default function RunModellingClient({
</table>
</div>
)}
</div>
</>
)}
</div>
{/* Summary tray */}
{!done && scenarioIds.length > 0 && (
@ -545,10 +595,14 @@ export default function RunModellingClient({
<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 className="font-manrope text-xl font-extrabold text-brandblue">
<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">
@ -557,8 +611,18 @@ export default function RunModellingClient({
<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"
>

View file

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

View file

@ -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[] = [];