From f0c5b5ae94106f8fff13428abffd5a4a662d534e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 26 Jul 2026 07:46:26 +0200 Subject: [PATCH] fix(medications): identify an imported dose by when it was taken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The intake import built its replay key from the row's optional `zaehler` field. That reads as a per-row counter — the documented example showed one — but nothing required it to be unique and nothing checked. A reported export carried the dose quantity there instead: 1 tablet, on all 28 rows. Every entry queued under the same key, one dose reached the ledger, and the other 27 came back reported as duplicates of a dose they had nothing to do with. The key is the instant now, and `zaehler` is out of the contract rather than accepted-and-ignored. Nothing is lost by dropping it: the live-row unique on `(user_id, medication_id, scheduled_for, source)` already makes an imported dose unique per instant, so a re-import of a file first imported under the old key still dedups. The telling is the other half. Two different facts were counted as one number: entries of a submission that collide with each other, and doses the ledger already holds. They are separate reasons now, grouped with a count, and the dialog renders them through the same classifier and the same outcome line as the CSV and JSON import cards — so an import that wrote nothing can no longer answer in success tone. That rule shipped for the settings import panel and this surface was not part of it; the guard now lists every import surface by path, so a fourth one has to be added deliberately. `skippedInvalid` is gone: it was hardcoded to zero on every path and reported a capability the importer never had. Alongside it, the other half of the environment report — a scheduled job that fails every run has to say so. `recordError()` increments one unnamed counter, so the admin status page could show a number and not which queue produced it, and the module's own screen showed a zero day count with nothing to distinguish "nothing stored yet" from "every run failed". pg-boss has held the answer the whole time: a job that exhausts its retries keeps its queue name, its error and its timestamp. The admin status now names the failing queues with counts and the last error, the environment overview reports its own account's failed runs, and neither needed a change at any of the thirty `recordError()` call sites — a visibility fix that has to be remembered per call site is the same failure one level up. Refs #650, #646 --- messages/de.json | 30 ++- messages/en.json | 30 ++- messages/es.json | 30 ++- messages/fr.json | 30 ++- messages/it.json | 30 ++- messages/pl.json | 30 ++- src/app/api/admin/status/route.ts | 14 + src/app/api/environment/route.ts | 75 +++--- .../fixtures/reported-intake-export.json | 30 +++ .../intake/import/__tests__/route.test.ts | 78 +++++- .../medications/[id]/intake/import/route.ts | 29 ++- src/components/admin/_shared.tsx | 15 ++ .../admin/system-status-section.tsx | 85 ++++++- .../__tests__/intake-import-result.test.tsx | 145 +++++++++++ .../medications/intake-import-dialog.tsx | 89 ++++--- .../medications/intake-import-result.tsx | 126 +++++++++ .../settings/environment-section.tsx | 23 +- .../__tests__/import-result-guard.test.ts | 75 +++++- .../medication-intake-import.test.ts | 73 +++++- src/lib/jobs/job-failures.ts | 153 +++++++++++ src/lib/jobs/medication-intake-import.ts | 132 ++++++++-- .../__tests__/intake-import-poll.test.ts | 8 +- src/lib/medications/intake-import-payload.ts | 42 +++ src/lib/medications/intake-import-poll.ts | 5 +- tests/integration/job-failures.test.ts | 151 +++++++++++ .../medication-intake-import-job.test.ts | 13 +- ...tion-intake-import-reported-export.test.ts | 239 ++++++++++++++++++ 27 files changed, 1626 insertions(+), 154 deletions(-) create mode 100644 src/app/api/medications/[id]/intake/import/__tests__/fixtures/reported-intake-export.json create mode 100644 src/components/medications/__tests__/intake-import-result.test.tsx create mode 100644 src/components/medications/intake-import-result.tsx create mode 100644 src/lib/jobs/job-failures.ts create mode 100644 src/lib/medications/intake-import-payload.ts create mode 100644 tests/integration/job-failures.test.ts create mode 100644 tests/integration/medication-intake-import-reported-export.test.ts 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 })} +

+ ) : ( +
    + {failingJobs.queues.map((queue) => ( +
  • +

    + {queue.queue}{" "} + + {tCount("admin.failingJobs.count", queue.failures, { + count: queue.failures, + })} + +

    +

    + {t("admin.failingJobs.lastFailed", { + when: formatDateTime(queue.lastFailedAt), + })} +

    + {queue.lastError.length > 0 && ( +

    + {queue.lastError} +

    + )} +
  • + ))} +
+ )} +
+ ); +} + 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"}
     ]`}