fix(admin): record the pulse rebuild and let a stopped one run again

The rebuild after apply now writes its state beside the dry run
(queued, running, done with counts, failed); a run left queued or
running without a job reads as failed. The card shows it and offers a
new rebuild after a failure, which the route accepts once the method is
applied, without a new dry run.
This commit is contained in:
Marc-André Bombeck committed 2026-10-16 05:55:31 +02:00
1 parent cc9d523bc8
commit f360d221e1
16 files changed
+483 -49

No files matched your search

@@ -37,7 +37,12 @@ const RESULT = {
const status = (
lastDryRun: PulseDayMeanStatus["lastDryRun"],
method: PulseDayMeanStatus["method"] = "hour_mean_v1",
): PulseDayMeanStatus => ({ method, appliedAt: null, lastDryRun });
): PulseDayMeanStatus => ({
method,
appliedAt: null,
lastDryRun,
lastRebuild: null,
});
const done = (finishedAt: string) => ({
status: "done" as const,
@@ -113,4 +118,33 @@ describe("PulseDayMeanCard", () => {
expect(de).toContain("Dünne Stunden gewichtet");
expect(de).toContain("Probelauf");
});
it("shows a stopped recalculation and offers it again without a dry run", () => {
const failed: PulseDayMeanStatus = {
...status(done("2026-10-01T10:00:00Z"), "weighted_hour_mean_v2"),
appliedAt: "2026-10-01T11:00:00Z",
lastRebuild: {
status: "failed",
startedAt: "2026-10-01T11:00:00Z",
finishedAt: "2026-10-01T11:05:00Z",
accounts: null,
rows: null,
},
};
const html = render(failed);
expect(html).toContain('data-state="failed"');
expect(html).toContain('data-testid="admin-pulse-day-mean-rebuild"');
const finished = render({
...failed,
lastRebuild: {
...failed.lastRebuild!,
status: "done",
accounts: 3,
rows: 900,
},
});
expect(finished).not.toContain("admin-pulse-day-mean-rebuild");
expect(finished).toContain('data-slot="pulse-day-mean-rebuild"');
});
});
+48 -3
View File
@@ -57,8 +57,13 @@ export function PulseDayMeanCard() {
queryFn: () =>
apiGet<PulseDayMeanStatus>(ENDPOINT, { credentials: "include" }),
refetchInterval: (query) => {
const state = query.state.data?.lastDryRun?.status;
return state === "queued" || state === "running" ? POLL_MS : false;
const states = [
query.state.data?.lastDryRun?.status,
query.state.data?.lastRebuild?.status,
];
return states.some((state) => state === "queued" || state === "running")
? POLL_MS
: false;
},
});
@@ -101,6 +106,8 @@ export function PulseDayMeanCard() {
? t("admin.section.pulseDayMean.applied")
: t("admin.section.pulseDayMean.appliedNoRebuild"),
);
} else if (action === "rebuild") {
toast.success(t("admin.section.pulseDayMean.rebuildStarted"));
} else {
toast.success(t("admin.section.pulseDayMean.dryRunStarted"));
}
@@ -125,9 +132,16 @@ export function PulseDayMeanCard() {
const s = status.data;
const dryRun = s?.lastDryRun ?? null;
const result = dryRun?.status === "done" ? dryRun.result : null;
const active = dryRun?.status === "queued" || dryRun?.status === "running";
const rebuild = s?.lastRebuild ?? null;
const rebuilding =
rebuild?.status === "queued" || rebuild?.status === "running";
const active =
dryRun?.status === "queued" || dryRun?.status === "running" || rebuilding;
const weighted = s?.method === "weighted_hour_mean_v2";
const canApply = !!s && !active && hasFreshDryRun(s);
// A stopped recalculation is run again from here; the method is already
// switched, so it needs no new dry run.
const canRebuild = !!s?.appliedAt && rebuild?.status === "failed";
return (
<SettingsCard data-testid="admin-pulse-day-mean">
@@ -252,6 +266,25 @@ export function PulseDayMeanCard() {
</p>
) : null}
{rebuild ? (
<p
className={
rebuild.status === "failed"
? "text-destructive text-sm"
: "text-muted-foreground text-xs"
}
data-slot="pulse-day-mean-rebuild"
data-state={rebuild.status}
>
{rebuild.status === "done"
? t("admin.section.pulseDayMean.rebuild.done", {
at: fmt.dateTime(rebuild.finishedAt ?? rebuild.startedAt),
accounts: fmt.number(rebuild.accounts ?? 0),
})
: t(`admin.section.pulseDayMean.rebuild.${rebuild.status}`)}
</p>
) : null}
<SettingsCardActions>
<Button
size="sm"
@@ -264,6 +297,18 @@ export function PulseDayMeanCard() {
<FlaskConical className="size-3.5" aria-hidden />
{t("admin.section.pulseDayMean.dryRun")}
</Button>
{canRebuild ? (
<Button
size="sm"
variant="outline"
className="min-h-11 sm:min-h-9"
disabled={run.isPending}
onClick={() => run.mutate("rebuild")}
data-testid="admin-pulse-day-mean-rebuild"
>
{t("admin.section.pulseDayMean.rebuildAgain")}
</Button>
) : null}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button