From 6911f115bc9a3b5241f755b35167d8073897806c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 23:06:44 +0200 Subject: [PATCH] fix(integrations,labs): let the write decide how a sync result reads Six integration cards and the lab-OCR dialog keyed their tone off `res.ok`. A sync that imported nothing because every reading was refused rendered a green tick and a count, and a re-scan in which every confirmed row turned out to be a duplicate raised a green toast saying zero readings were saved. Each surface now renders the outcome the route resolved: a tick only when everything landed, a warning when some of it did not, an error when none of it did, and a neutral note when there was nothing new. The lab-OCR toast picks its variant the same way. Replaces the directory-scoped import guard with one that covers the whole component tree and pins per-file occurrence counts, so a listed file cannot quietly add a second tick. Its docstring states what it cannot catch. --- messages/de.json | 8 + messages/en.json | 8 + messages/es.json | 8 + messages/fr.json | 8 + messages/it.json | 8 + messages/pl.json | 8 + .../success-affordance-guard.test.ts | 558 ++++++++++++++++++ src/components/labs/ocr-review-dialog.tsx | 22 +- .../__tests__/written-outcome-line.test.tsx | 76 +++ .../__tests__/import-result-guard.test.ts | 138 ----- .../__tests__/sync-outcome.test.ts | 44 ++ .../settings/integrations/fitbit-card.tsx | 55 +- .../integrations/google-health-card.tsx | 55 +- .../settings/integrations/nightscout-card.tsx | 56 +- .../integrations/oauth-provider-card.tsx | 54 +- .../settings/integrations/sync-outcome.ts | 77 +++ .../settings/integrations/whoop-card.tsx | 55 +- .../settings/integrations/withings-card.tsx | 61 +- src/lib/oura/__tests__/sync.test.ts | 1 - src/lib/validations/labs-ocr.ts | 9 + 20 files changed, 1032 insertions(+), 277 deletions(-) create mode 100644 src/__tests__/success-affordance-guard.test.ts create mode 100644 src/components/outcome/__tests__/written-outcome-line.test.tsx delete mode 100644 src/components/settings/import-panel/__tests__/import-result-guard.test.ts create mode 100644 src/components/settings/integrations/__tests__/sync-outcome.test.ts create mode 100644 src/components/settings/integrations/sync-outcome.ts diff --git a/messages/de.json b/messages/de.json index e633cfc99..6e7ec78e3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5148,6 +5148,11 @@ "withingsFullSyncTitle": "Vollsynchronisierung?", "withingsFullSyncDescription": "Alle verfügbaren Withings-Daten werden vollständig synchronisiert. Das kann je nach Historie etwas dauern.", "withingsSyncResult": "{count} Messwerte synchronisiert", + "syncOutcome": { + "empty": "Nichts Neues zu importieren. Alles ist aktuell.", + "failed": "Es wurde nichts gespeichert. Der nächste Lauf versucht es erneut.", + "partial": "{count} Messwerte synchronisiert. Ein Teil der Daten konnte nicht gespeichert werden; der nächste Lauf versucht es erneut." + }, "withingsFullSyncResult": "{count} Messwerte vollständig synchronisiert", "withingsSyncFailed": "Sync fehlgeschlagen", "withingsOauthConnected": "Withings verbunden — der erste Sync startet im Hintergrund.", @@ -7873,6 +7878,9 @@ "saveSelected": "{count} ausgewählte speichern", "discardAll": "Alle verwerfen", "savedToast": "{count} Laborwerte gespeichert", + "savedPartialToast": "{count} Laborwerte gespeichert, {skipped} als Dubletten übersprungen", + "savedNothingToast": "Nichts gespeichert: {skipped} Werte waren bereits erfasst", + "savedEmptyToast": "Nichts zu speichern", "noRows": "In diesem Befund konnten keine Werte gefunden werden.", "nothingToSave": "Wähle mindestens einen Wert zum Speichern aus.", "saveFailed": "Werte konnten nicht gespeichert werden. Bitte erneut versuchen.", diff --git a/messages/en.json b/messages/en.json index d7183b5c9..2cb2ed52b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5148,6 +5148,11 @@ "withingsFullSyncTitle": "Full synchronization?", "withingsFullSyncDescription": "All available Withings data will be fully synchronized. This may take some time depending on your history.", "withingsSyncResult": "{count} measurements synchronized", + "syncOutcome": { + "empty": "Nothing new to import. Everything is up to date.", + "failed": "Nothing was saved. The next run retries it.", + "partial": "{count} measurements synchronized. Some data could not be saved; the next run retries it." + }, "withingsFullSyncResult": "{count} measurements fully synchronized", "withingsSyncFailed": "Sync failed", "withingsOauthConnected": "Withings connected — the first sync starts in the background.", @@ -7873,6 +7878,9 @@ "saveSelected": "Save {count} selected", "discardAll": "Discard all", "savedToast": "{count} lab results saved", + "savedPartialToast": "{count} lab results saved, {skipped} skipped as duplicates", + "savedNothingToast": "Nothing saved: {skipped} readings were already recorded", + "savedEmptyToast": "Nothing to save", "noRows": "No readings could be found in this report.", "nothingToSave": "Select at least one reading to save.", "saveFailed": "Couldn't save the readings. Please try again.", diff --git a/messages/es.json b/messages/es.json index 642747c38..0fdf76133 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5148,6 +5148,11 @@ "withingsFullSyncTitle": "¿Sincronización completa?", "withingsFullSyncDescription": "Se sincronizarán por completo todos los datos disponibles de Withings. Esto puede tardar un poco según tu historial.", "withingsSyncResult": "{count} mediciones sincronizadas", + "syncOutcome": { + "empty": "No hay nada nuevo que importar. Todo está al día.", + "failed": "No se guardó nada. El próximo intento lo repetirá.", + "partial": "{count} mediciones sincronizadas. Parte de los datos no se pudo guardar; el próximo intento lo repetirá." + }, "withingsFullSyncResult": "{count} mediciones sincronizadas por completo", "withingsSyncFailed": "Falló la sincronización", "withingsOauthConnected": "Withings conectado — la primera sincronización comienza en segundo plano.", @@ -7873,6 +7878,9 @@ "saveSelected": "Guardar {count} seleccionados", "discardAll": "Descartar todo", "savedToast": "{count} resultados de laboratorio guardados", + "savedPartialToast": "{count} resultados de laboratorio guardados, {skipped} omitidos por duplicados", + "savedNothingToast": "No se guardó nada: {skipped} valores ya estaban registrados", + "savedEmptyToast": "No hay nada que guardar", "noRows": "No se encontraron valores en este informe.", "nothingToSave": "Selecciona al menos un valor para guardar.", "saveFailed": "No se pudieron guardar los valores. Inténtalo de nuevo.", diff --git a/messages/fr.json b/messages/fr.json index 604b61659..dab63fc75 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5148,6 +5148,11 @@ "withingsFullSyncTitle": "Synchronisation complète ?", "withingsFullSyncDescription": "Toutes les données Withings disponibles seront entièrement synchronisées. Cela peut prendre un certain temps selon votre historique.", "withingsSyncResult": "{count} mesures synchronisées", + "syncOutcome": { + "empty": "Rien de nouveau à importer. Tout est à jour.", + "failed": "Rien n'a été enregistré. La prochaine synchronisation réessaiera.", + "partial": "{count} mesures synchronisées. Une partie des données n'a pas pu être enregistrée ; la prochaine synchronisation réessaiera." + }, "withingsFullSyncResult": "{count} mesures entièrement synchronisées", "withingsSyncFailed": "Échec de la synchronisation", "withingsOauthConnected": "Withings connecté — la première synchronisation démarre en arrière-plan.", @@ -7873,6 +7878,9 @@ "saveSelected": "Enregistrer {count} sélectionnées", "discardAll": "Tout abandonner", "savedToast": "{count} résultats de laboratoire enregistrés", + "savedPartialToast": "{count} résultats de laboratoire enregistrés, {skipped} ignorés car en double", + "savedNothingToast": "Rien enregistré : {skipped} valeurs étaient déjà présentes", + "savedEmptyToast": "Rien à enregistrer", "noRows": "Aucune valeur n'a pu être trouvée dans ce compte rendu.", "nothingToSave": "Sélectionnez au moins une valeur à enregistrer.", "saveFailed": "Impossible d'enregistrer les valeurs. Veuillez réessayer.", diff --git a/messages/it.json b/messages/it.json index 715a74ce6..4967cf325 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5148,6 +5148,11 @@ "withingsFullSyncTitle": "Sincronizzazione completa?", "withingsFullSyncDescription": "Tutti i dati Withings disponibili verranno sincronizzati completamente. Potrebbe richiedere del tempo a seconda del tuo storico.", "withingsSyncResult": "{count} misurazioni sincronizzate", + "syncOutcome": { + "empty": "Non c'è nulla di nuovo da importare. Tutto è aggiornato.", + "failed": "Non è stato salvato nulla. La prossima esecuzione riprova.", + "partial": "{count} misurazioni sincronizzate. Una parte dei dati non è stata salvata; la prossima esecuzione riprova." + }, "withingsFullSyncResult": "{count} misurazioni sincronizzate completamente", "withingsSyncFailed": "Sincronizzazione non riuscita", "withingsOauthConnected": "Withings collegato — la prima sincronizzazione parte in background.", @@ -7873,6 +7878,9 @@ "saveSelected": "Salva {count} selezionati", "discardAll": "Scarta tutto", "savedToast": "{count} risultati di laboratorio salvati", + "savedPartialToast": "{count} risultati di laboratorio salvati, {skipped} ignorati perché duplicati", + "savedNothingToast": "Nulla salvato: {skipped} valori erano già registrati", + "savedEmptyToast": "Nulla da salvare", "noRows": "Nessun valore trovato in questo referto.", "nothingToSave": "Seleziona almeno un valore da salvare.", "saveFailed": "Impossibile salvare i valori. Riprova.", diff --git a/messages/pl.json b/messages/pl.json index 927e9b7e5..273d0af37 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5148,6 +5148,11 @@ "withingsFullSyncTitle": "Pełna synchronizacja?", "withingsFullSyncDescription": "Wszystkie dostępne dane Withings zostaną w pełni zsynchronizowane. Może to chwilę potrwać w zależności od historii.", "withingsSyncResult": "{count} pomiarów zsynchronizowano", + "syncOutcome": { + "empty": "Nie ma nic nowego do zaimportowania. Wszystko jest aktualne.", + "failed": "Nic nie zostało zapisane. Następne uruchomienie spróbuje ponownie.", + "partial": "Zsynchronizowano {count} pomiarów. Części danych nie udało się zapisać; następne uruchomienie spróbuje ponownie." + }, "withingsFullSyncResult": "{count} pomiarów w pełni zsynchronizowano", "withingsSyncFailed": "Synchronizacja nie powiodła się", "withingsOauthConnected": "Withings połączony — pierwsza synchronizacja rozpocznie się w tle.", @@ -7873,6 +7878,9 @@ "saveSelected": "Zapisz {count} wybranych", "discardAll": "Odrzuć wszystko", "savedToast": "Zapisano {count} wyników laboratoryjnych", + "savedPartialToast": "Zapisano {count} wyników laboratoryjnych, {skipped} pominięto jako duplikaty", + "savedNothingToast": "Nic nie zapisano: {skipped} wyników było już zarejestrowanych", + "savedEmptyToast": "Nie ma nic do zapisania", "noRows": "W tym wyniku nie znaleziono żadnych wartości.", "nothingToSave": "Wybierz co najmniej jeden wynik do zapisania.", "saveFailed": "Nie udało się zapisać wyników. Spróbuj ponownie.", diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts new file mode 100644 index 000000000..2bec7a7b9 --- /dev/null +++ b/src/__tests__/success-affordance-guard.test.ts @@ -0,0 +1,558 @@ +/** + * G3 — the success affordance is owned by the outcome module, everywhere. + * + * #640 established the rule inside the import panel: a write that wrote + * nothing must not read as a success. #650 widened it to the medication + * intake import. This widens it to the whole component tree, because the same + * defect had been sitting in seven more surfaces the whole time — six + * integration cards and the lab-OCR dialog reported "success" off `res.ok`, + * so a sync that imported nothing because every reading was refused rendered + * a green tick and a count. + * + * What this asserts: + * 1. Every `CheckCircle2` / `text-success` / `toast.success` occurrence + * under `src/components` is either inside the shared presentation module + * (`src/components/outcome/`) or pinned in `PINNED_AFFORDANCES` with an + * exact per-file count. A name-only allowlist would let a listed file add + * a SECOND, dishonest tick without anyone noticing, which is why the + * counts are pinned rather than the filenames. + * 2. No pinned entry has gone stale — a file that no longer carries the + * marker, or no longer exists, fails here rather than rotting silently. + * This is the guard-of-the-guard: if the scanner ever stopped matching, + * every entry would go stale at once and this is what would say so. + * 3. Every surface that reports a write routes its result line through the + * shared outcome module. + * + * HOW WEAK THIS GUARD IS, stated plainly: this is the weakest guard in the + * release and it is not complete. It matches three literal tokens. It is + * evadable by synonym — `text-emerald-500`, a raw checkmark glyph, an + * `` copy of the icon, `toast(...)` with a success-looking variant, or a + * component that wraps any of them. Nothing here proves a surface is honest; + * it proves a surface did not reach for these three specific affordances + * without being listed. It also says nothing about `src/app`, which is out of + * scope by construction (the counted noise floor was measured over + * `src/components`). Treat a green run as "the known evasions are closed", + * not as "no surface can lie". + * + * Its forced failures, each watched fail before this was committed and then + * reverted: + * - append `toast.success("probe")` to `src/components/ui/alert-dialog.tsx` + * (no entry) -> red on the first case, naming the file; + * - append a second `CheckCircle2` reference to + * `src/components/onboarding/done-screen.tsx` -> red on the second case: + * "CheckCircle2 pinned at 2, found 3"; + * - append a second `text-success` literal to + * `src/components/settings/test-connection-button.tsx`, which is pinned at + * exactly 1 -> red: "text-success pinned at 1, found 2". + */ +import { describe, it, expect } from "vitest"; +import { readFileSync, existsSync, readdirSync, statSync } from "node:fs"; +import { join, posix, relative, sep } from "node:path"; + +const REPO_ROOT = join(__dirname, "..", ".."); +const COMPONENT_ROOT = join(REPO_ROOT, "src", "components"); + +/** The tick component, the success colour token, the success toast. */ +const SUCCESS_MARKERS = [ + "CheckCircle2", + "text-success", + "toast.success", +] as const; + +type SuccessMarker = (typeof SUCCESS_MARKERS)[number]; + +/** + * The one module allowed to reach for the affordance. `WrittenOutcomeLine` + * and `toastWrittenOutcome` bind it to the `success` key of the outcome table + * and nothing else can get at it. + */ +const PRESENTATION_DIR = "src/components/outcome/"; + +/** + * Every OTHER file under `src/components` that mentions a marker, with the + * exact number of times it does. Seeded from the tree as it stands; an entry + * is not an endorsement, it is a record. Reducing one of these to zero is + * always welcome — delete the entry in the same commit. + */ +const PINNED_AFFORDANCES: Record< + string, + Partial> +> = { + "src/components/admin/_shared.tsx": { "text-success": 2, "toast.success": 1 }, + "src/components/admin/ai-server-key-section.tsx": { "toast.success": 1 }, + "src/components/admin/api-token-overview-section.tsx": { "text-success": 1 }, + "src/components/admin/app-log-preview-section.tsx": { + CheckCircle2: 2, + "text-success": 1, + }, + "src/components/admin/assistant-section.tsx": { "toast.success": 1 }, + "src/components/admin/backups-section.tsx": { "toast.success": 4 }, + "src/components/admin/central-codex-section.tsx": { "toast.success": 2 }, + "src/components/admin/danger-zone-section.tsx": { "text-success": 1 }, + "src/components/admin/encryption-section.tsx": { + "text-success": 1, + "toast.success": 1, + }, + "src/components/admin/glitchtip-section.tsx": { "toast.success": 1 }, + "src/components/admin/invite-tokens-section.tsx": { + "text-success": 2, + "toast.success": 3, + }, + "src/components/admin/login-overview-section.tsx": { + CheckCircle2: 2, + "text-success": 2, + "toast.success": 1, + }, + "src/components/admin/module-availability-section.tsx": { + "toast.success": 1, + }, + "src/components/admin/recent-audit-preview.tsx": { + CheckCircle2: 2, + "text-success": 1, + }, + "src/components/admin/reminders-section.tsx": { + CheckCircle2: 2, + "text-success": 4, + "toast.success": 2, + }, + "src/components/admin/system-status-section.tsx": { "text-success": 6 }, + "src/components/admin/system-status-summary.tsx": { "text-success": 3 }, + "src/components/admin/umami-section.tsx": { "toast.success": 1 }, + "src/components/admin/user-management-section.tsx": { + "text-success": 2, + "toast.success": 2, + }, + "src/components/admin/version-tile-section.tsx": { + CheckCircle2: 2, + "text-success": 1, + }, + "src/components/admin/web-push-vapid-section.tsx": { "toast.success": 1 }, + "src/components/charts/medication-compliance-chart.tsx": { + "text-success": 1, + }, + "src/components/custom-metrics/custom-metric-detail.tsx": { + "toast.success": 1, + }, + "src/components/custom-metrics/custom-metric-entry-form.tsx": { + "toast.success": 1, + }, + "src/components/custom-metrics/custom-metric-form.tsx": { + "toast.success": 1, + }, + "src/components/custom-metrics/custom-metric-history-list.tsx": { + "toast.success": 2, + }, + "src/components/cycle/cycle-phase-crosstab.tsx": { "text-success": 1 }, + "src/components/dashboard/medication-intake-quick-add.tsx": { + "toast.success": 1, + }, + "src/components/dashboard/range-display.tsx": { "text-success": 2 }, + "src/components/documents/document-card.tsx": { "text-success": 1 }, + "src/components/documents/document-detail-sheet.tsx": { "toast.success": 2 }, + "src/components/documents/documents-view.tsx": { "toast.success": 3 }, + "src/components/error-details.tsx": { CheckCircle2: 2, "text-success": 1 }, + "src/components/illness/episode-menu.tsx": { + CheckCircle2: 2, + "toast.success": 1, + }, + "src/components/insights/coach-panel/chat-bubble.tsx": { "text-success": 4 }, + "src/components/insights/coach-panel/history-rail.tsx": { + "toast.success": 1, + }, + "src/components/insights/coach-panel/plan-proposal-card.tsx": { + "text-success": 1, + }, + "src/components/insights/coach-panel/reminder-suggestion-card.tsx": { + "text-success": 1, + }, + "src/components/insights/coach-panel/self-context-adopt-offer.tsx": { + "text-success": 1, + }, + "src/components/insights/coach-panel/suggested-action-card.tsx": { + "text-success": 1, + }, + "src/components/insights/confidence-badge.ts": { "text-success": 1 }, + "src/components/insights/daily-briefing.tsx": { "text-success": 1 }, + "src/components/insights/derived/band-tokens.ts": { "text-success": 1 }, + "src/components/insights/health-score-card.tsx": { "text-success": 4 }, + "src/components/insights/insights-edit-mode.tsx": { "toast.success": 2 }, + "src/components/insights/insights-tab-strip.tsx": { "toast.success": 1 }, + "src/components/insights/mood/mood-better-days.tsx": { "text-success": 1 }, + "src/components/insights/mood/mood-factor-metric-crosstab.tsx": { + "text-success": 1, + }, + "src/components/insights/mood/mood-tag-influence.tsx": { "text-success": 1 }, + "src/components/insights/mood/mood-tag-metric-crosstab.tsx": { + "text-success": 1, + }, + "src/components/insights/nutrients/water-quick-add-sheet.tsx": { + "toast.success": 2, + }, + "src/components/insights/personal-record-badge.tsx": { "text-success": 1 }, + "src/components/insights/recommendation-feedback.tsx": { "text-success": 3 }, + "src/components/labs/biomarker-form.tsx": { "toast.success": 1 }, + "src/components/labs/biomarker-manager.tsx": { "toast.success": 2 }, + "src/components/labs/lab-biomarker-detail.tsx": { "toast.success": 1 }, + "src/components/labs/lab-form.tsx": { "toast.success": 1 }, + "src/components/labs/lab-history-list.tsx": { "toast.success": 3 }, + "src/components/measurement-reminders/vorsorge-dashboard-card.tsx": { + CheckCircle2: 2, + }, + "src/components/measurement-reminders/vorsorge-section.tsx": { + CheckCircle2: 2, + "text-success": 1, + }, + "src/components/measurements/measurement-form.tsx": { "toast.success": 1 }, + "src/components/measurements/measurement-list.tsx": { "toast.success": 3 }, + "src/components/medications/card-parts/medication-status-pill.tsx": { + "text-success": 1, + }, + "src/components/medications/dose-history-add-dialog.tsx": { + "toast.success": 1, + }, + "src/components/medications/dose-history-ledger.tsx": { + "text-success": 2, + "toast.success": 4, + }, + "src/components/medications/intake-edit-dialog.tsx": { "toast.success": 1 }, + "src/components/medications/intake-history-editable.tsx": { + "toast.success": 2, + }, + "src/components/medications/intake-history-list-v2.tsx": { + "text-success": 2, + }, + "src/components/medications/research-mode-acknowledgment-dialog.tsx": { + "toast.success": 1, + }, + "src/components/medications/scheduling/schedule-history-timeline.tsx": { + "toast.success": 2, + }, + "src/components/medications/scheduling/schedule-times-editor.tsx": { + "toast.success": 1, + }, + "src/components/medications/sections/api-tokens-row.tsx": { + "toast.success": 1, + }, + "src/components/medications/sections/destructive-zone-section.tsx": { + "toast.success": 4, + }, + "src/components/medications/sections/inventory-dialogs.tsx": { + "toast.success": 3, + }, + "src/components/medications/sections/inventory-section.tsx": { + "toast.success": 1, + }, + "src/components/medications/sections/notifications-section.tsx": { + "toast.success": 1, + }, + "src/components/medications/sections/phase-config-sheet.tsx": { + "toast.success": 1, + }, + "src/components/medications/sections/settings-section.tsx": { + "toast.success": 2, + }, + "src/components/medications/take-all-due.ts": { "toast.success": 1 }, + "src/components/medications/use-medication-intake.ts": { "toast.success": 4 }, + "src/components/medications/wizard/medication-wizard-dialog.tsx": { + "toast.success": 1, + }, + "src/components/mood/manage/archived-tags-card.tsx": { "toast.success": 2 }, + "src/components/mood/manage/tag-editor-sheet.tsx": { "toast.success": 2 }, + "src/components/mood/manage/tag-groups-card.tsx": { "toast.success": 4 }, + "src/components/mood/manage/tag-manager-card.tsx": { "toast.success": 2 }, + "src/components/mood/mood-form.tsx": { "toast.success": 1 }, + "src/components/mood/mood-list.tsx": { "toast.success": 3 }, + "src/components/onboarding/done-screen.tsx": { CheckCircle2: 2 }, + "src/components/onboarding/source-card-grid.tsx": { CheckCircle2: 2 }, + "src/components/records/allergy-form.tsx": { "toast.success": 1 }, + "src/components/records/allergy-manager.tsx": { "toast.success": 1 }, + "src/components/records/conditions-manager.tsx": { "toast.success": 1 }, + "src/components/records/family-history-form.tsx": { "toast.success": 1 }, + "src/components/records/family-history-manager.tsx": { "toast.success": 1 }, + "src/components/settings/_info-tile.tsx": { "text-success": 1 }, + "src/components/settings/about-me-section.tsx": { "toast.success": 1 }, + "src/components/settings/account-section/avatar-section.tsx": { + "text-success": 1, + }, + "src/components/settings/account-section/index.tsx": { "text-success": 2 }, + "src/components/settings/advanced-section.tsx": { "text-success": 2 }, + "src/components/settings/ai/ai-insights-card.tsx": { "text-success": 1 }, + "src/components/settings/ai/anthropic-provider-form.tsx": { + "text-success": 1, + }, + "src/components/settings/ai/codex-provider-form.tsx": { "text-success": 2 }, + "src/components/settings/ai/compat-provider-form.tsx": { "text-success": 1 }, + "src/components/settings/ai/fallback-chain-card.tsx": { "text-success": 1 }, + "src/components/settings/ai/local-provider-form.tsx": { "text-success": 1 }, + "src/components/settings/ai/openai-provider-form.tsx": { "text-success": 1 }, + "src/components/settings/ai/response-timeout-card.tsx": { "text-success": 1 }, + "src/components/settings/ai/runtime-actions-row.tsx": { "text-success": 2 }, + "src/components/settings/api-section.tsx": { "text-success": 3 }, + "src/components/settings/coach-memory-section.tsx": { "toast.success": 2 }, + "src/components/settings/coach-prefs-section.tsx": { "toast.success": 1 }, + "src/components/settings/dashboard-layout-section.tsx": { + "toast.success": 2, + }, + "src/components/settings/email-card.tsx": { "text-success": 1 }, + "src/components/settings/environment-section.tsx": { "toast.success": 4 }, + "src/components/settings/insights-pill-order-section.tsx": { + "toast.success": 1, + }, + "src/components/settings/integration-status-pill.tsx": { + CheckCircle2: 2, + "text-success": 1, + }, + "src/components/settings/integrations/connections-panel.tsx": { + "toast.success": 2, + }, + "src/components/settings/integrations/fitbit-card.tsx": { "text-success": 2 }, + "src/components/settings/integrations/google-health-card.tsx": { + "text-success": 2, + }, + "src/components/settings/integrations/nightscout-card.tsx": { + "text-success": 1, + }, + "src/components/settings/integrations/oauth-provider-card.tsx": { + "text-success": 1, + }, + "src/components/settings/integrations/whoop-card.tsx": { "text-success": 2 }, + "src/components/settings/integrations/withings-card.tsx": { + "text-success": 2, + }, + "src/components/settings/mcp-section.tsx": { + "text-success": 5, + "toast.success": 2, + }, + "src/components/settings/modules-section.tsx": { "toast.success": 1 }, + "src/components/settings/notification-status-card.tsx": { + CheckCircle2: 2, + "text-success": 1, + }, + "src/components/settings/ntfy-card.tsx": { "text-success": 1 }, + "src/components/settings/security-sessions-card.tsx": { "text-success": 1 }, + "src/components/settings/share-link-create-form.tsx": { "text-success": 3 }, + "src/components/settings/sharing-section.tsx": { "text-success": 1 }, + "src/components/settings/sources-section.tsx": { "toast.success": 2 }, + "src/components/settings/telegram-card.tsx": { "text-success": 1 }, + "src/components/settings/test-connection-button.tsx": { + CheckCircle2: 2, + "text-success": 1, + }, + "src/components/settings/thresholds-editor-section.tsx": { + "toast.success": 2, + }, + "src/components/settings/trusted-devices-card.tsx": { "text-success": 1 }, + "src/components/settings/web-push-card.tsx": { "text-success": 1 }, + "src/components/settings/webhook-card.tsx": { "text-success": 1 }, + "src/components/targets/target-edit-sheet.tsx": { "toast.success": 2 }, + "src/components/ui/password-strength.tsx": { "text-success": 2 }, +}; + +/** + * Every surface that reports the result of a write, and the shared module it + * must render that result through. `forbidsMarkers` is set where the surface + * carries no legitimate success affordance of its own, so any marker in the + * file is by definition a hand-rolled verdict. + */ +const RESULT_SURFACES: ReadonlyArray<{ + path: string; + through: RegExp; + forbidsMarkers: boolean; +}> = [ + // The import panel (#640) and the medication intake import (#650). + { + path: "src/components/settings/import-panel/csv-import-card.tsx", + through: /CsvImportResultView|WrittenOutcomeLine/, + forbidsMarkers: true, + }, + { + path: "src/components/settings/import-panel/json-import-card.tsx", + through: /CsvImportResultView|WrittenOutcomeLine/, + forbidsMarkers: true, + }, + { + path: "src/components/settings/import-panel/apple-health-import-card.tsx", + through: /CsvImportResultView|WrittenOutcomeLine/, + forbidsMarkers: true, + }, + { + path: "src/components/settings/import-panel/import-result-view.tsx", + through: /WrittenOutcomeLine/, + forbidsMarkers: true, + }, + { + path: "src/components/medications/intake-import-result.tsx", + through: /WrittenOutcomeLine/, + forbidsMarkers: true, + }, + { + path: "src/components/medications/intake-import-dialog.tsx", + through: /IntakeImportResultView/, + forbidsMarkers: true, + }, + { + path: "src/components/settings/import-panel/dose-history-import-card.tsx", + through: /IntakeImportResultView/, + forbidsMarkers: true, + }, + { + path: "src/components/settings/import-panel/dose-history-verdict.tsx", + through: /IntakeImportSkipGroups/, + forbidsMarkers: true, + }, + // The seven surfaces that were still gated on the transport. + { + path: "src/components/settings/integrations/fitbit-card.tsx", + through: /WrittenOutcomeLine/, + forbidsMarkers: false, + }, + { + path: "src/components/settings/integrations/google-health-card.tsx", + through: /WrittenOutcomeLine/, + forbidsMarkers: false, + }, + { + path: "src/components/settings/integrations/nightscout-card.tsx", + through: /WrittenOutcomeLine/, + forbidsMarkers: false, + }, + { + path: "src/components/settings/integrations/whoop-card.tsx", + through: /WrittenOutcomeLine/, + forbidsMarkers: false, + }, + { + path: "src/components/settings/integrations/withings-card.tsx", + through: /WrittenOutcomeLine/, + forbidsMarkers: false, + }, + { + path: "src/components/settings/integrations/oauth-provider-card.tsx", + through: /WrittenOutcomeLine/, + forbidsMarkers: false, + }, + { + path: "src/components/labs/ocr-review-dialog.tsx", + through: /toastWrittenOutcome/, + forbidsMarkers: true, + }, +]; + +/** Code only — a comment naming a marker is prose, not an affordance. */ +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); +} + +function readCode(relativePath: string): string { + return stripComments(readFileSync(join(REPO_ROOT, relativePath), "utf8")); +} + +/** Every non-test component source file, as a repo-relative posix path. */ +function componentSourceFiles(): string[] { + const found: string[] = []; + const walk = (dir: string) => { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + if (entry === "__tests__") continue; + walk(full); + continue; + } + if (!entry.endsWith(".ts") && !entry.endsWith(".tsx")) continue; + found.push(relative(REPO_ROOT, full).split(sep).join(posix.sep)); + } + }; + walk(COMPONENT_ROOT); + return found.sort(); +} + +function markerCounts( + relativePath: string, +): Partial> { + const source = readCode(relativePath); + const counts: Partial> = {}; + for (const marker of SUCCESS_MARKERS) { + const uses = source.split(marker).length - 1; + if (uses > 0) counts[marker] = uses; + } + return counts; +} + +describe("success affordance — owned by the outcome module, pinned everywhere else", () => { + it("lets no unlisted component reach for the success affordance", () => { + const unlisted = componentSourceFiles() + .filter((path) => !path.startsWith(PRESENTATION_DIR)) + .filter((path) => !(path in PINNED_AFFORDANCES)) + .filter((path) => Object.keys(markerCounts(path)).length > 0); + expect( + unlisted, + unlisted.length > 0 + ? `File(s) hand-rolling a success affordance. Render the result through ` + + `WrittenOutcomeLine / toastWrittenOutcome, or add a pinned entry ` + + `here with a reason in the commit message: ${unlisted.join(", ")}` + : undefined, + ).toEqual([]); + }); + + it("holds every listed file to the exact count it was pinned at", () => { + const drifted: string[] = []; + for (const [path, pinned] of Object.entries(PINNED_AFFORDANCES)) { + if (!existsSync(join(REPO_ROOT, path))) continue; + const actual = markerCounts(path); + for (const marker of SUCCESS_MARKERS) { + const was = pinned[marker] ?? 0; + const now = actual[marker] ?? 0; + if (was !== now) { + drifted.push(`${path}: ${marker} pinned at ${was}, found ${now}`); + } + } + } + expect( + drifted, + drifted.length > 0 + ? `A pinned file changed how often it reaches for the success ` + + `affordance. An ADDED one is the defect this guard exists for; a ` + + `REMOVED one is good news, update the count.\n${drifted.join("\n")}` + : undefined, + ).toEqual([]); + }); + + it("carries no stale entry — the guard-of-the-guard", () => { + const stale = Object.keys(PINNED_AFFORDANCES).filter((path) => { + if (!existsSync(join(REPO_ROOT, path))) return true; + return Object.keys(markerCounts(path)).length === 0; + }); + expect( + stale, + stale.length > 0 + ? `Pinned entr(ies) that no longer carry any marker (or no longer ` + + `exist). Delete them. If EVERY entry is listed here, the scanner ` + + `stopped matching and the guard is inert: ${stale.join(", ")}` + : undefined, + ).toEqual([]); + }); + + it("routes every write-reporting surface through the shared outcome module", () => { + for (const surface of RESULT_SURFACES) { + expect( + readCode(surface.path), + `${surface.path} must render its outcome through the shared module`, + ).toMatch(surface.through); + } + }); + + it("keeps the affordance out of the surfaces that own none of it", () => { + const offenders = RESULT_SURFACES.filter( + (surface) => + surface.forbidsMarkers && + Object.keys(markerCounts(surface.path)).length > 0, + ).map((surface) => surface.path); + expect( + offenders, + offenders.length > 0 + ? `Surface(s) hand-rolling the success affordance instead of ` + + `classifying what was written: ${offenders.join(", ")}` + : undefined, + ).toEqual([]); + }); +}); diff --git a/src/components/labs/ocr-review-dialog.tsx b/src/components/labs/ocr-review-dialog.tsx index fec4a1fcf..64a84bf44 100644 --- a/src/components/labs/ocr-review-dialog.tsx +++ b/src/components/labs/ocr-review-dialog.tsx @@ -14,6 +14,7 @@ import { useId, useMemo, useState } from "react"; import { Loader2, ScanLine, Upload } from "lucide-react"; import { toast } from "sonner"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { Button } from "@/components/ui/button"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { ApiError } from "@/lib/api/api-fetch"; @@ -184,8 +185,25 @@ export function OcrReviewDialog({ { rows: payload, file: pickedFile }, { onSuccess: (result) => { - toast.success( - t("labs.ocr.savedToast", { count: result.inserted.length }), + // The commit re-checks each confirmed row against what is already + // stored, so a re-scan can legitimately write nothing. The toast + // used to be green on any 200 and print the count regardless; the + // server resolves the outcome from the two lists and the dialog + // renders it. + toastWrittenOutcome( + result.outcome, + result.outcome === "success" + ? t("labs.ocr.savedToast", { count: result.inserted.length }) + : result.outcome === "partial" + ? t("labs.ocr.savedPartialToast", { + count: result.inserted.length, + skipped: result.skipped.length, + }) + : result.outcome === "failed" + ? t("labs.ocr.savedNothingToast", { + skipped: result.skipped.length, + }) + : t("labs.ocr.savedEmptyToast"), ); onCommitted(); handleClose(false); diff --git a/src/components/outcome/__tests__/written-outcome-line.test.tsx b/src/components/outcome/__tests__/written-outcome-line.test.tsx new file mode 100644 index 000000000..68cf2791a --- /dev/null +++ b/src/components/outcome/__tests__/written-outcome-line.test.tsx @@ -0,0 +1,76 @@ +/** + * The outcome presentation, pinned per state. + * + * The classifier is proved in `src/lib/outcome/__tests__/written-outcome.test.ts` + * and the structural rule in `src/__tests__/success-affordance-guard.test.ts`. + * This is the middle piece: that the tick and the success colour are attached + * to the `success` state and to no other, and that the toast twin picks the + * matching sonner variant. Project convention is SSR-only component tests, so + * the line is rendered directly. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const toastCalls: Array<{ variant: string; message: string }> = []; +vi.mock("sonner", () => ({ + toast: { + success: (m: string) => toastCalls.push({ variant: "success", message: m }), + warning: (m: string) => toastCalls.push({ variant: "warning", message: m }), + error: (m: string) => toastCalls.push({ variant: "error", message: m }), + info: (m: string) => toastCalls.push({ variant: "info", message: m }), + }, +})); + +import { toastWrittenOutcome } from "../outcome-toast"; +import { WrittenOutcomeLine } from "../written-outcome-line"; +import type { WrittenOutcome } from "@/lib/outcome/written-outcome"; + +function render(outcome: WrittenOutcome) { + return renderToStaticMarkup( + , + ); +} + +describe("WrittenOutcomeLine", () => { + it("carries the success colour only on the success state", () => { + expect(render("success")).toContain("text-success"); + for (const other of ["partial", "failed", "empty"] as const) { + expect(render(other)).not.toContain("text-success"); + } + }); + + it("exposes the outcome as a stable attribute for the tests below the UI", () => { + for (const outcome of [ + "success", + "partial", + "failed", + "empty", + ] as const satisfies readonly WrittenOutcome[]) { + expect(render(outcome)).toContain(`data-outcome="${outcome}"`); + } + }); + + it("alerts on a result that did not fully write, and only reports otherwise", () => { + // A run that saved nothing is the one a user must not scroll past. + expect(render("failed")).toContain('role="alert"'); + expect(render("partial")).toContain('role="alert"'); + expect(render("success")).toContain('role="status"'); + expect(render("empty")).toContain('role="status"'); + }); +}); + +describe("toastWrittenOutcome", () => { + it("raises the success toast only for a success", () => { + toastCalls.length = 0; + toastWrittenOutcome("success", "a"); + toastWrittenOutcome("partial", "b"); + toastWrittenOutcome("failed", "c"); + toastWrittenOutcome("empty", "d"); + expect(toastCalls).toEqual([ + { variant: "success", message: "a" }, + { variant: "warning", message: "b" }, + { variant: "error", message: "c" }, + { variant: "info", message: "d" }, + ]); + }); +}); diff --git a/src/components/settings/import-panel/__tests__/import-result-guard.test.ts b/src/components/settings/import-panel/__tests__/import-result-guard.test.ts deleted file mode 100644 index dcf939e18..000000000 --- a/src/components/settings/import-panel/__tests__/import-result-guard.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * #640 structural guard — "wrote nothing, looked successful" cannot come back. - * - * The classifier property in `import-result-state.test.ts` proves the rule. - * This proves the rule is the only route to the affordance: the success icon - * and the success colour exist in exactly one file in the import panel, on - * exactly one key of the outcome table, and no card reaches for them - * directly. A future card that hand-rolls a green tick fails here rather than - * shipping and lying to a self-hoster about their sensor history. - * - * #650 widened it. The medication intake import is a third import surface, in - * another directory, and it was not covered when the rule landed — it kept its - * own success/error tone and reported a run that wrote one row out of - * twenty-eight as a success. "Every import surface" now means every one of - * them, listed by path, so a fourth surface has to be added here deliberately - * rather than inheriting the old behaviour by being somewhere else. - */ -import { describe, it, expect } from "vitest"; -import { readFileSync, readdirSync } from "node:fs"; -import { join } from "node:path"; - -const PANEL_DIR = join(__dirname, ".."); -const PRESENTATION_FILE = "import-result-view.tsx"; -const REPO_ROOT = join(__dirname, "..", "..", "..", "..", ".."); - -/** - * Every surface that reports an import result, and the module each one must - * render its outcome through. Paths from the repository root. - */ -const IMPORT_SURFACES: ReadonlyArray<{ path: string; through: RegExp }> = [ - { - path: "src/components/settings/import-panel/csv-import-card.tsx", - through: /CsvImportResultView|ImportOutcomeLine/, - }, - { - path: "src/components/settings/import-panel/json-import-card.tsx", - through: /CsvImportResultView|ImportOutcomeLine/, - }, - { - path: "src/components/settings/import-panel/apple-health-import-card.tsx", - through: /CsvImportResultView|ImportOutcomeLine/, - }, - { - path: "src/components/medications/intake-import-result.tsx", - through: /ImportOutcomeLine/, - }, - { - path: "src/components/medications/intake-import-dialog.tsx", - through: /IntakeImportResultView/, - }, - { - path: "src/components/settings/import-panel/dose-history-import-card.tsx", - through: /IntakeImportResultView/, - }, - { - path: "src/components/settings/import-panel/dose-history-verdict.tsx", - through: /IntakeImportSkipGroups/, - }, -]; - -/** The success affordance: the tick component and the success colour token. */ -const SUCCESS_MARKERS = ["CheckCircle2", "text-success"] as const; - -function panelSourceFiles(): string[] { - return readdirSync(PANEL_DIR).filter( - (name) => name.endsWith(".ts") || name.endsWith(".tsx"), - ); -} - -/** Code only — a comment naming the marker is prose, not an affordance. */ -function stripComments(source: string): string { - return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); -} - -function readCode(name: string): string { - return stripComments(readFileSync(join(PANEL_DIR, name), "utf8")); -} - -function readRepoCode(relativePath: string): string { - return stripComments(readFileSync(join(REPO_ROOT, relativePath), "utf8")); -} - -describe("import panel — success affordance is owned by the outcome table", () => { - it("mentions the success markers in the presentation module only", () => { - const offenders = panelSourceFiles() - .filter((name) => name !== PRESENTATION_FILE) - .filter((name) => { - const source = readCode(name); - return SUCCESS_MARKERS.some((marker) => source.includes(marker)); - }); - expect(offenders).toEqual([]); - }); - - it("binds each success marker to the success outcome inside that module", () => { - const source = readCode(PRESENTATION_FILE); - for (const marker of SUCCESS_MARKERS) { - // Once as the lucide import (the tick only), once on the table row. - const uses = source.split(marker).length - 1; - expect(uses).toBe(marker === "CheckCircle2" ? 2 : 1); - } - // The table row itself: `success:` carries both markers, and no other - // outcome key does. - const successRow = source.match(/success:\s*\{[^}]*\}/)?.[0] ?? ""; - expect(successRow).toContain("CheckCircle2"); - expect(successRow).toContain("text-success"); - for (const other of ["partial", "failed", "empty"]) { - const row = source.match(new RegExp(`${other}:\\s*\\{[^}]*\\}`))?.[0]; - expect(row).toBeTruthy(); - for (const marker of SUCCESS_MARKERS) { - expect(row).not.toContain(marker); - } - } - }); - - it("routes every import surface's result line through the shared outcome line", () => { - for (const surface of IMPORT_SURFACES) { - expect( - readRepoCode(surface.path), - `${surface.path} must render its outcome through the shared line`, - ).toMatch(surface.through); - } - }); - - it("keeps the success markers out of every import surface", () => { - const offenders = IMPORT_SURFACES.filter((surface) => - SUCCESS_MARKERS.some((marker) => - readRepoCode(surface.path).includes(marker), - ), - ).map((surface) => surface.path); - expect( - offenders, - offenders.length > 0 - ? `Import surface(s) hand-rolling the success affordance instead of ` + - `classifying the counts: ${offenders.join(", ")}` - : undefined, - ).toEqual([]); - }); -}); diff --git a/src/components/settings/integrations/__tests__/sync-outcome.test.ts b/src/components/settings/integrations/__tests__/sync-outcome.test.ts new file mode 100644 index 000000000..ae0da88d3 --- /dev/null +++ b/src/components/settings/integrations/__tests__/sync-outcome.test.ts @@ -0,0 +1,44 @@ +/** + * What a card is allowed to believe about a sync response. + * + * The cards used to read `res.ok` and print `json.data.imported`. They now + * read the resolved outcome the route computed, and refuse to guess: an + * envelope that does not carry all three fields is treated as a failed run, + * not as a success with a missing count. An unreadable answer is not evidence + * of a write. + */ +import { describe, expect, it } from "vitest"; + +import { readSyncOutcome } from "../sync-outcome"; + +describe("readSyncOutcome", () => { + it("narrows a well-formed envelope", () => { + expect( + readSyncOutcome({ + data: { imported: 0, failed: true, outcome: "failed" }, + error: null, + }), + ).toEqual({ imported: 0, failed: true, outcome: "failed" }); + }); + + it("refuses an envelope that carries only the count", () => { + // The pre-fix shape. A card that accepted it would be back to reporting a + // number with no verdict attached. + expect(readSyncOutcome({ data: { imported: 12 }, error: null })).toBeNull(); + }); + + it("refuses an unknown outcome value", () => { + expect( + readSyncOutcome({ + data: { imported: 1, failed: false, outcome: "ok" }, + error: null, + }), + ).toBeNull(); + }); + + it("refuses an error envelope and anything that is not one at all", () => { + expect(readSyncOutcome({ data: null, error: "boom" })).toBeNull(); + expect(readSyncOutcome(null)).toBeNull(); + expect(readSyncOutcome("nope")).toBeNull(); + }); +}); diff --git a/src/components/settings/integrations/fitbit-card.tsx b/src/components/settings/integrations/fitbit-card.tsx index e6f30f8a8..caa222edc 100644 --- a/src/components/settings/integrations/fitbit-card.tsx +++ b/src/components/settings/integrations/fitbit-card.tsx @@ -42,6 +42,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header"; import { IntegrationStatusPill } from "@/components/settings/integration-status-pill"; import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch"; +import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; import { useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, @@ -56,6 +57,11 @@ import { type IntegrationStatusViewModel, } from "./shared"; import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure"; +import { + readSyncOutcome, + useSyncOutcomeMessage, + type SyncOutcomeState, +} from "./sync-outcome"; import { IntegrationCardDescription, IntegrationRedirectGuide, @@ -67,11 +73,9 @@ export function FitbitCard({ viewModel: IntegrationStatusViewModel | undefined; }) { const { t } = useTranslations(); + const describeSyncOutcome = useSyncOutcomeMessage(); const [syncing, setSyncing] = useState(false); - const [syncMsg, setSyncMsg] = useState(null); - const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>( - null, - ); + const [syncResult, setSyncResult] = useState(null); const [clientId, setClientId] = useState(""); const [clientSecret, setClientSecret] = useState(""); const [credsSaving, setCredsSaving] = useState(false); @@ -116,8 +120,7 @@ export function FitbitCard({ async function handleSync(fullSync = false) { setSyncing(true); - setSyncMsg(null); - setSyncMsgType(null); + setSyncResult(null); try { const res = await apiFetchRaw("/api/fitbit/sync", { method: "POST", @@ -125,23 +128,32 @@ export function FitbitCard({ body: JSON.stringify({ fullSync }), }); const json = await res.json(); - if (res.ok) { - setSyncMsg( - t("settings.fitbitSyncResult", { count: json.data.imported }), - ); - setSyncMsgType("success"); + const result = res.ok ? readSyncOutcome(json) : null; + if (result) { + // The tone comes off what the run wrote, not off `res.ok`. + setSyncResult({ + outcome: result.outcome, + message: describeSyncOutcome( + result, + t("settings.fitbitSyncResult", { count: result.imported }), + ), + }); void invalidateKeys(queryClient, measurementDependentKeys); queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() }); queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus(), }); } else { - setSyncMsg(json.error || t("settings.fitbitSyncFailed")); - setSyncMsgType("error"); + setSyncResult({ + outcome: "failed", + message: json?.error || t("settings.fitbitSyncFailed"), + }); } } catch { - setSyncMsg(t("settings.fitbitSyncFailed")); - setSyncMsgType("error"); + setSyncResult({ + outcome: "failed", + message: t("settings.fitbitSyncFailed"), + }); } finally { setSyncing(false); } @@ -472,13 +484,12 @@ export function FitbitCard({ {t("settings.fitbitBackfillInProgress")}

)} - {syncMsg && ( -

- {syncMsg} -

+ {syncResult && ( + )} {/* connect→data loop: a discreet link to where this provider's readings now surface — doubles as the "your data is richer" diff --git a/src/components/settings/integrations/google-health-card.tsx b/src/components/settings/integrations/google-health-card.tsx index f88d2f591..f6efb5a58 100644 --- a/src/components/settings/integrations/google-health-card.tsx +++ b/src/components/settings/integrations/google-health-card.tsx @@ -50,6 +50,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header"; import { IntegrationStatusPill } from "@/components/settings/integration-status-pill"; import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch"; +import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; import { useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, @@ -64,6 +65,11 @@ import { type IntegrationStatusViewModel, } from "./shared"; import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure"; +import { + readSyncOutcome, + useSyncOutcomeMessage, + type SyncOutcomeState, +} from "./sync-outcome"; import { IntegrationCardDescription } from "./setup-guide-link"; export function GoogleHealthCard({ @@ -72,11 +78,9 @@ export function GoogleHealthCard({ viewModel: IntegrationStatusViewModel | undefined; }) { const { t } = useTranslations(); + const describeSyncOutcome = useSyncOutcomeMessage(); const [syncing, setSyncing] = useState(false); - const [syncMsg, setSyncMsg] = useState(null); - const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>( - null, - ); + const [syncResult, setSyncResult] = useState(null); const [clientId, setClientId] = useState(""); const [clientSecret, setClientSecret] = useState(""); const [credsSaving, setCredsSaving] = useState(false); @@ -120,8 +124,7 @@ export function GoogleHealthCard({ async function handleSync(fullSync = false) { setSyncing(true); - setSyncMsg(null); - setSyncMsgType(null); + setSyncResult(null); try { const res = await apiFetchRaw("/api/google-health/sync", { method: "POST", @@ -129,23 +132,32 @@ export function GoogleHealthCard({ body: JSON.stringify({ fullSync }), }); const json = await res.json(); - if (res.ok) { - setSyncMsg( - t("settings.googleHealthSyncResult", { count: json.data.imported }), - ); - setSyncMsgType("success"); + const result = res.ok ? readSyncOutcome(json) : null; + if (result) { + // The tone comes off what the run wrote, not off `res.ok`. + setSyncResult({ + outcome: result.outcome, + message: describeSyncOutcome( + result, + t("settings.googleHealthSyncResult", { count: result.imported }), + ), + }); void invalidateKeys(queryClient, measurementDependentKeys); queryClient.invalidateQueries({ queryKey: queryKeys.googleHealth() }); queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus(), }); } else { - setSyncMsg(json.error || t("settings.googleHealthSyncFailed")); - setSyncMsgType("error"); + setSyncResult({ + outcome: "failed", + message: json?.error || t("settings.googleHealthSyncFailed"), + }); } } catch { - setSyncMsg(t("settings.googleHealthSyncFailed")); - setSyncMsgType("error"); + setSyncResult({ + outcome: "failed", + message: t("settings.googleHealthSyncFailed"), + }); } finally { setSyncing(false); } @@ -501,13 +513,12 @@ export function GoogleHealthCard({ {t("settings.googleHealthBackfillInProgress")}

)} - {syncMsg && ( -

- {syncMsg} -

+ {syncResult && ( + )} {/* connect→data loop: a discreet link to where this provider's readings now surface — doubles as the "your data is richer" diff --git a/src/components/settings/integrations/nightscout-card.tsx b/src/components/settings/integrations/nightscout-card.tsx index afd9a98b2..aa5e2e0de 100644 --- a/src/components/settings/integrations/nightscout-card.tsx +++ b/src/components/settings/integrations/nightscout-card.tsx @@ -43,6 +43,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header"; import { IntegrationStatusPill } from "@/components/settings/integration-status-pill"; import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch"; +import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; import { useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, @@ -51,6 +52,11 @@ import { } from "@/lib/query-keys"; import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure"; +import { + readSyncOutcome, + useSyncOutcomeMessage, + type SyncOutcomeState, +} from "./sync-outcome"; import { IntegrationErrorMessage, pillStateForVerdict, @@ -65,6 +71,7 @@ export function NightscoutCard({ viewModel?: IntegrationStatusViewModel; }) { const { t } = useTranslations(); + const describeSyncOutcome = useSyncOutcomeMessage(); const queryClient = useQueryClient(); const [url, setUrl] = useState(""); @@ -74,10 +81,7 @@ export function NightscoutCard({ const [msg, setMsg] = useState(null); const [msgType, setMsgType] = useState<"success" | "error" | null>(null); const [syncing, setSyncing] = useState(false); - const [syncMsg, setSyncMsg] = useState(null); - const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>( - null, - ); + const [syncResult, setSyncResult] = useState(null); const status = viewModel; @@ -144,27 +148,35 @@ export function NightscoutCard({ // full-history dialog — Nightscout has no full-sync arm to call. async function handleSync() { setSyncing(true); - setSyncMsg(null); - setSyncMsgType(null); + setSyncResult(null); try { const res = await apiFetchRaw("/api/nightscout/sync", { method: "POST" }); const json = await res.json(); - if (res.ok) { - setSyncMsg( - t("settings.nightscoutSyncResult", { count: json.data.imported }), - ); - setSyncMsgType("success"); + const result = res.ok ? readSyncOutcome(json) : null; + if (result) { + // The tone comes off what the run wrote, not off `res.ok`. + setSyncResult({ + outcome: result.outcome, + message: describeSyncOutcome( + result, + t("settings.nightscoutSyncResult", { count: result.imported }), + ), + }); void invalidateKeys(queryClient, measurementDependentKeys); queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus(), }); } else { - setSyncMsg(json.error || t("settings.nightscoutSyncFailed")); - setSyncMsgType("error"); + setSyncResult({ + outcome: "failed", + message: json?.error || t("settings.nightscoutSyncFailed"), + }); } } catch { - setSyncMsg(t("settings.nightscoutSyncFailed")); - setSyncMsgType("error"); + setSyncResult({ + outcome: "failed", + message: t("settings.nightscoutSyncFailed"), + }); } finally { setSyncing(false); } @@ -401,14 +413,12 @@ export function NightscoutCard({ - {syncMsg && ( -

- {syncMsg} -

+ {syncResult && ( + )} {/* connect→data loop: a discreet link to where the glucose readings now surface — doubles as the "your data is richer" diff --git a/src/components/settings/integrations/oauth-provider-card.tsx b/src/components/settings/integrations/oauth-provider-card.tsx index c0290aeee..e0ed8a5f7 100644 --- a/src/components/settings/integrations/oauth-provider-card.tsx +++ b/src/components/settings/integrations/oauth-provider-card.tsx @@ -41,6 +41,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header"; import { IntegrationStatusPill } from "@/components/settings/integration-status-pill"; import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch"; +import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; import { useTranslations } from "@/lib/i18n/context"; import type { MetricFreshnessEntry, @@ -52,6 +53,11 @@ import { queryKeys, } from "@/lib/query-keys"; import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure"; +import { + readSyncOutcome, + useSyncOutcomeMessage, + type SyncOutcomeState, +} from "./sync-outcome"; import { IntegrationErrorMessage, pillStateForVerdict } from "./shared"; import { IntegrationCardDescription, @@ -116,13 +122,11 @@ export function OAuthProviderCard({ viewModel, }: OAuthProviderCardProps) { const { t } = useTranslations(); + const describeSyncOutcome = useSyncOutcomeMessage(); const queryClient = useQueryClient(); const [msg, setMsg] = useState(null); const [syncing, setSyncing] = useState(false); - const [syncMsg, setSyncMsg] = useState(null); - const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>( - null, - ); + const [syncResult, setSyncResult] = useState(null); const [clientId, setClientId] = useState(""); const [clientSecret, setClientSecret] = useState(""); const [credsSaving, setCredsSaving] = useState(false); @@ -137,28 +141,38 @@ export function OAuthProviderCard({ // full-history dialog — none of these providers has a full-sync arm to call. async function handleSync() { setSyncing(true); - setSyncMsg(null); - setSyncMsgType(null); + setSyncResult(null); try { const res = await apiFetchRaw(`/api/${provider}/sync`, { method: "POST", }); const json = await res.json(); - if (res.ok) { - setSyncMsg(t(`${i18nPrefix}SyncResult`, { count: json.data.imported })); - setSyncMsgType("success"); + const result = res.ok ? readSyncOutcome(json) : null; + if (result) { + // The tone comes off what the run wrote, not off `res.ok`. + setSyncResult({ + outcome: result.outcome, + message: describeSyncOutcome( + result, + t(`${i18nPrefix}SyncResult`, { count: result.imported }), + ), + }); void invalidateKeys(queryClient, measurementDependentKeys); queryClient.invalidateQueries({ queryKey: statusQueryKey }); queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus(), }); } else { - setSyncMsg(json.error || t(`${i18nPrefix}SyncFailed`)); - setSyncMsgType("error"); + setSyncResult({ + outcome: "failed", + message: json?.error || t(`${i18nPrefix}SyncFailed`), + }); } } catch { - setSyncMsg(t(`${i18nPrefix}SyncFailed`)); - setSyncMsgType("error"); + setSyncResult({ + outcome: "failed", + message: t(`${i18nPrefix}SyncFailed`), + }); } finally { setSyncing(false); } @@ -463,14 +477,12 @@ export function OAuthProviderCard({ - {syncMsg && ( -

- {syncMsg} -

+ {syncResult && ( + )} {/* connect→data loop: a discreet link to where this provider's readings now surface — doubles as the "your data is richer" diff --git a/src/components/settings/integrations/sync-outcome.ts b/src/components/settings/integrations/sync-outcome.ts new file mode 100644 index 000000000..3eccbb1cc --- /dev/null +++ b/src/components/settings/integrations/sync-outcome.ts @@ -0,0 +1,77 @@ +"use client"; + +import { useTranslations } from "@/lib/i18n/context"; +import type { WrittenOutcome } from "@/lib/outcome/written-outcome"; + +/** + * What every provider-sync route answers with, and how a card says it. + * + * The cards used to key their tone off `res.ok`: the request came back, so the + * line went green and printed a count. A run that imported nothing because + * every reading was refused looked exactly like a clean one. The route now + * resolves the outcome from what was written, and the card only renders it. + */ +export interface SyncOutcomeResponse { + /** Rows that reached the database this run. */ + imported: number; + /** True when any part of the run did not land. */ + failed: boolean; + /** The resolved verdict — the card never recomputes this. */ + outcome: WrittenOutcome; +} + +/** What a card holds after a sync attempt, transport failures included. */ +export interface SyncOutcomeState { + outcome: WrittenOutcome; + message: string; +} + +/** + * Narrow an envelope body to the resolved outcome, or `null` when the route + * answered something else. A card that gets `null` treats the run as failed + * rather than guessing — an unreadable answer is not evidence of a write. + */ +export function readSyncOutcome(body: unknown): SyncOutcomeResponse | null { + if (typeof body !== "object" || body === null) return null; + const data = (body as { data?: unknown }).data; + if (typeof data !== "object" || data === null) return null; + const { imported, failed, outcome } = data as Record; + if (typeof imported !== "number") return null; + if (typeof failed !== "boolean") return null; + if ( + outcome !== "empty" && + outcome !== "failed" && + outcome !== "partial" && + outcome !== "success" + ) { + return null; + } + return { imported, failed, outcome }; +} + +/** + * Turn a resolved outcome into the sentence a card shows. + * + * The success wording stays per-provider (it names the provider's own count), + * so the caller passes it in; everything else is shared, because "some of it + * did not save" reads the same whoever the provider is. Written as literal + * `t(...)` calls so the i18n call-site coverage guard sees every key. + */ +export function useSyncOutcomeMessage(): ( + result: SyncOutcomeResponse, + successMessage: string, +) => string { + const { t } = useTranslations(); + return (result, successMessage) => { + switch (result.outcome) { + case "success": + return successMessage; + case "empty": + return t("settings.syncOutcome.empty"); + case "partial": + return t("settings.syncOutcome.partial", { count: result.imported }); + case "failed": + return t("settings.syncOutcome.failed"); + } + }; +} diff --git a/src/components/settings/integrations/whoop-card.tsx b/src/components/settings/integrations/whoop-card.tsx index bdd5f4397..8d697411a 100644 --- a/src/components/settings/integrations/whoop-card.tsx +++ b/src/components/settings/integrations/whoop-card.tsx @@ -33,6 +33,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header"; import { IntegrationStatusPill } from "@/components/settings/integration-status-pill"; import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch"; +import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; import { useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, @@ -47,6 +48,11 @@ import { type IntegrationStatusViewModel, } from "./shared"; import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure"; +import { + readSyncOutcome, + useSyncOutcomeMessage, + type SyncOutcomeState, +} from "./sync-outcome"; import { IntegrationCardDescription, IntegrationRedirectGuide, @@ -58,11 +64,9 @@ export function WhoopCard({ viewModel: IntegrationStatusViewModel | undefined; }) { const { t } = useTranslations(); + const describeSyncOutcome = useSyncOutcomeMessage(); const [syncing, setSyncing] = useState(false); - const [syncMsg, setSyncMsg] = useState(null); - const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>( - null, - ); + const [syncResult, setSyncResult] = useState(null); const [clientId, setClientId] = useState(""); const [clientSecret, setClientSecret] = useState(""); const [credsSaving, setCredsSaving] = useState(false); @@ -109,8 +113,7 @@ export function WhoopCard({ async function handleSync(fullSync = false) { setSyncing(true); - setSyncMsg(null); - setSyncMsgType(null); + setSyncResult(null); try { const res = await apiFetchRaw("/api/whoop/sync", { method: "POST", @@ -118,23 +121,32 @@ export function WhoopCard({ body: JSON.stringify({ fullSync }), }); const json = await res.json(); - if (res.ok) { - setSyncMsg( - t("settings.whoopSyncResult", { count: json.data.imported }), - ); - setSyncMsgType("success"); + const result = res.ok ? readSyncOutcome(json) : null; + if (result) { + // The tone comes off what the run wrote, not off `res.ok`. + setSyncResult({ + outcome: result.outcome, + message: describeSyncOutcome( + result, + t("settings.whoopSyncResult", { count: result.imported }), + ), + }); void invalidateKeys(queryClient, measurementDependentKeys); queryClient.invalidateQueries({ queryKey: queryKeys.whoop() }); queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus(), }); } else { - setSyncMsg(json.error || t("settings.whoopSyncFailed")); - setSyncMsgType("error"); + setSyncResult({ + outcome: "failed", + message: json?.error || t("settings.whoopSyncFailed"), + }); } } catch { - setSyncMsg(t("settings.whoopSyncFailed")); - setSyncMsgType("error"); + setSyncResult({ + outcome: "failed", + message: t("settings.whoopSyncFailed"), + }); } finally { setSyncing(false); } @@ -454,13 +466,12 @@ export function WhoopCard({ {t("settings.whoopBackfillInProgress")}

)} - {syncMsg && ( -

- {syncMsg} -

+ {syncResult && ( + )} {/* connect→data loop: a discreet link to where this provider's readings now surface — doubles as the "your data is richer" diff --git a/src/components/settings/integrations/withings-card.tsx b/src/components/settings/integrations/withings-card.tsx index 15f929a85..c3048e72b 100644 --- a/src/components/settings/integrations/withings-card.tsx +++ b/src/components/settings/integrations/withings-card.tsx @@ -32,6 +32,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header"; import { IntegrationStatusPill } from "@/components/settings/integration-status-pill"; import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch"; +import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; import { useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, @@ -46,6 +47,11 @@ import { type IntegrationStatusViewModel, } from "./shared"; import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure"; +import { + readSyncOutcome, + useSyncOutcomeMessage, + type SyncOutcomeState, +} from "./sync-outcome"; import { IntegrationCardDescription, IntegrationRedirectGuide, @@ -57,11 +63,9 @@ export function WithingsCard({ viewModel: IntegrationStatusViewModel | undefined; }) { const { t } = useTranslations(); + const describeSyncOutcome = useSyncOutcomeMessage(); const [syncing, setSyncing] = useState(false); - const [syncMsg, setSyncMsg] = useState(null); - const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>( - null, - ); + const [syncResult, setSyncResult] = useState(null); const [clientId, setClientId] = useState(""); const [clientSecret, setClientSecret] = useState(""); const [credsSaving, setCredsSaving] = useState(false); @@ -111,8 +115,7 @@ export function WithingsCard({ async function handleSync(fullSync = false) { setSyncing(true); - setSyncMsg(null); - setSyncMsgType(null); + setSyncResult(null); try { const res = await apiFetchRaw("/api/withings/sync", { method: "POST", @@ -120,26 +123,33 @@ export function WithingsCard({ body: JSON.stringify({ fullSync }), }); const json = await res.json(); - if (res.ok) { - setSyncMsg( - fullSync - ? t("settings.withingsFullSyncResult", { - count: json.data.imported, - }) - : t("settings.withingsSyncResult", { count: json.data.imported }), - ); - setSyncMsgType("success"); + const result = res.ok ? readSyncOutcome(json) : null; + if (result) { + // The tone comes off what the run wrote, not off `res.ok`. + setSyncResult({ + outcome: result.outcome, + message: describeSyncOutcome( + result, + fullSync + ? t("settings.withingsFullSyncResult", { count: result.imported }) + : t("settings.withingsSyncResult", { count: result.imported }), + ), + }); void invalidateKeys(queryClient, measurementDependentKeys); queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus(), }); } else { - setSyncMsg(json.error || t("settings.withingsSyncFailed")); - setSyncMsgType("error"); + setSyncResult({ + outcome: "failed", + message: json?.error || t("settings.withingsSyncFailed"), + }); } } catch { - setSyncMsg(t("settings.withingsSyncFailed")); - setSyncMsgType("error"); + setSyncResult({ + outcome: "failed", + message: t("settings.withingsSyncFailed"), + }); } finally { setSyncing(false); } @@ -505,13 +515,12 @@ export function WithingsCard({ - {syncMsg && ( -

- {syncMsg} -

+ {syncResult && ( + )} {/* connect→data loop: a discreet link to where this provider's readings now surface — doubles as the "your data is richer" diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index 7851a749a..2c51946a0 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -567,7 +567,6 @@ describe("syncUserOura", () => { // blanked — and the partial rides back with the count, so the card can // report a warning rather than a tick over a run that lost a collection. expect(result).toEqual({ imported: 2, failed: true }); - const imported = result.imported; const written = createdRows().map((row) => row.type); expect(written).toContain("SLEEP_SCORE"); expect(written).toContain("OXYGEN_SATURATION"); diff --git a/src/lib/validations/labs-ocr.ts b/src/lib/validations/labs-ocr.ts index 13eab118d..e9f3ff516 100644 --- a/src/lib/validations/labs-ocr.ts +++ b/src/lib/validations/labs-ocr.ts @@ -18,6 +18,8 @@ */ import { z } from "zod/v4"; +import type { WrittenOutcome } from "@/lib/outcome/written-outcome"; + import { validateEntryInstant } from "@/lib/validations/entry-instant"; /** Max rows a single scan / commit may carry — a dense panel is ~30 analytes. */ @@ -200,6 +202,13 @@ export interface OcrSkippedRowDto { export interface OcrCommitResponseDto { inserted: unknown[]; skipped: OcrSkippedRowDto[]; + /** + * How the commit reads, resolved server-side from the two lists. The dialog + * renders this rather than assuming a 200 means readings were saved — a + * re-scan in which every confirmed row turned out to be a duplicate writes + * nothing and must not report a green toast. + */ + outcome: WrittenOutcome; } /** The capability-probe response (drives whether the UI shows the scan entry). */