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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-27 23:06:44 +02:00
1 parent 530f78dcf5
commit 6911f115bc
20 files changed
+1032 -277

No files matched your search

+8
View File
@@ -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.",
+8
View File
@@ -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.",
+8
View File
@@ -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.",
+8
View File
@@ -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.",
+8
View File
@@ -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.",
+8
View File
@@ -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.",
@@ -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
* `<svg>` 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<Record<SuccessMarker, number>>
> = {
"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<Record<SuccessMarker, number>> {
const source = readCode(relativePath);
const counts: Partial<Record<SuccessMarker, number>> = {};
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([]);
});
});
+20 -2
View File
@@ -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);
@@ -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(
<WrittenOutcomeLine outcome={outcome} message="msg" testId="probe" />,
);
}
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" },
]);
});
});
@@ -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([]);
});
});
@@ -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();
});
});
@@ -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<string | null>(null);
const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
null,
);
const [syncResult, setSyncResult] = useState<SyncOutcomeState | null>(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")}
</p>
)}
{syncMsg && (
<p
role="alert"
className={`text-sm ${syncMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{syncMsg}
</p>
{syncResult && (
<WrittenOutcomeLine
outcome={syncResult.outcome}
message={syncResult.message}
testId="fitbit-sync-result"
/>
)}
{/* connect→data loop: a discreet link to where this provider's
readings now surface — doubles as the "your data is richer"
@@ -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<string | null>(null);
const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
null,
);
const [syncResult, setSyncResult] = useState<SyncOutcomeState | null>(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")}
</p>
)}
{syncMsg && (
<p
role="alert"
className={`text-sm ${syncMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{syncMsg}
</p>
{syncResult && (
<WrittenOutcomeLine
outcome={syncResult.outcome}
message={syncResult.message}
testId="google-health-sync-result"
/>
)}
{/* connect→data loop: a discreet link to where this provider's
readings now surface — doubles as the "your data is richer"
@@ -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<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const [syncing, setSyncing] = useState(false);
const [syncMsg, setSyncMsg] = useState<string | null>(null);
const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
null,
);
const [syncResult, setSyncResult] = useState<SyncOutcomeState | null>(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({
</AlertDialogContent>
</AlertDialog>
</div>
{syncMsg && (
<p
role="alert"
data-testid="nightscout-sync-result"
className={`text-sm ${syncMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{syncMsg}
</p>
{syncResult && (
<WrittenOutcomeLine
outcome={syncResult.outcome}
message={syncResult.message}
testId="nightscout-sync-result"
/>
)}
{/* connect→data loop: a discreet link to where the glucose
readings now surface — doubles as the "your data is richer"
@@ -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<string | null>(null);
const [syncing, setSyncing] = useState(false);
const [syncMsg, setSyncMsg] = useState<string | null>(null);
const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
null,
);
const [syncResult, setSyncResult] = useState<SyncOutcomeState | null>(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({
</AlertDialogContent>
</AlertDialog>
</div>
{syncMsg && (
<p
role="alert"
data-testid={`${provider}-sync-result`}
className={`text-sm ${syncMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{syncMsg}
</p>
{syncResult && (
<WrittenOutcomeLine
outcome={syncResult.outcome}
message={syncResult.message}
testId={`${provider}-sync-result`}
/>
)}
{/* connect→data loop: a discreet link to where this provider's
readings now surface — doubles as the "your data is richer"
@@ -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<string, unknown>;
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");
}
};
}
@@ -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<string | null>(null);
const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
null,
);
const [syncResult, setSyncResult] = useState<SyncOutcomeState | null>(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")}
</p>
)}
{syncMsg && (
<p
role="alert"
className={`text-sm ${syncMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{syncMsg}
</p>
{syncResult && (
<WrittenOutcomeLine
outcome={syncResult.outcome}
message={syncResult.message}
testId="whoop-sync-result"
/>
)}
{/* connect→data loop: a discreet link to where this provider's
readings now surface — doubles as the "your data is richer"
@@ -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<string | null>(null);
const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
null,
);
const [syncResult, setSyncResult] = useState<SyncOutcomeState | null>(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({
</AlertDialogContent>
</AlertDialog>
</div>
{syncMsg && (
<p
role="alert"
className={`text-sm ${syncMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{syncMsg}
</p>
{syncResult && (
<WrittenOutcomeLine
outcome={syncResult.outcome}
message={syncResult.message}
testId="withings-sync-result"
/>
)}
{/* connect→data loop: a discreet link to where this provider's
readings now surface — doubles as the "your data is richer"
-1
View File
@@ -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");
+9
View File
@@ -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). */