mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-19 17:02:59 +00:00
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:
parent
06ad4eec0d
commit
8f7f5b3a88
4 changed files with 205 additions and 72 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'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't need to stay on this page
|
||||
</h2>
|
||||
<p className="mt-1 max-w-[60ch] text-[13px] leading-relaxed text-gray-600">
|
||||
We'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't load your portfolio'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'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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue