From 8f7f5b3a88ed9d553740cb81fd2caa3f8beb6a49 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 7 Jul 2026 17:37:16 +0000 Subject: [PATCH] fix(modelling): set-and-forget trigger state, visible back link, filter clearing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../[slug]/(portfolio)/scenarios/page.tsx | 24 ++- .../modelling/run/RunModellingClient.tsx | 194 ++++++++++++------ src/lib/modellingRuns/model.test.ts | 35 ++++ src/lib/modellingRuns/model.ts | 24 +++ 4 files changed, 205 insertions(+), 72 deletions(-) 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. -

-
- - +
+
+ +
+

+ 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. +

+
+
+ + + Back to portfolio + +
) : ( @@ -298,9 +314,8 @@ export default function RunModellingClient({ const on = selScenarios.has(s.id); return (
- + {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 && ( + + )} +

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.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)} + +