mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-22 08:48:34 +00:00
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:
parent
35eb681f8f
commit
6c479542a0
8 changed files with 1088 additions and 0 deletions
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
103
src/app/api/portfolio/[portfolioId]/modelling-runs/route.ts
Normal file
103
src/app/api/portfolio/[portfolioId]/modelling-runs/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
42
src/app/api/portfolio/[portfolioId]/postcodes/route.ts
Normal file
42
src/app/api/portfolio/[portfolioId]/postcodes/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
582
src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx
Normal file
582
src/app/portfolio/[slug]/modelling/run/RunModellingClient.tsx
Normal 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'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'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've told us about a home
|
||||
takes priority over its EPC; “Unknown” 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'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 “
|
||||
{scenarioById.get(p.scenarioId)?.name ?? p.scenarioId}” — they'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'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'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>
|
||||
);
|
||||
}
|
||||
38
src/app/portfolio/[slug]/modelling/run/page.tsx
Normal file
38
src/app/portfolio/[slug]/modelling/run/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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[];
|
||||
|
|
|
|||
244
src/lib/modellingRuns/server.ts
Normal file
244
src/lib/modellingRuns/server.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue