diff --git a/messages/de.json b/messages/de.json index 9fb9a2e08..632dfa127 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8126,6 +8126,48 @@ "started": "Pflege gestartet.", "alreadyQueued": "Ein Pflegelauf ist bereits eingeplant oder läuft.", "startFailed": "Die Pflege konnte nicht gestartet werden." + }, + "pulseDayMean": { + "title": "Puls-Tagesmittel", + "description": "Wie Stunden mit nur wenigen Messwerten in den Tagespuls eingehen.", + "body": "Bisher zählt jede Stunde mit einem Messwert gleich viel, sodass ein einzelner Ausreißer einen ganzen Tag verschieben kann. Die gewichtete Methode lässt eine Stunde ab drei Messwerten voll zählen und einen einzelnen Messwert zu einem Drittel. Starte zuerst den Probelauf, um zu sehen, wie viele Tage sich ändern würden. Beim Anwenden werden die gespeicherten Pulstage im Hintergrund neu berechnet.", + "method": { + "hourly": "Jede Stunde gleich", + "weighted": "Dünne Stunden gewichtet" + }, + "loading": "Puls-Tagesmittel wird geprüft…", + "loadError": "Der Stand des Puls-Tagesmittels konnte nicht gelesen werden.", + "state": { + "idle": "Noch kein Probelauf.", + "queued": "Der Probelauf wartet auf den Hintergrunddienst.", + "running": "Der Probelauf läuft.", + "done": "Probelauf am {at} abgeschlossen.", + "failed": "Der Probelauf wurde abgebrochen. Das App-Protokoll nennt die Ursache; starte ihn erneut." + }, + "result": { + "days": "Verglichene Tage", + "changed": "Tage mit mehr als 1 bpm Änderung", + "max": "Größte Änderung", + "maxValue": "{value} bpm", + "bucket": "{range} bpm", + "account": "Konto {account}", + "accountDays": "{changed} von {days} Tagen ändern sich" + }, + "appliedAt": "Gewichtete Methode angewendet am {at}.", + "dryRun": "Probelauf", + "apply": "Anwenden", + "confirmTitle": "Gewichtetes Puls-Tagesmittel anwenden?", + "confirmBody": "Danach werden die Pulstage aller Konten mit gewichteten dünnen Stunden berechnet. Die gespeicherten Pulstage werden im Hintergrund neu berechnet, was auf einer großen Instanz eine Weile dauern kann.", + "confirm": "Anwenden", + "cancel": "Abbrechen", + "dryRunStarted": "Probelauf gestartet.", + "applied": "Gewichtete Methode angewendet. Die Pulstage werden neu berechnet.", + "appliedNoRebuild": "Gewichtete Methode angewendet. Ein anderer Lauf arbeitet noch; wende sie erneut an, sobald er fertig ist, damit die Pulstage neu berechnet werden.", + "error": { + "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." + } } }, "carrier": "Netzbetreiber", diff --git a/messages/en.json b/messages/en.json index dfaf30239..fecce85f8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8126,6 +8126,48 @@ "started": "Maintenance started.", "alreadyQueued": "A maintenance run is already queued or running.", "startFailed": "Could not start the maintenance." + }, + "pulseDayMean": { + "title": "Pulse day mean", + "description": "How hours with only a few readings count toward a day's pulse.", + "body": "Up to now every hour with a reading counts the same, so a single stray reading can move a whole day. The weighted method lets an hour count fully from three readings on, and a lone reading a third. Run the dry run first to see how many days would change. Applying recalculates the stored pulse days in the background.", + "method": { + "hourly": "Every hour equal", + "weighted": "Sparse hours weighted" + }, + "loading": "Checking the pulse day mean…", + "loadError": "Could not read the pulse day-mean status.", + "state": { + "idle": "No dry run yet.", + "queued": "The dry run is waiting for the background worker.", + "running": "The dry run is in progress.", + "done": "Dry run finished on {at}.", + "failed": "The dry run stopped. The app log names the cause; start it again." + }, + "result": { + "days": "Days compared", + "changed": "Days moving more than 1 bpm", + "max": "Largest change", + "maxValue": "{value} bpm", + "bucket": "{range} bpm", + "account": "Account {account}", + "accountDays": "{changed} of {days} days move" + }, + "appliedAt": "Weighted method applied on {at}.", + "dryRun": "Dry run", + "apply": "Apply", + "confirmTitle": "Apply the weighted pulse day mean?", + "confirmBody": "Every account's pulse days are then computed with sparse hours weighted. The stored pulse days are recalculated in the background, which can take a while on a large instance.", + "confirm": "Apply", + "cancel": "Cancel", + "dryRunStarted": "Dry run started.", + "applied": "Weighted method applied. The pulse days are being recalculated.", + "appliedNoRebuild": "Weighted method applied. Another run is still working, so apply again once it has finished to recalculate the pulse days.", + "error": { + "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." + } } }, "carrier": "Carrier", diff --git a/messages/es.json b/messages/es.json index 33e992c02..dd77b93f3 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8126,6 +8126,48 @@ "started": "Mantenimiento iniciado.", "alreadyQueued": "Ya hay un mantenimiento en cola o en marcha.", "startFailed": "No se pudo iniciar el mantenimiento." + }, + "pulseDayMean": { + "title": "Media diaria del pulso", + "description": "Cómo cuentan las horas con pocas lecturas en el pulso de un día.", + "body": "Hasta ahora, cada hora con una lectura cuenta lo mismo, así que una sola lectura suelta puede mover un día entero. El método ponderado hace que una hora cuente por completo a partir de tres lecturas, y una lectura aislada un tercio. Ejecuta primero la prueba para ver cuántos días cambiarían. Al aplicarlo, los días de pulso guardados se recalculan en segundo plano.", + "method": { + "hourly": "Todas las horas igual", + "weighted": "Horas escasas ponderadas" + }, + "loading": "Comprobando la media diaria del pulso…", + "loadError": "No se pudo leer el estado de la media diaria del pulso.", + "state": { + "idle": "Aún no hay ninguna prueba.", + "queued": "La prueba está esperando al proceso en segundo plano.", + "running": "La prueba está en curso.", + "done": "Prueba terminada el {at}.", + "failed": "La prueba se detuvo. El registro de la app indica la causa; vuelve a iniciarla." + }, + "result": { + "days": "Días comparados", + "changed": "Días que cambian más de 1 bpm", + "max": "Mayor cambio", + "maxValue": "{value} bpm", + "bucket": "{range} bpm", + "account": "Cuenta {account}", + "accountDays": "Cambian {changed} de {days} días" + }, + "appliedAt": "Método ponderado aplicado el {at}.", + "dryRun": "Prueba", + "apply": "Aplicar", + "confirmTitle": "¿Aplicar la media diaria del pulso ponderada?", + "confirmBody": "A partir de entonces, los días de pulso de todas las cuentas se calculan con las horas escasas ponderadas. Los días de pulso guardados se recalculan en segundo plano, lo que puede tardar en una instancia grande.", + "confirm": "Aplicar", + "cancel": "Cancelar", + "dryRunStarted": "Prueba iniciada.", + "applied": "Método ponderado aplicado. Los días de pulso se están recalculando.", + "appliedNoRebuild": "Método ponderado aplicado. Otro proceso sigue en marcha; vuelve a aplicarlo cuando termine para recalcular los días de pulso.", + "error": { + "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ó." + } } }, "carrier": "Operador", diff --git a/messages/fr.json b/messages/fr.json index 66e399825..08e203bae 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8126,6 +8126,48 @@ "started": "Maintenance lancée.", "alreadyQueued": "Une maintenance est déjà en file d'attente ou en cours.", "startFailed": "Impossible de lancer la maintenance." + }, + "pulseDayMean": { + "title": "Moyenne journalière du pouls", + "description": "Comment les heures avec peu de mesures comptent dans le pouls d'une journée.", + "body": "Jusqu'ici, chaque heure avec une mesure compte autant, si bien qu'une seule mesure isolée peut déplacer toute une journée. La méthode pondérée fait compter une heure pleinement à partir de trois mesures, et une mesure isolée pour un tiers. Lancez d'abord la simulation pour voir combien de jours changeraient. L'application recalcule en arrière-plan les journées de pouls enregistrées.", + "method": { + "hourly": "Toutes les heures à égalité", + "weighted": "Heures clairsemées pondérées" + }, + "loading": "Vérification de la moyenne journalière du pouls…", + "loadError": "Impossible de lire l'état de la moyenne journalière du pouls.", + "state": { + "idle": "Aucune simulation pour l'instant.", + "queued": "La simulation attend le processus d'arrière-plan.", + "running": "La simulation est en cours.", + "done": "Simulation terminée le {at}.", + "failed": "La simulation s'est arrêtée. Le journal de l'application en indique la cause ; relancez-la." + }, + "result": { + "days": "Jours comparés", + "changed": "Jours qui bougent de plus de 1 bpm", + "max": "Plus grand écart", + "maxValue": "{value} bpm", + "bucket": "{range} bpm", + "account": "Compte {account}", + "accountDays": "{changed} jours sur {days} changent" + }, + "appliedAt": "Méthode pondérée appliquée le {at}.", + "dryRun": "Simulation", + "apply": "Appliquer", + "confirmTitle": "Appliquer la moyenne journalière pondérée du pouls ?", + "confirmBody": "Les journées de pouls de tous les comptes sont alors calculées avec les heures clairsemées pondérées. Les journées enregistrées sont recalculées en arrière-plan, ce qui peut prendre un moment sur une grande instance.", + "confirm": "Appliquer", + "cancel": "Annuler", + "dryRunStarted": "Simulation lancée.", + "applied": "Méthode pondérée appliquée. Les journées de pouls sont en cours de recalcul.", + "appliedNoRebuild": "Méthode pondérée appliquée. Une autre exécution est encore en cours ; appliquez de nouveau une fois terminée pour recalculer les journées de pouls.", + "error": { + "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." + } } }, "carrier": "Opérateur", diff --git a/messages/it.json b/messages/it.json index 2e29bb05c..2068119a0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8126,6 +8126,48 @@ "started": "Manutenzione avviata.", "alreadyQueued": "Una manutenzione è già in coda o in corso.", "startFailed": "Impossibile avviare la manutenzione." + }, + "pulseDayMean": { + "title": "Media giornaliera del polso", + "description": "Come contano le ore con poche misurazioni nel polso di una giornata.", + "body": "Finora ogni ora con una misurazione conta allo stesso modo, quindi una sola misurazione isolata può spostare un'intera giornata. Il metodo ponderato fa contare un'ora per intero da tre misurazioni in su, e una misurazione isolata per un terzo. Avvia prima la prova per vedere quanti giorni cambierebbero. Applicandolo, i giorni di polso salvati vengono ricalcolati in background.", + "method": { + "hourly": "Tutte le ore uguali", + "weighted": "Ore rade ponderate" + }, + "loading": "Controllo della media giornaliera del polso…", + "loadError": "Impossibile leggere lo stato della media giornaliera del polso.", + "state": { + "idle": "Ancora nessuna prova.", + "queued": "La prova è in attesa del processo in background.", + "running": "La prova è in corso.", + "done": "Prova terminata il {at}.", + "failed": "La prova si è interrotta. Il registro dell'app ne indica la causa; avviala di nuovo." + }, + "result": { + "days": "Giorni confrontati", + "changed": "Giorni che cambiano di oltre 1 bpm", + "max": "Variazione maggiore", + "maxValue": "{value} bpm", + "bucket": "{range} bpm", + "account": "Account {account}", + "accountDays": "Cambiano {changed} giorni su {days}" + }, + "appliedAt": "Metodo ponderato applicato il {at}.", + "dryRun": "Prova", + "apply": "Applica", + "confirmTitle": "Applicare la media giornaliera del polso ponderata?", + "confirmBody": "Da quel momento i giorni di polso di tutti gli account vengono calcolati con le ore rade ponderate. I giorni salvati vengono ricalcolati in background, il che può richiedere un po' su un'istanza grande.", + "confirm": "Applica", + "cancel": "Annulla", + "dryRunStarted": "Prova avviata.", + "applied": "Metodo ponderato applicato. I giorni di polso vengono ricalcolati.", + "appliedNoRebuild": "Metodo ponderato applicato. Un'altra esecuzione è ancora in corso; applica di nuovo quando è terminata per ricalcolare i giorni di polso.", + "error": { + "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." + } } }, "carrier": "Operatore", diff --git a/messages/ko.json b/messages/ko.json index 23d6ec87a..288cdd975 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8101,6 +8101,48 @@ "started": "유지 관리를 시작했습니다.", "alreadyQueued": "유지 관리가 이미 대기 중이거나 실행 중입니다.", "startFailed": "유지 관리를 시작할 수 없습니다." + }, + "pulseDayMean": { + "title": "하루 평균 맥박", + "description": "측정값이 적은 시간이 하루 맥박에 반영되는 방식입니다.", + "body": "지금까지는 측정값이 있는 모든 시간이 똑같이 반영되어, 동떨어진 측정값 하나가 하루 전체를 움직일 수 있습니다. 가중 방식에서는 측정값이 세 개 이상인 시간이 온전히 반영되고, 측정값이 하나뿐인 시간은 3분의 1만 반영됩니다. 먼저 시험 실행으로 바뀌는 날이 얼마나 되는지 확인하세요. 적용하면 저장된 맥박 일별 값이 백그라운드에서 다시 계산됩니다.", + "method": { + "hourly": "모든 시간 동일", + "weighted": "드문 시간 가중" + }, + "loading": "하루 평균 맥박을 확인하는 중…", + "loadError": "하루 평균 맥박 상태를 읽을 수 없습니다.", + "state": { + "idle": "아직 시험 실행이 없습니다.", + "queued": "시험 실행이 백그라운드 작업자를 기다리는 중입니다.", + "running": "시험 실행이 진행 중입니다.", + "done": "{at}에 시험 실행이 끝났습니다.", + "failed": "시험 실행이 중단되었습니다. 앱 로그에 원인이 있습니다. 다시 시작하세요." + }, + "result": { + "days": "비교한 날", + "changed": "1 bpm 넘게 바뀌는 날", + "max": "가장 큰 변화", + "maxValue": "{value} bpm", + "bucket": "{range} bpm", + "account": "계정 {account}", + "accountDays": "{days}일 중 {changed}일 변경" + }, + "appliedAt": "{at}에 가중 방식이 적용되었습니다.", + "dryRun": "시험 실행", + "apply": "적용", + "confirmTitle": "가중 하루 평균 맥박을 적용할까요?", + "confirmBody": "이후 모든 계정의 맥박 일별 값이 드문 시간을 가중하여 계산됩니다. 저장된 맥박 일별 값은 백그라운드에서 다시 계산되며, 큰 인스턴스에서는 시간이 걸릴 수 있습니다.", + "confirm": "적용", + "cancel": "취소", + "dryRunStarted": "시험 실행을 시작했습니다.", + "applied": "가중 방식이 적용되었습니다. 맥박 일별 값을 다시 계산하는 중입니다.", + "appliedNoRebuild": "가중 방식이 적용되었습니다. 다른 실행이 아직 진행 중이니, 끝난 뒤 다시 적용하여 맥박 일별 값을 다시 계산하세요.", + "error": { + "busy": "시험 실행이나 재계산이 아직 진행 중입니다. 끝난 뒤 다시 시도하세요.", + "dryRunRequired": "먼저 시험 실행을 하세요. 적용하려면 최근 7일 안에 끝난 시험 실행이 필요합니다.", + "generic": "요청이 처리되지 않았습니다." + } } }, "carrier": "통신사", diff --git a/messages/pl.json b/messages/pl.json index 2f1669774..d738d3cb8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8126,6 +8126,48 @@ "started": "Konserwacja uruchomiona.", "alreadyQueued": "Konserwacja jest już w kolejce lub trwa.", "startFailed": "Nie udało się uruchomić konserwacji." + }, + "pulseDayMean": { + "title": "Dzienna średnia tętna", + "description": "Jak godziny z niewieloma pomiarami liczą się do tętna danego dnia.", + "body": "Dotąd każda godzina z pomiarem liczy się tak samo, więc jeden przypadkowy pomiar może przesunąć cały dzień. Metoda ważona sprawia, że godzina liczy się w pełni od trzech pomiarów, a pojedynczy pomiar w jednej trzeciej. Najpierw uruchom próbę, aby zobaczyć, ile dni by się zmieniło. Zastosowanie przelicza zapisane dni tętna w tle.", + "method": { + "hourly": "Każda godzina tak samo", + "weighted": "Rzadkie godziny ważone" + }, + "loading": "Sprawdzanie dziennej średniej tętna…", + "loadError": "Nie udało się odczytać stanu dziennej średniej tętna.", + "state": { + "idle": "Jeszcze nie było próby.", + "queued": "Próba czeka na proces w tle.", + "running": "Próba trwa.", + "done": "Próba zakończona {at}.", + "failed": "Próba została przerwana. Dziennik aplikacji podaje przyczynę; uruchom ją ponownie." + }, + "result": { + "days": "Porównane dni", + "changed": "Dni ze zmianą powyżej 1 bpm", + "max": "Największa zmiana", + "maxValue": "{value} bpm", + "bucket": "{range} bpm", + "account": "Konto {account}", + "accountDays": "Zmienia się {changed} z {days} dni" + }, + "appliedAt": "Metoda ważona zastosowana {at}.", + "dryRun": "Próba", + "apply": "Zastosuj", + "confirmTitle": "Zastosować ważoną dzienną średnią tętna?", + "confirmBody": "Od tej chwili dni tętna wszystkich kont są liczone z ważonymi rzadkimi godzinami. Zapisane dni tętna są przeliczane w tle, co na dużej instancji może chwilę potrwać.", + "confirm": "Zastosuj", + "cancel": "Anuluj", + "dryRunStarted": "Próba uruchomiona.", + "applied": "Metoda ważona zastosowana. Dni tętna są przeliczane.", + "appliedNoRebuild": "Metoda ważona zastosowana. Inny przebieg wciąż trwa; zastosuj ponownie, gdy się zakończy, aby przeliczyć dni tętna.", + "error": { + "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." + } } }, "carrier": "Operator", diff --git a/src/__tests__/dense-measurement-read-guard.test.ts b/src/__tests__/dense-measurement-read-guard.test.ts index fa81848b6..eb46135e0 100644 --- a/src/__tests__/dense-measurement-read-guard.test.ts +++ b/src/__tests__/dense-measurement-read-guard.test.ts @@ -127,6 +127,8 @@ const ALLOWED_SQL: Record = { "Canonical-source blood pressure and weight over 90 days: sparse types, a few rows a day.", "lib/measurements/reconcile-external-measurement.ts::1": "Row locks (`FOR UPDATE`) on an id list the caller already read; returns those ids only.", + "lib/jobs/pulse-day-mean.ts::1": + "Selects one row per account; `measurements` is read only inside the MIN / MAX scalar subqueries of each row.", "lib/jobs/pr-detection.ts::1": "Selects account ids; `measurements` is read inside `EXISTS`, one row per account at most.", "lib/rollups/measurement-coverage.ts::1": diff --git a/src/__tests__/error-level-discipline-guard.test.ts b/src/__tests__/error-level-discipline-guard.test.ts index bd1b96be9..027fe7e17 100644 --- a/src/__tests__/error-level-discipline-guard.test.ts +++ b/src/__tests__/error-level-discipline-guard.test.ts @@ -201,6 +201,11 @@ const ALLOWED: Readonly> = { reason: "A size probe for an admin card: null shows no figure, and the status read must not fail over it.", }, + "lib/jobs/pulse-day-mean.ts::isPulseDayMeanRunActive": { + count: 1, + reason: + "No pgboss schema on a web-only deployment, so no run can be active; the route answers 503 there before it asks.", + }, "lib/jobs/job-observer.ts::emitJobLine": { count: 1, reason: diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 72619b731..2ef7b29f8 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -107,6 +107,11 @@ const PINNED_AFFORDANCES: Record< "src/components/admin/measurement-maintenance-card.tsx": { "toast.success": 1, }, + // Fires only on a 202: the dry run was queued, or the method was applied. + // The dry run's outcome itself is the status line. + "src/components/admin/pulse-day-mean-card.tsx": { + "toast.success": 2, + }, "src/components/admin/invite-tokens-section.tsx": { "text-success": 2, "toast.success": 3, diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx index 6579e9ca8..942d7a770 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -41,6 +41,7 @@ import { SupportReportCard } from "@/components/admin/support-report-card"; import { RevealWhenSettled } from "@/components/ui/reveal-when-settled"; import { Skeleton } from "@/components/ui/skeleton"; import { MeasurementMaintenanceCard } from "@/components/admin/measurement-maintenance-card"; +import { PulseDayMeanCard } from "@/components/admin/pulse-day-mean-card"; import { InviteTokensSection } from "@/components/admin/invite-tokens-section"; import { UserManagementSection } from "@/components/admin/user-management-section"; import type { AdminSectionSlug } from "@/components/admin/section-slugs"; @@ -75,6 +76,8 @@ export function AdminSectionRenderer({ {/* v1.42 — the table maintenance after the tombstone purge. */} + {/* v1.43 — the pulse day-mean method, dry run before apply. */} + ); 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 new file mode 100644 index 000000000..5ea92d747 --- /dev/null +++ b/src/app/api/admin/maintenance/pulse-day-mean/__tests__/route.test.ts @@ -0,0 +1,275 @@ +/** + * `GET/POST /api/admin/maintenance/pulse-day-mean` — the pulse day-mean + * method's dry run and apply step. Admin only; apply needs a finished dry run + * from the last seven days, and nothing starts while a run is active. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + $queryRaw: vi.fn(), + appSettings: { findUnique: vi.fn(), upsert: vi.fn() }, + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() })); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, POST } from "../route"; +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { getSession } from "@/lib/auth/session"; +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { + activePulseDayMeanMethod, + setActivePulseDayMeanMethod, +} from "@/lib/measurements/day-statistic"; + +const ADMIN = { + session: { id: "s-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "admin-1", username: "admin", role: "ADMIN" as const }, +}; +const USER = { + session: { id: "s-2", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "bob", role: "USER" as const }, +}; + +const URL = "http://localhost/api/admin/maintenance/pulse-day-mean"; +const get = () => + (GET as unknown as (r: NextRequest) => Promise)( + new NextRequest(URL), + ); +const post = (body: unknown) => + (POST as unknown as (r: NextRequest) => Promise)( + new NextRequest(URL, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); + +const NOW = new Date("2026-10-16T12:00:00Z"); +const send = vi.fn(); + +function settings(dryRun: unknown, method = "hour_mean_v1") { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + pulseDayMeanMethod: method, + pulseDayMeanAppliedAt: null, + pulseDayMeanDryRunJson: dryRun, + } as never); +} + +function activeJobs(n: number) { + vi.mocked(prisma.$queryRaw).mockResolvedValue([{ n }] as never); +} + +const RESULT = { + accounts: 1, + daysCompared: 30, + daysChangedOver1Bpm: 2, + maxChangeBpm: 3.4, + distribution: [ + { bucket: "0-1", days: 28 }, + { bucket: "1-2", days: 1 }, + { bucket: "2-5", days: 1 }, + { bucket: "5+", days: 0 }, + ], + perAccount: [{ account: "#1", days: 30, changedOver1Bpm: 2 }], +}; + +function doneAt(iso: string) { + return { + status: "done", + startedAt: iso, + finishedAt: iso, + result: RESULT, + }; +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); + setActivePulseDayMeanMethod("hour_mean_v1"); + vi.mocked(getSession).mockResolvedValue(ADMIN as never); + vi.mocked(getGlobalBoss).mockReturnValue({ send } as never); + vi.mocked(prisma.appSettings.upsert).mockResolvedValue({} as never); + send.mockResolvedValue("job-1"); + activeJobs(0); +}); + +afterEach(() => { + vi.useRealTimers(); + setActivePulseDayMeanMethod("hour_mean_v1"); +}); + +describe("admin only", () => { + it("refuses anyone but an admin, before reading anything", async () => { + vi.mocked(getSession).mockResolvedValue(USER as never); + expect((await get()).status).toBe(403); + expect((await post({ action: "dry_run" })).status).toBe(403); + expect(prisma.appSettings.findUnique).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); + }); +}); + +describe("GET", () => { + it("answers the method and the last dry run", async () => { + settings(doneAt("2026-10-15T08:00:00.000Z")); + const body = await (await get()).json(); + expect(body.data).toEqual({ + method: "hour_mean_v1", + appliedAt: null, + lastDryRun: doneAt("2026-10-15T08:00:00.000Z"), + }); + }); + + it("reads a run marked running with no live job behind it as failed", async () => { + settings({ + status: "running", + startedAt: "2026-10-16T11:00:00.000Z", + finishedAt: null, + result: null, + }); + activeJobs(0); + const body = await (await get()).json(); + expect(body.data.lastDryRun.status).toBe("failed"); + }); + + it("drops a stored value of the wrong shape instead of passing it on", async () => { + settings({ status: "done", userId: "u-1" }); + const body = await (await get()).json(); + expect(body.data.lastDryRun).toBeNull(); + }); +}); + +describe("POST dry_run", () => { + it("marks the dry run queued and enqueues it", async () => { + settings(null); + const res = await post({ action: "dry_run" }); + expect(res.status).toBe(202); + expect((await res.json()).data).toEqual({ enqueued: true }); + expect(send).toHaveBeenCalledWith( + "pulse-day-mean", + { action: "dry_run" }, + expect.objectContaining({ retryLimit: 0, singletonKey: "pulse-day-mean" }), + ); + expect(prisma.appSettings.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + update: { + pulseDayMeanDryRunJson: expect.objectContaining({ status: "queued" }), + }, + }), + ); + }); + + it("answers 409 busy while a run is active, and starts nothing", async () => { + activeJobs(1); + const res = await post({ action: "dry_run" }); + expect(res.status).toBe(409); + expect((await res.json()).meta.errorCode).toBe( + "maintenance.pulseDayMean.busy", + ); + expect(send).not.toHaveBeenCalled(); + expect(prisma.appSettings.upsert).not.toHaveBeenCalled(); + }); + + it("puts the last dry run back when the send loses the singleton", async () => { + const previous = doneAt("2026-10-15T08:00:00.000Z"); + settings(previous); + send.mockResolvedValue(null); + 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 } }), + ); + }); +}); + +describe("POST apply", () => { + it("is refused without a dry run", async () => { + settings(null); + const res = await post({ action: "apply" }); + expect(res.status).toBe(409); + expect((await res.json()).meta.errorCode).toBe( + "maintenance.pulseDayMean.dryRunRequired", + ); + expect(prisma.appSettings.upsert).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); + }); + + it("is refused with a dry run older than seven days", async () => { + settings(doneAt("2026-10-09T11:59:00.000Z")); + expect((await post({ action: "apply" })).status).toBe(409); + }); + + it("is refused with a dry run that failed", async () => { + settings({ ...doneAt("2026-10-15T08:00:00.000Z"), status: "failed" }); + expect((await post({ action: "apply" })).status).toBe(409); + }); + + it("is refused 409 busy while a run is active", async () => { + settings(doneAt("2026-10-15T08:00:00.000Z")); + activeJobs(1); + const res = await post({ action: "apply" }); + expect((await res.json()).meta.errorCode).toBe( + "maintenance.pulseDayMean.busy", + ); + }); + + it("switches the method, enqueues the rebuild and audits it", async () => { + settings(doneAt("2026-10-15T08:00:00.000Z")); + const res = await post({ action: "apply" }); + expect(res.status).toBe(202); + expect((await res.json()).data).toEqual({ enqueued: true }); + expect(prisma.appSettings.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + update: { + pulseDayMeanMethod: "weighted_hour_mean_v2", + pulseDayMeanAppliedAt: NOW, + }, + }), + ); + expect(send).toHaveBeenCalledWith( + "pulse-day-mean", + { action: "rebuild" }, + expect.anything(), + ); + expect(activePulseDayMeanMethod()).toBe("weighted_hour_mean_v2"); + expect(auditLog).toHaveBeenCalledWith( + "admin.pulseDayMean.apply", + expect.objectContaining({ + userId: "admin-1", + details: { method: "weighted_hour_mean_v2", rebuildEnqueued: true }, + }), + ); + }); +}); + +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(send).not.toHaveBeenCalled(); + }); + + it("answers 503 without a background worker", async () => { + vi.mocked(getGlobalBoss).mockReturnValue(null as never); + expect((await post({ action: "dry_run" })).status).toBe(503); + }); +}); 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 e13c592e4..c94e34f50 100644 --- a/src/app/api/admin/maintenance/pulse-day-mean/route.ts +++ b/src/app/api/admin/maintenance/pulse-day-mean/route.ts @@ -26,7 +26,6 @@ import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { - hasFreshDryRun, isPulseDayMeanRunActive, PULSE_DAY_MEAN_QUEUE, PULSE_DAY_MEAN_SEND_OPTIONS, @@ -39,7 +38,10 @@ import { setActivePulseDayMeanMethod, type PulseDayMeanMethod, } from "@/lib/measurements/day-statistic"; -import { pulseDayMeanActionSchema } from "@/lib/validations/pulse-day-mean"; +import { + hasFreshDryRun, + pulseDayMeanActionSchema, +} from "@/lib/validations/pulse-day-mean"; export const dynamic = "force-dynamic"; diff --git a/src/components/admin/pulse-day-mean-card.tsx b/src/components/admin/pulse-day-mean-card.tsx new file mode 100644 index 000000000..285fec109 --- /dev/null +++ b/src/components/admin/pulse-day-mean-card.tsx @@ -0,0 +1,306 @@ +"use client"; + +/** + * `` — switch the pulse day mean to weigh sparse hours, with + * a dry run first (#1107 follow-up). + * + * The card shows the active method, starts a dry run, shows what it found + * (how many days would move by more than 1 bpm, the largest move, the spread, + * and the same per account under an ordinal), and applies the new method once + * a dry run from the last seven days has finished. It polls only while a dry + * run is queued or running. The route enforces every rule the buttons mirror. + */ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { FlaskConical, HeartPulse, Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +import { StatTile } from "@/components/admin/_stat-tile"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; +import { showErrorToast } from "@/lib/errors/error-toast"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { + hasFreshDryRun, + type PulseDayMeanActionInput, + type PulseDayMeanStatus, +} from "@/lib/validations/pulse-day-mean"; + +const ENDPOINT = "/api/admin/maintenance/pulse-day-mean"; + +/** How often the card asks while a dry run is waiting or working. */ +const POLL_MS = 10_000; + +export function PulseDayMeanCard() { + const { t } = useTranslations(); + const fmt = useFormatters(); + const queryClient = useQueryClient(); + + const status = useQuery({ + queryKey: queryKeys.adminPulseDayMean(), + queryFn: () => + apiGet(ENDPOINT, { credentials: "include" }), + refetchInterval: (query) => { + const state = query.state.data?.lastDryRun?.status; + return state === "queued" || state === "running" ? POLL_MS : false; + }, + }); + + const run = useMutation({ + mutationFn: async ( + action: PulseDayMeanActionInput["action"], + ): Promise<{ action: typeof action; enqueued: boolean }> => { + const res = await apiFetchRaw(ENDPOINT, { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action }), + }); + if (!res.ok) { + let code: string | undefined; + try { + const body = (await res.json()) as { meta?: { errorCode?: string } }; + code = body.meta?.errorCode; + } catch { + // No envelope: the generic message below. + } + // The route's two refusals each have their own message. + throw new Error( + code === "maintenance.pulseDayMean.busy" + ? t("admin.section.pulseDayMean.error.busy") + : code === "maintenance.pulseDayMean.dryRunRequired" + ? t("admin.section.pulseDayMean.error.dryRunRequired") + : t("admin.section.pulseDayMean.error.generic"), + ); + } + const { enqueued } = ( + (await res.json()) as { data: { enqueued: boolean } } + ).data; + return { action, enqueued }; + }, + onSuccess: ({ action, enqueued }) => { + if (action === "apply") { + toast.success( + enqueued + ? t("admin.section.pulseDayMean.applied") + : t("admin.section.pulseDayMean.appliedNoRebuild"), + ); + } else { + toast.success(t("admin.section.pulseDayMean.dryRunStarted")); + } + void queryClient.invalidateQueries({ + queryKey: queryKeys.adminPulseDayMean(), + }); + }, + onError: (err: Error) => { + showErrorToast(err.message, { cause: err }); + }, + }); + + if (status.isError) { + return ( + void status.refetch()} + /> + ); + } + + 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 weighted = s?.method === "weighted_hour_mean_v2"; + const canApply = !!s && !active && hasFreshDryRun(s); + + return ( + + + {weighted + ? t("admin.section.pulseDayMean.method.weighted") + : t("admin.section.pulseDayMean.method.hourly")} + + ) : null + } + /> +

{t("admin.section.pulseDayMean.body")}

+ +
+ {status.isLoading ? ( + + + {t("admin.section.pulseDayMean.loading")} + + ) : active ? ( + + + {dryRun?.status === "queued" + ? t("admin.section.pulseDayMean.state.queued") + : t("admin.section.pulseDayMean.state.running")} + + ) : dryRun?.status === "done" ? ( + + {t("admin.section.pulseDayMean.state.done", { + at: fmt.dateTime(dryRun.finishedAt ?? dryRun.startedAt), + })} + + ) : dryRun?.status === "failed" ? ( + + {t("admin.section.pulseDayMean.state.failed")} + + ) : ( + + {t("admin.section.pulseDayMean.state.idle")} + + )} +
+ + {result ? ( +
+
+ + + +
+
+ {result.distribution.map((d) => ( +
+
+ {t("admin.section.pulseDayMean.result.bucket", { + range: d.bucket, + })} +
+
{fmt.number(d.days)}
+
+ ))} +
+ {result.perAccount.length > 0 ? ( +
    + {result.perAccount.map((a) => ( +
  • + + {t("admin.section.pulseDayMean.result.account", { + account: a.account, + })} + + + {t("admin.section.pulseDayMean.result.accountDays", { + changed: fmt.number(a.changedOver1Bpm), + days: fmt.number(a.days), + })} + +
  • + ))} +
+ ) : null} +
+ ) : null} + + {s?.appliedAt ? ( +

+ {t("admin.section.pulseDayMean.appliedAt", { + at: fmt.dateTime(s.appliedAt), + })} +

+ ) : null} + + + + + + + + + + + {t("admin.section.pulseDayMean.confirmTitle")} + + + {t("admin.section.pulseDayMean.confirmBody")} + + + + + {t("admin.section.pulseDayMean.cancel")} + + run.mutate("apply")}> + {t("admin.section.pulseDayMean.confirm")} + + + + + +
+ ); +} diff --git a/src/lib/jobs/pulse-day-mean.ts b/src/lib/jobs/pulse-day-mean.ts index 3b613434a..b0c3bbcd9 100644 --- a/src/lib/jobs/pulse-day-mean.ts +++ b/src/lib/jobs/pulse-day-mean.ts @@ -63,9 +63,6 @@ export interface PulseDayMeanPayload { action: "dry_run" | "rebuild"; } -/** How old a finished dry run may be for the apply step to accept it. */ -export const DRY_RUN_FRESH_MS = 7 * 24 * 60 * 60 * 1000; - const DAY_MS = 24 * 60 * 60 * 1000; /** Days of one account's pulse a dry-run statement covers. */ const DRY_RUN_WINDOW_DAYS = 90; @@ -131,17 +128,6 @@ export async function readPulseDayMeanStatus(): Promise { }; } -/** A finished dry run from the last seven days, the apply step's condition. */ -export function hasFreshDryRun( - status: PulseDayMeanStatus, - now: Date = new Date(), -): boolean { - const run = status.lastDryRun; - if (!run || run.status !== "done" || run.finishedAt === null) return false; - const finished = Date.parse(run.finishedAt); - return Number.isFinite(finished) && now.getTime() - finished <= DRY_RUN_FRESH_MS; -} - /** The only write a dry run makes. */ export async function writeDryRun(run: PulseDayMeanDryRun): Promise { await prisma.appSettings.upsert({ diff --git a/src/lib/query-keys/admin.ts b/src/lib/query-keys/admin.ts index f3eeda9b7..dbc98647d 100644 --- a/src/lib/query-keys/admin.ts +++ b/src/lib/query-keys/admin.ts @@ -53,6 +53,8 @@ export const adminKeys = { adminEncryptionStatus: () => ["admin", "encryption", "status"] as const, adminMeasurementMaintenance: () => ["admin", "maintenance", "measurements"] as const, + /** v1.43 — the pulse day-mean method, its dry run and apply step. */ + adminPulseDayMean: () => ["admin", "maintenance", "pulse-day-mean"] as const, /** v1.40.0 — the "Back up your encryption key" step (admin + dashboard banner). */ adminEncryptionKeyBackup: () => ["admin", "encryption", "key-backup"] as const, diff --git a/src/lib/validations/pulse-day-mean.ts b/src/lib/validations/pulse-day-mean.ts index 170930d43..46333d154 100644 --- a/src/lib/validations/pulse-day-mean.ts +++ b/src/lib/validations/pulse-day-mean.ts @@ -56,3 +56,22 @@ export interface PulseDayMeanStatus { appliedAt: string | null; lastDryRun: PulseDayMeanDryRun | null; } + +/** How old a finished dry run may be for the apply step to accept it. */ +const DRY_RUN_FRESH_MS = 7 * 24 * 60 * 60 * 1000; + +/** + * A finished dry run from the last seven days: the apply step's condition, + * enforced by the route and mirrored by the admin card's button. + */ +export function hasFreshDryRun( + status: Pick, + now: Date = new Date(), +): boolean { + const run = status.lastDryRun; + if (!run || run.status !== "done" || run.finishedAt === null) return false; + const finished = Date.parse(run.finishedAt); + return ( + Number.isFinite(finished) && now.getTime() - finished <= DRY_RUN_FRESH_MS + ); +}