From f360d221e145551111f036f7117ece3e2382cab6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 16 Oct 2026 05:55:31 +0200
Subject: [PATCH] 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.
---
messages/de.json | 10 +-
messages/en.json | 10 +-
messages/es.json | 10 +-
messages/fr.json | 10 +-
messages/it.json | 10 +-
messages/ko.json | 10 +-
messages/pl.json | 10 +-
.../pulse-day-mean/__tests__/route.test.ts | 76 +++++++++++-
.../admin/maintenance/pulse-day-mean/route.ts | 62 ++++++++--
.../__tests__/pulse-day-mean-card.test.tsx | 36 +++++-
src/components/admin/pulse-day-mean-card.tsx | 51 +++++++-
.../pulse-day-mean-rebuild-state.test.ts | 73 ++++++++++++
src/lib/jobs/pulse-day-mean.ts | 112 ++++++++++++++----
.../__tests__/recompute-on-switch.test.ts | 2 +-
src/lib/validations/pulse-day-mean.ts | 37 +++++-
.../integration/pulse-day-mean-method.test.ts | 13 +-
16 files changed, 483 insertions(+), 49 deletions(-)
create mode 100644 src/lib/jobs/__tests__/pulse-day-mean-rebuild-state.test.ts
diff --git a/messages/de.json b/messages/de.json
index d514020f9..8b7dae3ab 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -8223,7 +8223,15 @@
"busy": "Ein Probelauf oder eine Neuberechnung läuft noch. Versuche es erneut, sobald er fertig ist.",
"dryRunRequired": "Starte zuerst einen Probelauf. Zum Anwenden braucht es einen, der in den letzten sieben Tagen abgeschlossen wurde.",
"generic": "Die Anfrage ist nicht durchgegangen."
- }
+ },
+ "rebuild": {
+ "queued": "Die Neuberechnung wartet auf den Hintergrunddienst.",
+ "running": "Die Pulstage werden neu berechnet.",
+ "done": "Pulstage am {at} für {accounts} Konten neu berechnet.",
+ "failed": "Die Neuberechnung ist abgebrochen. Das App-Log nennt die Ursache; starte sie erneut."
+ },
+ "rebuildAgain": "Erneut berechnen",
+ "rebuildStarted": "Neuberechnung gestartet."
}
},
"carrier": "Netzbetreiber",
diff --git a/messages/en.json b/messages/en.json
index 84b69140a..2a30cc523 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -8223,7 +8223,15 @@
"busy": "A dry run or recalculation is still running. Try again once it has finished.",
"dryRunRequired": "Run a dry run first. Applying needs one that finished in the last seven days.",
"generic": "The request did not go through."
- }
+ },
+ "rebuild": {
+ "queued": "The recalculation is waiting for the background worker.",
+ "running": "The pulse days are being recalculated.",
+ "done": "Pulse days recalculated on {at} for {accounts} accounts.",
+ "failed": "The recalculation stopped. The app log names the cause; run it again."
+ },
+ "rebuildAgain": "Recalculate again",
+ "rebuildStarted": "Recalculation started."
}
},
"carrier": "Carrier",
diff --git a/messages/es.json b/messages/es.json
index 7f428795b..49d60e6f5 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -8223,7 +8223,15 @@
"busy": "Todavía hay una prueba o un recálculo en marcha. Inténtalo de nuevo cuando termine.",
"dryRunRequired": "Ejecuta primero una prueba. Para aplicar hace falta una terminada en los últimos siete días.",
"generic": "La solicitud no se completó."
- }
+ },
+ "rebuild": {
+ "queued": "El recálculo espera al proceso en segundo plano.",
+ "running": "Se están recalculando los días de pulso.",
+ "done": "Días de pulso recalculados el {at} para {accounts} cuentas.",
+ "failed": "El recálculo se detuvo. El registro de la app indica la causa; vuelve a ejecutarlo."
+ },
+ "rebuildAgain": "Recalcular de nuevo",
+ "rebuildStarted": "Recálculo iniciado."
}
},
"carrier": "Operador",
diff --git a/messages/fr.json b/messages/fr.json
index 8f7f8f69f..d2453af72 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -8223,7 +8223,15 @@
"busy": "Une simulation ou un recalcul est encore en cours. Réessayez une fois terminé.",
"dryRunRequired": "Lancez d'abord une simulation. L'application en exige une terminée au cours des sept derniers jours.",
"generic": "La requête n'a pas abouti."
- }
+ },
+ "rebuild": {
+ "queued": "Le recalcul attend le processus en arrière-plan.",
+ "running": "Les jours de pouls sont en cours de recalcul.",
+ "done": "Jours de pouls recalculés le {at} pour {accounts} comptes.",
+ "failed": "Le recalcul s’est arrêté. Le journal de l’app en indique la cause ; relancez-le."
+ },
+ "rebuildAgain": "Recalculer à nouveau",
+ "rebuildStarted": "Recalcul lancé."
}
},
"carrier": "Opérateur",
diff --git a/messages/it.json b/messages/it.json
index 4e3f7b5b5..fefbb8d30 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -8223,7 +8223,15 @@
"busy": "Una prova o un ricalcolo è ancora in corso. Riprova quando è terminato.",
"dryRunRequired": "Avvia prima una prova. Per applicare ne serve una terminata negli ultimi sette giorni.",
"generic": "La richiesta non è andata a buon fine."
- }
+ },
+ "rebuild": {
+ "queued": "Il ricalcolo attende il processo in background.",
+ "running": "I giorni del polso sono in fase di ricalcolo.",
+ "done": "Giorni del polso ricalcolati il {at} per {accounts} account.",
+ "failed": "Il ricalcolo si è interrotto. Il log dell’app indica la causa; avvialo di nuovo."
+ },
+ "rebuildAgain": "Ricalcola di nuovo",
+ "rebuildStarted": "Ricalcolo avviato."
}
},
"carrier": "Operatore",
diff --git a/messages/ko.json b/messages/ko.json
index 95d537ce8..ec0a66d5b 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -8198,7 +8198,15 @@
"busy": "시험 실행이나 재계산이 아직 진행 중입니다. 끝난 뒤 다시 시도하세요.",
"dryRunRequired": "먼저 시험 실행을 하세요. 적용하려면 최근 7일 안에 끝난 시험 실행이 필요합니다.",
"generic": "요청이 처리되지 않았습니다."
- }
+ },
+ "rebuild": {
+ "queued": "재계산이 백그라운드 작업을 기다리고 있습니다.",
+ "running": "맥박 일별 값을 재계산하고 있습니다.",
+ "done": "{at}에 계정 {accounts}개의 맥박 일별 값을 재계산했습니다.",
+ "failed": "재계산이 중단되었습니다. 앱 로그에 원인이 있습니다. 다시 실행하세요."
+ },
+ "rebuildAgain": "다시 계산",
+ "rebuildStarted": "재계산을 시작했습니다."
}
},
"carrier": "통신사",
diff --git a/messages/pl.json b/messages/pl.json
index 5fe13ba60..7aecb1066 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -8223,7 +8223,15 @@
"busy": "Próba lub przeliczanie wciąż trwa. Spróbuj ponownie, gdy się zakończy.",
"dryRunRequired": "Najpierw uruchom próbę. Do zastosowania potrzebna jest próba zakończona w ciągu ostatnich siedmiu dni.",
"generic": "Żądanie nie zostało zrealizowane."
- }
+ },
+ "rebuild": {
+ "queued": "Ponowne przeliczenie czeka na proces w tle.",
+ "running": "Dni pulsu są przeliczane.",
+ "done": "Dni pulsu przeliczone {at} dla kont: {accounts}.",
+ "failed": "Przeliczanie zostało przerwane. Przyczynę podaje log aplikacji; uruchom je ponownie."
+ },
+ "rebuildAgain": "Przelicz ponownie",
+ "rebuildStarted": "Przeliczanie rozpoczęte."
}
},
"carrier": "Operator",
diff --git a/src/app/api/admin/maintenance/pulse-day-mean/__tests__/route.test.ts b/src/app/api/admin/maintenance/pulse-day-mean/__tests__/route.test.ts
index 3f05cb0a1..26c89266e 100644
--- a/src/app/api/admin/maintenance/pulse-day-mean/__tests__/route.test.ts
+++ b/src/app/api/admin/maintenance/pulse-day-mean/__tests__/route.test.ts
@@ -66,10 +66,14 @@ const post = (body: unknown) =>
const NOW = new Date("2026-10-16T12:00:00Z");
const send = vi.fn();
-function settings(dryRun: unknown, method = "hour_mean_v1") {
+function settings(
+ dryRun: unknown,
+ method = "hour_mean_v1",
+ appliedAt: Date | null = null,
+) {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
pulseDayMeanMethod: method,
- pulseDayMeanAppliedAt: null,
+ pulseDayMeanAppliedAt: appliedAt,
pulseDayMeanDryRunJson: dryRun,
} as never);
}
@@ -136,6 +140,7 @@ describe("GET", () => {
method: "hour_mean_v1",
appliedAt: null,
lastDryRun: doneAt("2026-10-15T08:00:00.000Z"),
+ lastRebuild: null,
});
});
@@ -199,7 +204,9 @@ describe("POST dry_run", () => {
const res = await post({ action: "dry_run" });
expect(res.status).toBe(409);
expect(vi.mocked(prisma.appSettings.upsert).mock.lastCall?.[0]).toEqual(
- expect.objectContaining({ update: { pulseDayMeanDryRunJson: previous } }),
+ expect.objectContaining({
+ update: { pulseDayMeanDryRunJson: { ...previous, rebuild: null } },
+ }),
);
});
});
@@ -264,10 +271,71 @@ describe("POST apply", () => {
});
});
+describe("POST rebuild", () => {
+ const failedRebuild = {
+ status: "failed",
+ startedAt: "2026-10-15T09:00:00.000Z",
+ finishedAt: "2026-10-15T09:05:00.000Z",
+ accounts: null,
+ rows: null,
+ };
+
+ it("is refused before any apply", async () => {
+ settings(doneAt("2026-10-15T08:00:00.000Z"));
+ const res = await post({ action: "rebuild" });
+ expect(res.status).toBe(409);
+ expect((await res.json()).meta.errorCode).toBe(
+ "maintenance.pulseDayMean.dryRunRequired",
+ );
+ expect(send).not.toHaveBeenCalled();
+ });
+
+ it("runs the recalculation again after a stopped one, without a new dry run", async () => {
+ // The dry run is three weeks old: apply would refuse it, a rebuild does not.
+ settings(
+ { ...doneAt("2026-09-20T08:00:00.000Z"), rebuild: failedRebuild },
+ "weighted_hour_mean_v2",
+ new Date("2026-09-20T09:00:00.000Z"),
+ );
+ const res = await post({ action: "rebuild" });
+ expect(res.status).toBe(202);
+ expect(send).toHaveBeenCalledWith(
+ "pulse-day-mean",
+ { action: "rebuild" },
+ expect.anything(),
+ );
+ expect(
+ vi.mocked(prisma.appSettings.upsert).mock.lastCall?.[0],
+ ).toMatchObject({
+ update: {
+ pulseDayMeanDryRunJson: { rebuild: { status: "queued" } },
+ },
+ });
+ expect(auditLog).toHaveBeenCalledWith(
+ "admin.pulseDayMean.rebuild",
+ expect.anything(),
+ );
+ });
+
+ it("shows a rebuild left queued with no job behind it as failed", async () => {
+ settings(
+ {
+ ...doneAt("2026-10-15T08:00:00.000Z"),
+ rebuild: { ...failedRebuild, status: "running", finishedAt: null },
+ },
+ "weighted_hour_mean_v2",
+ new Date("2026-10-15T09:00:00.000Z"),
+ );
+ const body = await (await get()).json();
+ expect(body.data.lastRebuild.status).toBe("failed");
+ expect(body.data.lastDryRun.status).toBe("done");
+ });
+});
+
describe("POST body", () => {
it("is strict", async () => {
expect((await post({ action: "apply", force: true })).status).toBe(422);
- expect((await post({ action: "rebuild" })).status).toBe(422);
+ expect((await post({ action: "purge" })).status).toBe(422);
expect(send).not.toHaveBeenCalled();
});
diff --git a/src/app/api/admin/maintenance/pulse-day-mean/route.ts b/src/app/api/admin/maintenance/pulse-day-mean/route.ts
index c94e34f50..12e0ebf5d 100644
--- a/src/app/api/admin/maintenance/pulse-day-mean/route.ts
+++ b/src/app/api/admin/maintenance/pulse-day-mean/route.ts
@@ -4,7 +4,7 @@
*
* GET answers the active method, when it was applied and the last dry run
* (status and result: per-account ordinal labels only, never an id). POST
- * `{ action: "dry_run" | "apply" }` answers 202 `{ enqueued }`: a dry run
+ * `{ action: "dry_run" | "apply" | "rebuild" }` answers 202 `{ enqueued }`: a dry run
* computes both methods for every account and day and writes nothing but its
* result; `apply` needs a successful dry run from the last seven days (409
* `maintenance.pulseDayMean.dryRunRequired`), switches the method and
@@ -31,6 +31,7 @@ import {
PULSE_DAY_MEAN_SEND_OPTIONS,
readPulseDayMeanStatus,
writeDryRun,
+ writeRebuild,
type PulseDayMeanPayload,
} from "@/lib/jobs/pulse-day-mean";
import { annotate } from "@/lib/logging/context";
@@ -41,6 +42,7 @@ import {
import {
hasFreshDryRun,
pulseDayMeanActionSchema,
+ type PulseDayMeanStatus,
} from "@/lib/validations/pulse-day-mean";
export const dynamic = "force-dynamic";
@@ -54,6 +56,33 @@ function busy() {
});
}
+/**
+ * Queue the rebuild and record it as queued, so the card shows it from the
+ * moment it is asked for and a rebuild that never starts reads as failed.
+ * When another run holds the singleton, the previous rebuild state is put
+ * back and the answer is false.
+ */
+async function sendRebuild(
+ boss: NonNullable>,
+ previous: PulseDayMeanStatus["lastRebuild"],
+): Promise {
+ await writeRebuild({
+ status: "queued",
+ startedAt: new Date().toISOString(),
+ finishedAt: null,
+ accounts: null,
+ rows: null,
+ });
+ const payload: PulseDayMeanPayload = { action: "rebuild" };
+ const jobId = await boss.send(
+ PULSE_DAY_MEAN_QUEUE,
+ payload,
+ PULSE_DAY_MEAN_SEND_OPTIONS,
+ );
+ if (jobId === null) await writeRebuild(previous);
+ return jobId !== null;
+}
+
export const GET = apiHandler(async () => {
await requireAdmin();
const status = await readPulseDayMeanStatus();
@@ -110,6 +139,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiSuccess({ enqueued: true }, 202);
}
+ if (action === "rebuild") {
+ // The recalculation again, after an apply whose rebuild stopped. The
+ // method is already switched; the dry run that justified it stands.
+ const status = await readPulseDayMeanStatus();
+ if (status.appliedAt === null) {
+ return apiError(
+ "Nothing applied yet; run a dry run and apply first",
+ 409,
+ { errorCode: "maintenance.pulseDayMean.dryRunRequired" },
+ );
+ }
+ const enqueued = await sendRebuild(boss, status.lastRebuild);
+ if (!enqueued) return busy();
+ await auditLog("admin.pulseDayMean.rebuild", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: { method: status.method },
+ });
+ annotate({ meta: { pulse_day_mean_enqueued: true } });
+ return apiSuccess({ enqueued: true }, 202);
+ }
+
if (!hasFreshDryRun(await readPulseDayMeanStatus())) {
return apiError(
"Run a dry run first; apply needs one finished in the last seven days",
@@ -135,13 +186,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
setActivePulseDayMeanMethod(APPLIED_METHOD);
- const payload: PulseDayMeanPayload = { action: "rebuild" };
- const jobId = await boss.send(
- PULSE_DAY_MEAN_QUEUE,
- payload,
- PULSE_DAY_MEAN_SEND_OPTIONS,
+ const enqueued = await sendRebuild(
+ boss,
+ (await readPulseDayMeanStatus()).lastRebuild,
);
- const enqueued = jobId !== null;
await auditLog("admin.pulseDayMean.apply", {
userId: user.id,
diff --git a/src/components/admin/__tests__/pulse-day-mean-card.test.tsx b/src/components/admin/__tests__/pulse-day-mean-card.test.tsx
index 926de7ea6..317901ea6 100644
--- a/src/components/admin/__tests__/pulse-day-mean-card.test.tsx
+++ b/src/components/admin/__tests__/pulse-day-mean-card.test.tsx
@@ -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"');
+ });
});
diff --git a/src/components/admin/pulse-day-mean-card.tsx b/src/components/admin/pulse-day-mean-card.tsx
index 285fec109..91bfbc0dc 100644
--- a/src/components/admin/pulse-day-mean-card.tsx
+++ b/src/components/admin/pulse-day-mean-card.tsx
@@ -57,8 +57,13 @@ export function PulseDayMeanCard() {
queryFn: () =>
apiGet(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 (
@@ -252,6 +266,25 @@ export function PulseDayMeanCard() {
) : null}
+ {rebuild ? (
+
+ {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}`)}
+
+ ) : null}
+
{t("admin.section.pulseDayMean.dryRun")}
+ {canRebuild ? (
+
+ ) : null}