diff --git a/messages/de.json b/messages/de.json index 861e0a940..206b9d8f2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -911,9 +911,6 @@ "importFileLoaded": "Datei \"{name}\" geladen", "importInvalidJson": "Datei enthält kein gültiges JSON", "importNoArray": "Kein Array gefunden", - "importResult": "{imported} Einnahmen importiert", - "importDuplicatesSkipped": "{count} Duplikate übersprungen", - "importInvalidSkipped": "{count} ungültige Einträge übersprungen", "importFailed": "Import fehlgeschlagen", "importInvalidFormat": "Ungültiges JSON-Format", "requestExample": "Request-Beispiel", @@ -1909,6 +1906,18 @@ "scaleAbsolute": "Absolut", "scalePercentSinceStart": "% seit Start", "scaleToggleLabel": "Diagrammskala" + }, + "import": { + "resultSuccess": "{imported} Einnahmen importiert.", + "resultPartial": "{imported} importiert, {skipped} übersprungen.", + "resultNothing": "Nichts importiert: alle {skipped} Einträge wurden übersprungen.", + "resultEmpty": "Diese Datei enthielt keine Einnahmen.", + "skipGroupOne": "{count} Eintrag übersprungen: {reason}", + "skipGroupFew": "{count} Einträge übersprungen: {reason}", + "skipGroupOther": "{count} Einträge übersprungen: {reason}", + "reasonDuplicateInFile": "ein anderer Eintrag derselben Datei hat dasselbe Datum und dieselbe Uhrzeit", + "reasonAlreadyRecorded": "für dieses Datum und diese Uhrzeit ist bereits eine Einnahme erfasst", + "reasonUnknown": "Grund nicht erfasst" } }, "charts": { @@ -4661,7 +4670,10 @@ "description": "Rufe Wetter für einen vergangenen Zeitraum ab und speichere es, damit ältere Tage in die Korrelationen einfließen. Der Start steht standardmäßig auf dem Gültigkeitsdatum deines Wohnorts; um weiter zurückzureichen, füge zuerst einen Ortszeitraum für diesen Bereich hinzu.", "run": "Nacherfassen" }, - "noResults": "Keine passenden Orte gefunden." + "noResults": "Keine passenden Orte gefunden.", + "fetchFailedOne": "Die letzte Wetteraktualisierung im Hintergrund ist fehlgeschlagen ({when}).", + "fetchFailedFew": "Die letzten {count} Wetteraktualisierungen im Hintergrund sind fehlgeschlagen, zuletzt {when}.", + "fetchFailedOther": "Die letzten {count} Wetteraktualisierungen im Hintergrund sind fehlgeschlagen, zuletzt {when}." } }, "profile": "Profil", @@ -6289,6 +6301,16 @@ "connected": "Gemeinsame KI-Verbindung verknüpft", "disconnected": "Gemeinsame KI-Verbindung entfernt", "error": "Etwas ist schiefgelaufen — bitte erneut versuchen" + }, + "failingJobs": { + "title": "Hintergrundjobs", + "description": "Queues, deren Jobs nach allen Wiederholungen aufgegeben haben.", + "none": "In den letzten {hours} Stunden ist kein Job fehlgeschlagen.", + "unavailable": "Keine Job-Queue lesbar — dieses Deployment läuft ohne Hintergrund-Worker.", + "countOne": "{count} Fehlschlag", + "countFew": "{count} Fehlschläge", + "countOther": "{count} Fehlschläge", + "lastFailed": "Letzter Fehlschlag {when}" } }, "achievements": { diff --git a/messages/en.json b/messages/en.json index 7edc56190..48bfe097b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -911,9 +911,6 @@ "importFileLoaded": "File \"{name}\" loaded", "importInvalidJson": "File does not contain valid JSON", "importNoArray": "No array found", - "importResult": "{imported} intakes imported", - "importDuplicatesSkipped": "{count} duplicates skipped", - "importInvalidSkipped": "{count} invalid entries skipped", "importFailed": "Import failed", "importInvalidFormat": "Invalid JSON format", "requestExample": "Request example", @@ -1909,6 +1906,18 @@ "scaleAbsolute": "Absolute", "scalePercentSinceStart": "% since start", "scaleToggleLabel": "Chart scale" + }, + "import": { + "resultSuccess": "{imported} intakes imported.", + "resultPartial": "{imported} imported, {skipped} skipped.", + "resultNothing": "Nothing was imported: all {skipped} entries were skipped.", + "resultEmpty": "That file carried no intakes.", + "skipGroupOne": "{count} entry skipped: {reason}", + "skipGroupFew": "{count} entries skipped: {reason}", + "skipGroupOther": "{count} entries skipped: {reason}", + "reasonDuplicateInFile": "another entry in the same file has the same date and time", + "reasonAlreadyRecorded": "an intake is already recorded for that date and time", + "reasonUnknown": "reason not recorded" } }, "charts": { @@ -4661,7 +4670,10 @@ "description": "Fetch and store weather for a past date range so older days join the correlations. The start defaults to your home's effective date; to reach further back, add a location period for that range first.", "run": "Backfill" }, - "noResults": "No matching places found." + "noResults": "No matching places found.", + "fetchFailedOne": "The last background weather update failed ({when}).", + "fetchFailedFew": "The last {count} background weather updates failed, most recently {when}.", + "fetchFailedOther": "The last {count} background weather updates failed, most recently {when}." } }, "profile": "Profile", @@ -6289,6 +6301,16 @@ "connected": "Shared AI connection linked", "disconnected": "Shared AI connection removed", "error": "Something went wrong — please try again" + }, + "failingJobs": { + "title": "Background jobs", + "description": "Queues whose jobs gave up after exhausting their retries.", + "none": "No job has failed in the last {hours} hours.", + "unavailable": "No job queue to read — this deployment runs without the background worker.", + "countOne": "{count} failure", + "countFew": "{count} failures", + "countOther": "{count} failures", + "lastFailed": "Last failure {when}" } }, "achievements": { diff --git a/messages/es.json b/messages/es.json index a4c955d17..fcad5dafa 100644 --- a/messages/es.json +++ b/messages/es.json @@ -911,9 +911,6 @@ "importFileLoaded": "Archivo \"{name}\" cargado", "importInvalidJson": "El archivo no contiene un JSON válido", "importNoArray": "No se encontró ningún array", - "importResult": "{imported} tomas importadas", - "importDuplicatesSkipped": "{count} duplicados omitidos", - "importInvalidSkipped": "{count} entradas no válidas omitidas", "importFailed": "Falló la importación", "importInvalidFormat": "Formato JSON no válido", "requestExample": "Ejemplo de solicitud", @@ -1909,6 +1906,18 @@ "scaleAbsolute": "Absoluto", "scalePercentSinceStart": "% desde el inicio", "scaleToggleLabel": "Escala del gráfico" + }, + "import": { + "resultSuccess": "{imported} tomas importadas.", + "resultPartial": "{imported} importadas, {skipped} omitidas.", + "resultNothing": "No se importó nada: se omitieron las {skipped} entradas.", + "resultEmpty": "Ese archivo no contenía ninguna toma.", + "skipGroupOne": "{count} entrada omitida: {reason}", + "skipGroupFew": "{count} entradas omitidas: {reason}", + "skipGroupOther": "{count} entradas omitidas: {reason}", + "reasonDuplicateInFile": "otra entrada del mismo archivo tiene la misma fecha y hora", + "reasonAlreadyRecorded": "ya hay una toma registrada para esa fecha y hora", + "reasonUnknown": "motivo no registrado" } }, "charts": { @@ -4661,7 +4670,10 @@ "description": "Obtén y guarda el tiempo de un rango de fechas pasado para que los días antiguos entren en las correlaciones. El inicio toma por defecto la fecha de vigencia de tu hogar; para llegar más atrás, añade primero un periodo de ubicación para ese rango.", "run": "Rellenar" }, - "noResults": "No se encontraron lugares coincidentes." + "noResults": "No se encontraron lugares coincidentes.", + "fetchFailedOne": "La última actualización meteorológica en segundo plano falló ({when}).", + "fetchFailedFew": "Las últimas {count} actualizaciones meteorológicas en segundo plano fallaron, la más reciente {when}.", + "fetchFailedOther": "Las últimas {count} actualizaciones meteorológicas en segundo plano fallaron, la más reciente {when}." } }, "profile": "Perfil", @@ -6289,6 +6301,16 @@ "connected": "Conexión de IA compartida vinculada", "disconnected": "Conexión de IA compartida eliminada", "error": "Algo salió mal: inténtalo de nuevo" + }, + "failingJobs": { + "title": "Trabajos en segundo plano", + "description": "Colas cuyos trabajos se rindieron tras agotar sus reintentos.", + "none": "Ningún trabajo ha fallado en las últimas {hours} horas.", + "unavailable": "No hay ninguna cola que consultar: este despliegue funciona sin el worker en segundo plano.", + "countOne": "{count} fallo", + "countFew": "{count} fallos", + "countOther": "{count} fallos", + "lastFailed": "Último fallo {when}" } }, "achievements": { diff --git a/messages/fr.json b/messages/fr.json index cd3cc3217..5d2e86ce7 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -911,9 +911,6 @@ "importFileLoaded": "Fichier « {name} » chargé", "importInvalidJson": "Le fichier ne contient pas de JSON valide", "importNoArray": "Aucun tableau trouvé", - "importResult": "{imported} prises importées", - "importDuplicatesSkipped": "{count} doublons ignorés", - "importInvalidSkipped": "{count} entrées non valides ignorées", "importFailed": "Échec de l’importation", "importInvalidFormat": "Format JSON non valide", "requestExample": "Exemple de requête", @@ -1909,6 +1906,18 @@ "scaleAbsolute": "Absolu", "scalePercentSinceStart": "% depuis le début", "scaleToggleLabel": "Échelle du graphique" + }, + "import": { + "resultSuccess": "{imported} prises importées.", + "resultPartial": "{imported} importées, {skipped} ignorées.", + "resultNothing": "Rien n’a été importé : les {skipped} entrées ont été ignorées.", + "resultEmpty": "Ce fichier ne contenait aucune prise.", + "skipGroupOne": "{count} entrée ignorée : {reason}", + "skipGroupFew": "{count} entrées ignorées : {reason}", + "skipGroupOther": "{count} entrées ignorées : {reason}", + "reasonDuplicateInFile": "une autre entrée du même fichier porte les mêmes date et heure", + "reasonAlreadyRecorded": "une prise est déjà enregistrée pour cette date et cette heure", + "reasonUnknown": "motif non enregistré" } }, "charts": { @@ -4661,7 +4670,10 @@ "description": "Récupérez et enregistrez la météo d'une plage de dates passée pour que les jours anciens rejoignent les corrélations. Le début reprend par défaut la date d'effet de votre domicile ; pour remonter plus loin, ajoutez d'abord une période de localisation pour cette plage.", "run": "Récupérer" }, - "noResults": "Aucun lieu correspondant trouvé." + "noResults": "Aucun lieu correspondant trouvé.", + "fetchFailedOne": "La dernière mise à jour météo en arrière-plan a échoué ({when}).", + "fetchFailedFew": "Les {count} dernières mises à jour météo en arrière-plan ont échoué, la plus récente {when}.", + "fetchFailedOther": "Les {count} dernières mises à jour météo en arrière-plan ont échoué, la plus récente {when}." } }, "profile": "Profil", @@ -6289,6 +6301,16 @@ "connected": "Connexion IA partagée reliée", "disconnected": "Connexion IA partagée supprimée", "error": "Une erreur s'est produite — veuillez réessayer" + }, + "failingJobs": { + "title": "Tâches en arrière-plan", + "description": "Files dont les tâches ont abandonné après avoir épuisé leurs tentatives.", + "none": "Aucune tâche n’a échoué au cours des {hours} dernières heures.", + "unavailable": "Aucune file à consulter : ce déploiement fonctionne sans le worker en arrière-plan.", + "countOne": "{count} échec", + "countFew": "{count} échecs", + "countOther": "{count} échecs", + "lastFailed": "Dernier échec {when}" } }, "achievements": { diff --git a/messages/it.json b/messages/it.json index 858cab7f8..d838baa38 100644 --- a/messages/it.json +++ b/messages/it.json @@ -911,9 +911,6 @@ "importFileLoaded": "File \"{name}\" caricato", "importInvalidJson": "Il file non contiene un JSON valido", "importNoArray": "Nessun array trovato", - "importResult": "{imported} assunzioni importate", - "importDuplicatesSkipped": "{count} duplicati ignorati", - "importInvalidSkipped": "{count} voci non valide ignorate", "importFailed": "Importazione non riuscita", "importInvalidFormat": "Formato JSON non valido", "requestExample": "Esempio di richiesta", @@ -1909,6 +1906,18 @@ "scaleAbsolute": "Assoluto", "scalePercentSinceStart": "% dall'inizio", "scaleToggleLabel": "Scala del grafico" + }, + "import": { + "resultSuccess": "{imported} assunzioni importate.", + "resultPartial": "{imported} importate, {skipped} ignorate.", + "resultNothing": "Non è stato importato nulla: tutte le {skipped} voci sono state ignorate.", + "resultEmpty": "Questo file non conteneva assunzioni.", + "skipGroupOne": "{count} voce ignorata: {reason}", + "skipGroupFew": "{count} voci ignorate: {reason}", + "skipGroupOther": "{count} voci ignorate: {reason}", + "reasonDuplicateInFile": "un’altra voce dello stesso file ha la stessa data e la stessa ora", + "reasonAlreadyRecorded": "per quella data e ora è già registrata un’assunzione", + "reasonUnknown": "motivo non registrato" } }, "charts": { @@ -4661,7 +4670,10 @@ "description": "Recupera e salva il meteo di un intervallo di date passato così i giorni più vecchi entrano nelle correlazioni. L'inizio è impostato in modo predefinito sulla data di validità della tua residenza; per andare più indietro, aggiungi prima un periodo di posizione per quell'intervallo.", "run": "Recupera" }, - "noResults": "Nessun luogo corrispondente trovato." + "noResults": "Nessun luogo corrispondente trovato.", + "fetchFailedOne": "L’ultimo aggiornamento meteo in background non è riuscito ({when}).", + "fetchFailedFew": "Gli ultimi {count} aggiornamenti meteo in background non sono riusciti, il più recente {when}.", + "fetchFailedOther": "Gli ultimi {count} aggiornamenti meteo in background non sono riusciti, il più recente {when}." } }, "profile": "Profilo", @@ -6289,6 +6301,16 @@ "connected": "Connessione IA condivisa collegata", "disconnected": "Connessione IA condivisa rimossa", "error": "Qualcosa è andato storto — riprova" + }, + "failingJobs": { + "title": "Processi in background", + "description": "Code i cui processi hanno rinunciato dopo aver esaurito i tentativi.", + "none": "Nessun processo è fallito nelle ultime {hours} ore.", + "unavailable": "Nessuna coda da leggere: questo deployment funziona senza il worker in background.", + "countOne": "{count} errore", + "countFew": "{count} errori", + "countOther": "{count} errori", + "lastFailed": "Ultimo errore {when}" } }, "achievements": { diff --git a/messages/pl.json b/messages/pl.json index 4a8f61594..2fd455604 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -911,9 +911,6 @@ "importFileLoaded": "Plik „{name}” wczytany", "importInvalidJson": "Plik nie zawiera prawidłowego JSON", "importNoArray": "Nie znaleziono tablicy", - "importResult": "{imported} przyjęć zaimportowano", - "importDuplicatesSkipped": "{count} duplikatów pominięto", - "importInvalidSkipped": "{count} nieprawidłowych wpisów pominięto", "importFailed": "Import nie powiódł się", "importInvalidFormat": "Nieprawidłowy format JSON", "requestExample": "Przykład żądania", @@ -1909,6 +1906,18 @@ "scaleAbsolute": "Bezwzględne", "scalePercentSinceStart": "% od początku", "scaleToggleLabel": "Skala wykresu" + }, + "import": { + "resultSuccess": "Zaimportowano przyjęcia: {imported}.", + "resultPartial": "Zaimportowano {imported}, pominięto {skipped}.", + "resultNothing": "Nic nie zaimportowano: pominięto wszystkie wpisy ({skipped}).", + "resultEmpty": "Ten plik nie zawierał żadnych przyjęć.", + "skipGroupOne": "Pominięto {count} wpis: {reason}", + "skipGroupFew": "Pominięto {count} wpisy: {reason}", + "skipGroupOther": "Pominięto {count} wpisów: {reason}", + "reasonDuplicateInFile": "inny wpis w tym samym pliku ma tę samą datę i godzinę", + "reasonAlreadyRecorded": "przyjęcie o tej dacie i godzinie jest już zapisane", + "reasonUnknown": "powód nieodnotowany" } }, "charts": { @@ -4661,7 +4670,10 @@ "description": "Pobierz i zapisz pogodę dla minionego zakresu dat, aby starsze dni weszły do korelacji. Początek domyślnie przyjmuje datę obowiązywania Twojego domu; aby sięgnąć dalej wstecz, najpierw dodaj okres lokalizacji dla tego zakresu.", "run": "Uzupełnij" }, - "noResults": "Nie znaleziono pasujących miejsc." + "noResults": "Nie znaleziono pasujących miejsc.", + "fetchFailedOne": "Ostatnia aktualizacja pogody w tle nie powiodła się ({when}).", + "fetchFailedFew": "Ostatnie {count} aktualizacje pogody w tle nie powiodły się, najnowsza {when}.", + "fetchFailedOther": "Ostatnich {count} aktualizacji pogody w tle nie powiodło się, najnowsza {when}." } }, "profile": "Profil", @@ -6289,6 +6301,16 @@ "connected": "Wspólne połączenie AI podłączone", "disconnected": "Wspólne połączenie AI usunięte", "error": "Coś poszło nie tak — spróbuj ponownie" + }, + "failingJobs": { + "title": "Zadania w tle", + "description": "Kolejki, których zadania poddały się po wyczerpaniu ponowień.", + "none": "W ciągu ostatnich {hours} godzin żadne zadanie nie zawiodło.", + "unavailable": "Brak kolejki zadań do odczytu — to wdrożenie działa bez workera w tle.", + "countOne": "{count} niepowodzenie", + "countFew": "{count} niepowodzenia", + "countOther": "{count} niepowodzeń", + "lastFailed": "Ostatnie niepowodzenie {when}" } }, "achievements": { diff --git a/src/app/api/admin/status/route.ts b/src/app/api/admin/status/route.ts index 15ae3331b..0633e4262 100644 --- a/src/app/api/admin/status/route.ts +++ b/src/app/api/admin/status/route.ts @@ -3,6 +3,10 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { getWorkerStatus } from "@/lib/jobs/worker-status"; +import { + JOB_FAILURE_WINDOW_HOURS, + readFailingQueues, +} from "@/lib/jobs/job-failures"; import { readFileSync } from "node:fs"; // Build timestamp (set at build time via next.config) @@ -57,6 +61,12 @@ export const GET = apiHandler(async () => { appSettings?.webPushVapidSubject, ); const workerStatus = getWorkerStatus(); + // `worker.errors` is one unnamed counter, which is how a queue that failed on + // every run for months could sit behind a number nobody could act on. The + // queue itself knows which job broke and why, so that is what the status + // reports. `null` = no queue schema to ask (web-only deployment); an empty + // array = asked, nothing is failing. + const failingJobs = await readFailingQueues(); return apiSuccess({ version: process.env.npm_package_version ?? "0.1.0", @@ -66,6 +76,10 @@ export const GET = apiHandler(async () => { startTime: START_TIME, database: "connected", worker: workerStatus, + failingJobs: + failingJobs === null + ? null + : { windowHours: JOB_FAILURE_WINDOW_HOURS, queues: failingJobs }, counts: { users: userCount, measurements: measurementCount, diff --git a/src/app/api/environment/route.ts b/src/app/api/environment/route.ts index 2d6e1ceb2..bb447e24b 100644 --- a/src/app/api/environment/route.ts +++ b/src/app/api/environment/route.ts @@ -12,6 +12,8 @@ import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; import { OPEN_METEO_ATTRIBUTION } from "@/lib/environment/open-meteo"; +import { ENVIRONMENT_FETCH_QUEUE } from "@/lib/jobs/environment-fetch"; +import { readQueueFailureForUser } from "@/lib/jobs/job-failures"; export const dynamic = "force-dynamic"; @@ -21,37 +23,43 @@ export const GET = apiHandler(async () => { const gate = await requireModuleEnabled(user.id, "environment"); if (!gate.enabled) return gate.response; - const [profile, travel, contextCount, latest] = await Promise.all([ - prisma.user.findUnique({ - where: { id: user.id }, - select: { - homeLat: true, - homeLon: true, - homeLabel: true, - homeTimezone: true, - homeSince: true, - timezone: true, - }, - }), - prisma.environmentTravelLocation.findMany({ - where: { userId: user.id }, - orderBy: { startDate: "desc" }, - select: { - id: true, - startDate: true, - endDate: true, - lat: true, - lon: true, - label: true, - }, - }), - prisma.environmentContext.count({ where: { userId: user.id } }), - prisma.environmentContext.findFirst({ - where: { userId: user.id }, - orderBy: { date: "desc" }, - select: { date: true, fetchedAt: true }, - }), - ]); + const [profile, travel, contextCount, latest, lastFetchFailure] = + await Promise.all([ + prisma.user.findUnique({ + where: { id: user.id }, + select: { + homeLat: true, + homeLon: true, + homeLabel: true, + homeTimezone: true, + homeSince: true, + timezone: true, + }, + }), + prisma.environmentTravelLocation.findMany({ + where: { userId: user.id }, + orderBy: { startDate: "desc" }, + select: { + id: true, + startDate: true, + endDate: true, + lat: true, + lon: true, + label: true, + }, + }), + prisma.environmentContext.count({ where: { userId: user.id } }), + prisma.environmentContext.findFirst({ + where: { userId: user.id }, + orderBy: { date: "desc" }, + select: { date: true, fetchedAt: true }, + }), + // A day count on its own cannot tell an empty module from a broken one — + // which is exactly how a job that failed on every run since the module + // shipped could render as "0 days recorded" and nothing else. The queue + // knows; the overview asks it. + readQueueFailureForUser(ENVIRONMENT_FETCH_QUEUE, user.id), + ]); const home = profile?.homeLat != null && profile?.homeLon != null @@ -72,6 +80,7 @@ export const GET = apiHandler(async () => { has_home: home !== null, travel_count: travel.length, context_days: contextCount, + last_fetch_failed: lastFetchFailure !== null, }, }); @@ -83,6 +92,10 @@ export const GET = apiHandler(async () => { latestDate: latest?.date ?? null, latestFetchedAt: latest?.fetchedAt?.toISOString() ?? null, }, + // null = the last background runs did not fail (or there is no queue to + // ask). Non-null names the failure so a zero day count is never left to + // stand alone. No error text: that message is written for an operator. + lastFetchFailure, attribution: OPEN_METEO_ATTRIBUTION, }); }); diff --git a/src/app/api/medications/[id]/intake/import/__tests__/fixtures/reported-intake-export.json b/src/app/api/medications/[id]/intake/import/__tests__/fixtures/reported-intake-export.json new file mode 100644 index 000000000..14463721c --- /dev/null +++ b/src/app/api/medications/[id]/intake/import/__tests__/fixtures/reported-intake-export.json @@ -0,0 +1,30 @@ +[ + { "datum": "2026-05-31", "uhrzeit": "08:30:00", "zaehler": 1.0 }, + { "datum": "2026-06-01", "uhrzeit": "08:30:00", "zaehler": 1.0 }, + { "datum": "2026-06-02", "uhrzeit": "08:30:00", "zaehler": 1.0 }, + { "datum": "2026-06-03", "uhrzeit": "08:30:00", "zaehler": 1.0 }, + { "datum": "2026-06-04", "uhrzeit": "08:30:00", "zaehler": 1.0 }, + { "datum": "2026-06-05", "uhrzeit": "08:30:00", "zaehler": 1.0 }, + { "datum": "2026-06-06", "uhrzeit": "08:30:00", "zaehler": 1.0 }, + { "datum": "2026-06-07", "uhrzeit": "08:30:00", "zaehler": 1.0 }, + { "datum": "2026-06-08", "uhrzeit": "08:30:00", "zaehler": 1.0 }, + { "datum": "2026-06-09", "uhrzeit": "08:30:00", "zaehler": 1.0 }, + { "datum": "2026-06-10", "uhrzeit": "08:30:00", "zaehler": 1.0 }, + { "datum": "2026-06-11", "uhrzeit": "08:30:00", "zaehler": 1.0 }, + { "datum": "2026-06-12", "uhrzeit": "08:30:00", "zaehler": 1.0 }, + { "datum": "2026-06-13", "uhrzeit": "08:30:00", "zaehler": 1.0 }, + { "datum": "2026-06-14", "uhrzeit": "08:30:00", "zaehler": 1.0 }, + { "datum": "2026-06-15", "uhrzeit": "08:30:00", "zaehler": 1.0 }, + { "datum": "2026-06-16", "uhrzeit": "08:30:00", "zaehler": 1.0 }, + { "datum": "2026-06-17", "uhrzeit": "08:30:00", "zaehler": 1.0 }, + { "datum": "2026-06-18", "uhrzeit": "08:30:00", "zaehler": 1.0 }, + { "datum": "2026-06-19", "uhrzeit": "08:30:00", "zaehler": 1.0 }, + { "datum": "2026-06-20", "uhrzeit": "08:30:00", "zaehler": 1.0 }, + { "datum": "2026-06-21", "uhrzeit": "08:30:00", "zaehler": 1.0 }, + { "datum": "2026-06-22", "uhrzeit": "08:30:00", "zaehler": 1.0 }, + { "datum": "2026-06-23", "uhrzeit": "08:30:00", "zaehler": 1.0 }, + { "datum": "2026-06-24", "uhrzeit": "08:30:00", "zaehler": 1.0 }, + { "datum": "2026-06-25", "uhrzeit": "08:30:00", "zaehler": 1.0 }, + { "datum": "2026-06-26", "uhrzeit": "08:30:00", "zaehler": 1.0 }, + { "datum": "2026-06-27", "uhrzeit": "08:30:00", "zaehler": 1.0 } +] diff --git a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts index 92d3e92f6..a11c60a78 100644 --- a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts @@ -4,6 +4,9 @@ * The request process validates and persists the complete payload, then * returns a polling handle without performing intake writes itself. */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; @@ -233,14 +236,17 @@ describe("POST /api/medications/[id]/intake/import — durable kickoff", () => { userId: "user-1", medicationId: "m1", status: "queued", + // A legacy `zaehler` on the first row rides along and decides nothing: + // both keys come from the instant, so two different times can never + // collapse onto one key again. payload: { entries: [ { - idempotencyKey: "import-m1-41", + idempotencyKey: `import-m1-${Date.parse("2026-01-01T07:00:00")}`, takenAt: expect.any(String), }, { - idempotencyKey: expect.stringMatching(/^import-m1-\d+$/), + idempotencyKey: `import-m1-${Date.parse("2026-01-01T19:00:00")}`, takenAt: expect.any(String), }, ], @@ -248,7 +254,7 @@ describe("POST /api/medications/[id]/intake/import — durable kickoff", () => { progress: expect.objectContaining({ processed: 0, imported: 0, - skippedDuplicates: 0, + skippedByReason: {}, total: 2, }), }), @@ -412,3 +418,69 @@ describe("POST /api/medications/[id]/intake/import — future bound and limiter" ); }); }); + +describe("POST /api/medications/[id]/intake/import — the reported export (#650)", () => { + // The file attached to the report: 28 doses, one per day, all at 08:30, and + // `"zaehler": 1.0` on every single row — a dose quantity, not a row counter. + // The old key derivation used that field verbatim, so all 28 entries queued + // under `import-m1-1`, one row was written and 27 were reported back as + // duplicates of each other. + const REPORTED_EXPORT = JSON.parse( + readFileSync( + join(__dirname, "fixtures", "reported-intake-export.json"), + "utf8", + ), + ) as Array<{ datum: string; uhrzeit: string; zaehler: number }>; + + it("is the reported shape (a fixture that drifted would prove nothing)", () => { + expect(REPORTED_EXPORT).toHaveLength(28); + expect(new Set(REPORTED_EXPORT.map((row) => row.zaehler)).size).toBe(1); + expect( + new Set(REPORTED_EXPORT.map((row) => `${row.datum}T${row.uhrzeit}`)).size, + ).toBe(28); + }); + + it("queues 28 distinct doses instead of collapsing them onto one key", async () => { + const res = await POST(postReq(REPORTED_EXPORT), ROUTE_CTX); + + expect(res.status).toBe(202); + const created = vi.mocked(prisma.medicationIntakeImportJob.create).mock + .calls[0][0] as unknown as { + data: { + payload: { + entries: Array<{ takenAt: string; idempotencyKey: string }>; + }; + }; + }; + const entries = created.data.payload.entries; + expect(entries).toHaveLength(28); + expect(new Set(entries.map((entry) => entry.idempotencyKey)).size).toBe(28); + expect(new Set(entries.map((entry) => entry.takenAt)).size).toBe(28); + // The key is a function of the instant and nothing else. + for (const [index, entry] of entries.entries()) { + const row = REPORTED_EXPORT[index]; + const instant = Date.parse(`${row.datum}T${row.uhrzeit}`); + expect(entry.idempotencyKey).toBe(`import-m1-${instant}`); + expect(entry.takenAt).toBe(new Date(instant).toISOString()); + } + }); + + it("keys two rows at the same instant identically, whatever else they carry", async () => { + await POST( + postReq([ + { datum: "2026-03-01", uhrzeit: "08:00:00", zaehler: 7 }, + { datum: "2026-03-01", uhrzeit: "08:00:00", zaehler: 9 }, + ]), + ROUTE_CTX, + ); + + const created = vi.mocked(prisma.medicationIntakeImportJob.create).mock + .calls[0][0] as unknown as { + data: { payload: { entries: Array<{ idempotencyKey: string }> } }; + }; + const keys = created.data.payload.entries.map( + (entry) => entry.idempotencyKey, + ); + expect(keys[0]).toBe(keys[1]); + }); +}); diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts index 71ad1f28e..12020d103 100644 --- a/src/app/api/medications/[id]/intake/import/route.ts +++ b/src/app/api/medications/[id]/intake/import/route.ts @@ -21,14 +21,31 @@ import { type MedicationImportProgress, } from "@/lib/jobs/medication-intake-import"; import { annotate } from "@/lib/logging/context"; +import { buildMedicationImportEntries } from "@/lib/medications/intake-import-payload"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { checkRateLimit } from "@/lib/rate-limit"; import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant"; +// The entry shape carried a third field, `zaehler`, and the replay key was +// built from it whenever it was present. That reads as a per-row counter, +// which is what the documented example showed — but the field was optional, +// its uniqueness was never stated and never checked, and an export that puts +// the dose quantity there (1 tablet, on every row) collapsed a month of +// history onto one key: the first row imported, every later one lost the +// `(userId, idempotencyKey)` unique and came back reported as a duplicate of +// a dose it had nothing to do with. +// +// `zaehler` is gone from the contract rather than accepted-and-ignored. A +// payload that still carries it imports unchanged — `z.object` drops +// unrecognised keys — it simply no longer decides anything. The key now comes +// from the instant (`buildMedicationImportEntries`), which is also the grain +// the database already enforces: the live-row unique on +// `(user_id, medication_id, scheduled_for, source)` makes an imported dose +// unique per instant whatever the key says, so a re-import of a file first +// imported under the old key still dedups. const importEntrySchema = z.object({ datum: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), uhrzeit: z.string().regex(/^\d{2}:\d{2}:\d{2}$/), - zaehler: z.union([z.number().int(), z.string()]).optional(), }); function isValidImportTimestamp(datum: string, uhrzeit: string): boolean { @@ -149,19 +166,13 @@ export const POST = apiHandler( const entries = parsed.data; const normalized: MedicationImportPayload = { - entries: entries.map((entry) => { - const takenAt = new Date(`${entry.datum}T${entry.uhrzeit}`); - const idempotencyKey = entry.zaehler - ? `import-${id}-${String(entry.zaehler)}` - : `import-${id}-${takenAt.getTime()}`; - return { takenAt: takenAt.toISOString(), idempotencyKey }; - }), + entries: buildMedicationImportEntries(id, entries), }; const progress: MedicationImportProgress = { processed: 0, total: normalized.entries.length, imported: 0, - skippedDuplicates: 0, + skippedByReason: {}, touchedDays: [], rollupProcessed: 0, }; diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index e7d143c75..8ff11b29b 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -45,6 +45,14 @@ export interface WorkerStatus { errors: number; } +/** One queue with exhausted-retry failures, as `/api/admin/status` reports it. */ +export interface FailingQueue { + queue: string; + failures: number; + lastFailedAt: string; + lastError: string; +} + export interface SystemStatus { version: string; nodeVersion: string; @@ -53,6 +61,13 @@ export interface SystemStatus { startTime: string; database: string; worker: WorkerStatus; + /** + * Queues whose jobs exhausted their retries recently. `null` means the + * deployment has no pg-boss schema to ask (web-only); an empty `queues` + * array means it was asked and nothing is failing. The two are rendered + * differently on purpose — absence is not health. + */ + failingJobs: { windowHours: number; queues: FailingQueue[] } | null; counts: { users: number; measurements: number; diff --git a/src/components/admin/system-status-section.tsx b/src/components/admin/system-status-section.tsx index 053789cc1..74a67d0d8 100644 --- a/src/components/admin/system-status-section.tsx +++ b/src/components/admin/system-status-section.tsx @@ -23,7 +23,12 @@ import { formatDateTime } from "@/lib/format"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; -import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared"; +import { + StatusItem, + usePublicVersion, + useSystemStatus, + type FailingQueue, +} from "./_shared"; // v1.4.16 phase B3: Recharts is ~108 KiB Brotli — defer-load the host // metrics chart so the system-status page only ships it when an admin @@ -43,6 +48,83 @@ const HostMetricsChart = dynamic( }, ); +/** + * The queues whose jobs gave up, named. + * + * Before this, a background job that threw on every single run showed up as one + * digit in an unnamed `errors` counter, and the module it fed rendered an empty + * screen as if empty were the answer. A broken queue has to say its own name, + * so this card is rendered whenever there is anything to report and stays + * silent otherwise — an operator should be able to trust that a quiet card + * means quiet queues, which is why the "cannot ask" case renders its own line + * rather than looking identical to health. + */ +function FailingJobsCard({ + failingJobs, +}: { + failingJobs: { windowHours: number; queues: FailingQueue[] } | null; +}) { + const { t, tCount } = useTranslations(); + + if (failingJobs === null) { + return ( + + +

+ {t("admin.failingJobs.unavailable")} +

+
+ ); + } + + return ( + + + {failingJobs.queues.length === 0 ? ( +

+ {t("admin.failingJobs.none", { hours: failingJobs.windowHours })} +

+ ) : ( + + )} +
+ ); +} + export function SystemStatusSection() { const { t } = useTranslations(); const fmt = useFormatters(); @@ -216,6 +298,7 @@ export function SystemStatusSection() { )} + {status && } ); } diff --git a/src/components/medications/__tests__/intake-import-result.test.tsx b/src/components/medications/__tests__/intake-import-result.test.tsx new file mode 100644 index 000000000..640a7369c --- /dev/null +++ b/src/components/medications/__tests__/intake-import-result.test.tsx @@ -0,0 +1,145 @@ +/** + * #650 — the intake import says what actually happened. + * + * The report was a file of 28 doses where 27 were "skipped" and the dialog + * answered in success tone with a duplicate count, so nothing on screen said + * the history had not landed. The cause is fixed elsewhere; this pins the + * telling, which is what turned a recoverable problem into a silent one. + * + * Project convention is SSR-only component tests (vitest runs `node`; + * `@testing-library/react` is not installed), so the view takes its state as a + * prop and each case is rendered directly. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + IntakeImportResultView, + type IntakeImportResultState, +} from "../intake-import-result"; + +/** `
  • ` elements only — the lucide icon markup carries `` tags. */ +function countListItems(html: string): number { + return html.split("
  • ").length - 1; +} + +function render(state: IntakeImportResultState): string { + return renderToStaticMarkup( + + + , + ); +} + +/** What the reported run would have produced before the key was fixed. */ +const ALL_SKIPPED: IntakeImportResultState = { + kind: "outcome", + imported: 0, + skipped: 27, + skipReasons: [{ reason: "duplicate_in_file", count: 27 }], +}; + +describe(" — an import that wrote nothing", () => { + it("renders as a failure, never a success", () => { + const html = render(ALL_SKIPPED); + expect(html).toContain('data-outcome="failed"'); + expect(html).not.toContain('data-outcome="success"'); + expect(html).toContain("Nothing was imported"); + expect(html).toContain("27"); + }); + + it("names the skip reason once with its count, not once per entry", () => { + const html = render(ALL_SKIPPED); + expect(html).toContain("27 entries skipped"); + expect(html).toContain("same date and time"); + // One list item for one reason — 27 entries must not become 27 lines. + expect(countListItems(html)).toBe(1); + }); + + it("separates the two skip reasons instead of calling both duplicates", () => { + const html = render({ + kind: "outcome", + imported: 4, + skipped: 5, + skipReasons: [ + { reason: "already_recorded", count: 3 }, + { reason: "duplicate_in_file", count: 2 }, + ], + }); + expect(html).toContain("3 entries skipped"); + expect(html).toContain("already recorded"); + expect(html).toContain("2 entries skipped"); + expect(html).toContain("another entry in the same file"); + expect(countListItems(html)).toBe(2); + }); +}); + +describe(" — the other outcomes", () => { + it("renders a mixed run as a warning, not a tick", () => { + const html = render({ + kind: "outcome", + imported: 26, + skipped: 2, + skipReasons: [{ reason: "already_recorded", count: 2 }], + }); + expect(html).toContain('data-outcome="partial"'); + expect(html).toContain("26 imported, 2 skipped"); + }); + + it("renders a clean run as a success", () => { + const html = render({ + kind: "outcome", + imported: 28, + skipped: 0, + skipReasons: [], + }); + expect(html).toContain('data-outcome="success"'); + expect(html).toContain("28 intakes imported"); + expect(countListItems(html)).toBe(0); + }); + + it("gives a file with no intakes its own message rather than either", () => { + const html = render({ + kind: "outcome", + imported: 0, + skipped: 0, + skipReasons: [], + }); + expect(html).toContain('data-outcome="empty"'); + expect(html).not.toContain('data-outcome="success"'); + expect(html).toContain("no intakes"); + }); + + it("falls back to a stated absence for an unrecognised reason", () => { + const html = render({ + kind: "outcome", + imported: 0, + skipped: 1, + skipReasons: [{ reason: "something_new", count: 1 }], + }); + expect(html).toContain("reason not recorded"); + }); +}); + +describe(" — notices are not outcomes", () => { + it("reports a loaded file in neutral tone, not as an import success", () => { + const html = render({ + kind: "notice", + tone: "info", + text: 'File "intakes.json" loaded', + }); + expect(html).toContain('data-tone="info"'); + expect(html).not.toContain("text-success"); + expect(html).not.toContain("data-outcome"); + }); + + it("reports a refusal in destructive tone", () => { + const html = render({ + kind: "notice", + tone: "error", + text: "Import failed", + }); + expect(html).toContain("text-destructive"); + }); +}); diff --git a/src/components/medications/intake-import-dialog.tsx b/src/components/medications/intake-import-dialog.tsx index 6a201bb2b..b9f2c9c57 100644 --- a/src/components/medications/intake-import-dialog.tsx +++ b/src/components/medications/intake-import-dialog.tsx @@ -21,6 +21,11 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Loader2, MoreHorizontal, RotateCcw, Upload } from "lucide-react"; +import { + IntakeImportResultView, + type IntakeImportResultState, +} from "@/components/medications/intake-import-result"; + import { Button } from "@/components/ui/button"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { @@ -51,10 +56,7 @@ export function IntakeImportDialog({ const { t } = useTranslations(); const [jsonText, setJsonText] = useState(""); const [importing, setImporting] = useState(false); - const [result, setResult] = useState(null); - const [resultType, setResultType] = useState<"success" | "error" | null>( - null, - ); + const [result, setResult] = useState(null); const [selectedFileName, setSelectedFileName] = useState(null); const [fileInputKey, setFileInputKey] = useState(0); const queryClient = useQueryClient(); @@ -62,7 +64,6 @@ export function IntakeImportDialog({ function resetImportForm() { setJsonText(""); setResult(null); - setResultType(null); setSelectedFileName(null); setFileInputKey((prev) => prev + 1); } @@ -77,18 +78,25 @@ export function IntakeImportDialog({ if (!file) return; setResult(null); - setResultType(null); try { const content = await file.text(); JSON.parse(content); setJsonText(content); setSelectedFileName(file.name); - setResult(t("medications.importFileLoaded", { name: file.name })); - setResultType("success"); + // A readable file is a step, not an import. It says so in neutral tone — + // the success affordance belongs to a run that actually wrote rows. + setResult({ + kind: "notice", + tone: "info", + text: t("medications.importFileLoaded", { name: file.name }), + }); } catch { - setResult(t("medications.importInvalidJson")); - setResultType("error"); + setResult({ + kind: "notice", + tone: "error", + text: t("medications.importInvalidJson"), + }); } } @@ -96,7 +104,6 @@ export function IntakeImportDialog({ if (!medicationId || !jsonText.trim()) return; setImporting(true); setResult(null); - setResultType(null); try { let data = JSON.parse(jsonText.trim()); @@ -114,37 +121,41 @@ export function IntakeImportDialog({ statusUrl: string; }>(`/api/medications/${medicationId}/intake/import`, data); const d = await waitForMedicationIntakeImport(kickoff.statusUrl); - setResult( - t("medications.importResult", { imported: d.imported }) + - (d.skippedDuplicates > 0 - ? `, ${t("medications.importDuplicatesSkipped", { count: d.skippedDuplicates })}` - : "") + - (d.skippedInvalid > 0 - ? `, ${t("medications.importInvalidSkipped", { count: d.skippedInvalid })}` - : ""), - ); - setResultType("success"); + setResult({ + kind: "outcome", + imported: d.imported, + skipped: d.skipped, + skipReasons: d.skipReasons, + }); // An import can land today-dated doses, so clear the Today hero + // dashboard through the blessed helper (they are unmounted behind the // import dialog on the detail page). void invalidateMedicationReads(queryClient); } catch (err) { if (err instanceof MedicationIntakeImportError) { - setResult(t("medications.importFailed")); - setResultType("error"); + setResult({ + kind: "notice", + tone: "error", + text: t("medications.importFailed"), + }); return; } if (!(err instanceof ApiError)) throw err; - setResult(err.message || t("medications.importFailed")); - setResultType("error"); + setResult({ + kind: "notice", + tone: "error", + text: err.message || t("medications.importFailed"), + }); } } catch (err) { - setResult( - err instanceof SyntaxError - ? t("medications.importInvalidFormat") - : (err as Error).message || t("medications.importFailed"), - ); - setResultType("error"); + setResult({ + kind: "notice", + tone: "error", + text: + err instanceof SyntaxError + ? t("medications.importInvalidFormat") + : (err as Error).message || t("medications.importFailed"), + }); } finally { setImporting(false); } @@ -226,8 +237,8 @@ export function IntakeImportDialog({
               {`[
    -  {"datum": "2026-02-14", "uhrzeit": "10:27:43", "zaehler": 523},
    -  {"datum": "2026-02-14", "uhrzeit": "23:33:42", "zaehler": 524}
    +  {"datum": "2026-02-14", "uhrzeit": "10:27:43"},
    +  {"datum": "2026-02-14", "uhrzeit": "23:33:42"}
     ]`}