diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 58dc9d0be..1013348a3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -29688,11 +29688,22 @@ components: - analyte - reason additionalProperties: false + outcome: + type: string + enum: + - empty + - failed + - partial + - success + description: How the commit reads, resolved from the counts rather than the status code. `success` only when every + confirmed row was written; `partial` when some were skipped; `failed` when nothing was written and at least + one row was skipped; `empty` when there was nothing to write. required: - inserted - skipped + - outcome additionalProperties: false - description: 'The rows written (`source: "OCR"`) and those skipped as commit-time duplicates.' + description: 'The rows written (`source: "OCR"`), those skipped as commit-time duplicates, and the resolved outcome.' OcrCommittedLabResult: type: object properties: 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/app/api/fitbit/sync/route.ts b/src/app/api/fitbit/sync/route.ts index 2f7641fc7..00be8309a 100644 --- a/src/app/api/fitbit/sync/route.ts +++ b/src/app/api/fitbit/sync/route.ts @@ -3,6 +3,7 @@ import { apiSuccess, apiError } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import { syncUserFitbit } from "@/lib/fitbit/sync"; +import { resolveSyncOutcome } from "@/lib/outcome/written-outcome"; import { NextRequest } from "next/server"; /** @@ -58,6 +59,9 @@ export const POST = apiHandler(async (request: NextRequest) => { } } - const { imported } = await syncUserFitbit(user.id, { fullSync }); - return apiSuccess({ imported, fullSync }); + // The verdict is resolved here, not in the card: a run that imported nothing + // because every resource failed is not the same answer as a quiet hour, and + // both used to render as a green tick with a count. + const result = await syncUserFitbit(user.id, { fullSync }); + return apiSuccess({ ...resolveSyncOutcome(result), fullSync }); }); diff --git a/src/app/api/google-health/sync/__tests__/route.test.ts b/src/app/api/google-health/sync/__tests__/route.test.ts index 63b9686ab..e51d4df0d 100644 --- a/src/app/api/google-health/sync/__tests__/route.test.ts +++ b/src/app/api/google-health/sync/__tests__/route.test.ts @@ -56,6 +56,28 @@ describe("POST /api/google-health/sync", () => { expect(envelope.error).toBe("Google Health sync failed"); }); + it("reports a run that failed after writing some resources as a partial", async () => { + // A 502 threw the honest half away: rows DID land, and the card showed the + // generic error instead of "imported N, some data is behind". + vi.mocked(syncUserGoogleHealth).mockResolvedValue({ + imported: 7, + failed: true, + }); + + const response = await POST(request()); + const envelope = (await response.json()) as { + data: { imported: number; failed: boolean; outcome: string }; + error: string | null; + }; + + expect(response.status).toBe(200); + expect(envelope.data).toMatchObject({ + imported: 7, + failed: true, + outcome: "partial", + }); + }); + it("keeps the successful imported-count response unchanged", async () => { vi.mocked(syncUserGoogleHealth).mockResolvedValue({ imported: 7, @@ -70,7 +92,7 @@ describe("POST /api/google-health/sync", () => { expect(response.status).toBe(200); expect(envelope).toEqual({ - data: { imported: 7, fullSync: false }, + data: { imported: 7, failed: false, outcome: "success", fullSync: false }, error: null, }); expect(syncUserGoogleHealth).toHaveBeenCalledWith("u1", { diff --git a/src/app/api/google-health/sync/route.ts b/src/app/api/google-health/sync/route.ts index b504ba329..2cb7b99b3 100644 --- a/src/app/api/google-health/sync/route.ts +++ b/src/app/api/google-health/sync/route.ts @@ -3,6 +3,7 @@ import { apiSuccess, apiError } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import { syncUserGoogleHealth } from "@/lib/google-health/sync"; +import { resolveSyncOutcome } from "@/lib/outcome/written-outcome"; import { NextRequest } from "next/server"; /** @@ -55,11 +56,12 @@ export const POST = apiHandler(async (request: NextRequest) => { } } - const { imported, failed } = await syncUserGoogleHealth(user.id, { - fullSync, - }); - if (failed) { + const result = await syncUserGoogleHealth(user.id, { fullSync }); + // A run that failed AND wrote nothing is an error, as it always was. A run + // that failed after writing some of its resources is a partial, and answering + // 502 for it threw away the honest half of the result. + if (result.failed && result.imported === 0) { return apiError("Google Health sync failed", 502); } - return apiSuccess({ imported, fullSync }); + return apiSuccess({ ...resolveSyncOutcome(result), fullSync }); }); diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index 5d3f37b82..42b6b1d77 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -9,6 +9,11 @@ * * `userId` is always narrowed from the session — never a body field. The write * `data` object is built field-by-field (no mass assignment). + * + * The response carries the resolved `outcome` alongside the two lists. The + * dialog used to raise a green toast on any 200, so a re-scan in which every + * confirmed row turned out to be a duplicate reported "saved 0 readings" under + * a tick. The verdict is the server's to compute; the dialog only renders it. */ import { NextRequest } from "next/server"; import { fireAndForget } from "@/lib/logging/fire-and-forget"; @@ -33,6 +38,7 @@ import { type InsertedLabForLink, } from "@/lib/labs/vault-link"; import { annotate } from "@/lib/logging/context"; +import { classifyWrittenOutcome } from "@/lib/outcome/written-outcome"; import { ocrCommitSchema, type OcrCommitRow, @@ -235,5 +241,12 @@ async function commitOcrRows(request: NextRequest) { } } - return apiSuccess({ inserted, skipped }); + return apiSuccess({ + inserted, + skipped, + outcome: classifyWrittenOutcome({ + written: inserted.length, + skipped: skipped.length, + }), + }); } diff --git a/src/app/api/nightscout/sync/__tests__/route.test.ts b/src/app/api/nightscout/sync/__tests__/route.test.ts index a133cb8c5..15585a50b 100644 --- a/src/app/api/nightscout/sync/__tests__/route.test.ts +++ b/src/app/api/nightscout/sync/__tests__/route.test.ts @@ -57,18 +57,65 @@ beforeEach(() => { describe("POST /api/nightscout/sync", () => { it("syncs the session user and answers with the freshly inserted count", async () => { - vi.mocked(syncUserNightscout).mockResolvedValue(36); + vi.mocked(syncUserNightscout).mockResolvedValue({ + imported: 36, + failed: false, + }); const response = await post(request()); expect(response.status).toBe(200); expect(await envelope(response)).toEqual({ - data: { imported: 36 }, + data: { imported: 36, failed: false, outcome: "success" }, error: null, }); expect(syncUserNightscout).toHaveBeenCalledWith("u1"); }); + it("does not call a window whose every row was refused a success", async () => { + // The defect: 200 + `imported: 0` rendered as a green tick with a count, + // when in fact not one SGV row reached the database. + vi.mocked(syncUserNightscout).mockResolvedValue({ + imported: 0, + failed: true, + }); + + const response = await post(request()); + + expect(response.status).toBe(200); + expect((await envelope(response)).data).toEqual({ + imported: 0, + failed: true, + outcome: "failed", + }); + }); + + it("calls a window that wrote some rows and lost others a partial", async () => { + vi.mocked(syncUserNightscout).mockResolvedValue({ + imported: 30, + failed: true, + }); + + const response = await post(request()); + + expect((await envelope(response)).data).toMatchObject({ + outcome: "partial", + }); + }); + + it("calls a window that found nothing new empty, not a success", async () => { + vi.mocked(syncUserNightscout).mockResolvedValue({ + imported: 0, + failed: false, + }); + + const response = await post(request()); + + expect((await envelope(response)).data).toMatchObject({ + outcome: "empty", + }); + }); + it("refuses an unauthenticated caller before syncing anything", async () => { vi.mocked(requireAuth).mockRejectedValue( new HttpError(401, "Not authenticated"), diff --git a/src/app/api/nightscout/sync/route.ts b/src/app/api/nightscout/sync/route.ts index 1c379ce79..99504bb4d 100644 --- a/src/app/api/nightscout/sync/route.ts +++ b/src/app/api/nightscout/sync/route.ts @@ -3,12 +3,15 @@ import { apiSuccess, apiError } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import { syncUserNightscout } from "@/lib/nightscout/sync"; +import { resolveSyncOutcome } from "@/lib/outcome/written-outcome"; /** * Manually trigger a Nightscout sync for the current user (v1.32.28). * * Pulls the recent SGV window from the configured instance; `imported` counts - * the readings that were genuinely new this run, not the ones re-confirmed. + * the readings that were genuinely new this run, not the ones re-confirmed, + * and `outcome` says how that count reads — a window in which every row was + * refused by a constraint imports 0 and must not answer like a quiet night. * * No body and no `fullSync` flag: there is no full-history arm to call. * @@ -34,8 +37,8 @@ export const POST = apiHandler(async () => { } try { - const imported = await syncUserNightscout(user.id); - return apiSuccess({ imported }); + const result = await syncUserNightscout(user.id); + return apiSuccess(resolveSyncOutcome(result)); } catch { return apiError("Nightscout sync failed", 502); } diff --git a/src/app/api/oura/sync/__tests__/route.test.ts b/src/app/api/oura/sync/__tests__/route.test.ts index 8dd1fd51a..eea02fca6 100644 --- a/src/app/api/oura/sync/__tests__/route.test.ts +++ b/src/app/api/oura/sync/__tests__/route.test.ts @@ -53,19 +53,30 @@ beforeEach(() => { describe("POST /api/oura/sync", () => { it("syncs the session user and answers with the imported count", async () => { - vi.mocked(syncUserOura).mockResolvedValue(12); + vi.mocked(syncUserOura).mockResolvedValue({ imported: 12, failed: false }); const response = await post(request()); expect(response.status).toBe(200); expect(await envelope(response)).toEqual({ - data: { imported: 12 }, + data: { imported: 12, failed: false, outcome: "success" }, error: null, }); // The id comes from the session, never from the request. expect(syncUserOura).toHaveBeenCalledWith("u1"); }); + it("does not call a run whose every collection failed a success", async () => { + vi.mocked(syncUserOura).mockResolvedValue({ imported: 0, failed: true }); + + const response = await post(request()); + + expect(response.status).toBe(200); + expect((await envelope(response)).data).toMatchObject({ + outcome: "failed", + }); + }); + it("refuses an unauthenticated caller before syncing anything", async () => { vi.mocked(requireAuth).mockRejectedValue( new HttpError(401, "Not authenticated"), diff --git a/src/app/api/oura/sync/route.ts b/src/app/api/oura/sync/route.ts index 98602ed80..3f47ab631 100644 --- a/src/app/api/oura/sync/route.ts +++ b/src/app/api/oura/sync/route.ts @@ -2,6 +2,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess, apiError } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; +import { resolveSyncOutcome } from "@/lib/outcome/written-outcome"; import { syncUserOura } from "@/lib/oura/sync"; /** @@ -31,8 +32,8 @@ export const POST = apiHandler(async () => { } try { - const imported = await syncUserOura(user.id); - return apiSuccess({ imported }); + const result = await syncUserOura(user.id); + return apiSuccess(resolveSyncOutcome(result)); } catch { // The failure is already on the `oura` ledger, classified by the provider // that owns the error shape. Answer generically — an upstream message can diff --git a/src/app/api/polar/sync/__tests__/route.test.ts b/src/app/api/polar/sync/__tests__/route.test.ts index f18d71c4f..a3f870aed 100644 --- a/src/app/api/polar/sync/__tests__/route.test.ts +++ b/src/app/api/polar/sync/__tests__/route.test.ts @@ -67,26 +67,40 @@ beforeEach(() => { vi.clearAllMocks(); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); vi.mocked(requireAuth).mockResolvedValue({ user: { id: "u1" } } as never); - syncUserPolarMock.mockResolvedValue(0); + syncUserPolarMock.mockResolvedValue({ imported: 0, failed: false }); syncUserPolarWorkoutsMock.mockResolvedValue(0); }); describe("POST /api/polar/sync", () => { it("runs both Polar legs and totals what they imported", async () => { - syncUserPolarMock.mockResolvedValue(5); + syncUserPolarMock.mockResolvedValue({ imported: 5, failed: false }); syncUserPolarWorkoutsMock.mockResolvedValue(2); const response = await post(request()); expect(response.status).toBe(200); expect(await envelope(response)).toEqual({ - data: { imported: 7 }, + data: { imported: 7, failed: false, outcome: "success" }, error: null, }); expect(syncUserPolarMock).toHaveBeenCalledWith("u1"); expect(syncUserPolarWorkoutsMock).toHaveBeenCalledWith("u1"); }); + it("does not call a run whose vitals collections all failed a success", async () => { + syncUserPolarMock.mockResolvedValue({ imported: 0, failed: true }); + syncUserPolarWorkoutsMock.mockResolvedValue(0); + + const response = await post(request()); + + expect(response.status).toBe(200); + expect((await envelope(response)).data).toEqual({ + imported: 0, + failed: true, + outcome: "failed", + }); + }); + it("goes through the shared legs entry point, not the provider internals", () => { const source = readFileSync(join(__dirname, "..", "route.ts"), "utf8"); expect(source).toContain("syncUserPolarLegs"); diff --git a/src/app/api/polar/sync/route.ts b/src/app/api/polar/sync/route.ts index 01b528dcd..f743459af 100644 --- a/src/app/api/polar/sync/route.ts +++ b/src/app/api/polar/sync/route.ts @@ -3,6 +3,7 @@ import { apiSuccess, apiError } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import { syncUserPolarLegs } from "@/lib/jobs/reminder/polar-sync"; +import { resolveSyncOutcome } from "@/lib/outcome/written-outcome"; /** * Manually trigger a Polar sync for the current user (v1.32.28). @@ -31,8 +32,8 @@ export const POST = apiHandler(async () => { } try { - const imported = await syncUserPolarLegs(user.id); - return apiSuccess({ imported }); + const result = await syncUserPolarLegs(user.id); + return apiSuccess(resolveSyncOutcome(result)); } catch { // Both legs record their own classified failure on the `polar` ledger. // Answer generically so no upstream detail rides the response body. diff --git a/src/app/api/strava/sync/__tests__/route.test.ts b/src/app/api/strava/sync/__tests__/route.test.ts index 45fcd1bcf..c72617871 100644 --- a/src/app/api/strava/sync/__tests__/route.test.ts +++ b/src/app/api/strava/sync/__tests__/route.test.ts @@ -60,7 +60,7 @@ describe("POST /api/strava/sync", () => { expect(response.status).toBe(200); expect(await envelope(response)).toEqual({ - data: { imported: 4 }, + data: { imported: 4, failed: false, outcome: "success" }, error: null, }); expect(syncUserStrava).toHaveBeenCalledWith("u1"); diff --git a/src/app/api/strava/sync/route.ts b/src/app/api/strava/sync/route.ts index 0c36326cd..8810ccb64 100644 --- a/src/app/api/strava/sync/route.ts +++ b/src/app/api/strava/sync/route.ts @@ -2,6 +2,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess, apiError } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; +import { resolveSyncOutcome } from "@/lib/outcome/written-outcome"; import { syncUserStrava } from "@/lib/strava/sync"; /** @@ -28,7 +29,11 @@ export const POST = apiHandler(async () => { try { const imported = await syncUserStrava(user.id); - return apiSuccess({ imported }); + // `failed: false` is a statement, not a placeholder: the Strava walk has no + // settled-but-partial arm. `upsertStravaWorkouts` rethrows the first row + // error after attempting the batch and the page walk rethrows its own, so + // reaching this line means every fetched activity was written. + return apiSuccess(resolveSyncOutcome({ imported, failed: false })); } catch { // Already recorded on the `strava` ledger with its classification. Answer // generically so no upstream detail rides the response body. diff --git a/src/app/api/whoop/sync/route.ts b/src/app/api/whoop/sync/route.ts index d3a331aca..1a7579cfe 100644 --- a/src/app/api/whoop/sync/route.ts +++ b/src/app/api/whoop/sync/route.ts @@ -1,6 +1,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess, apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; +import { resolveSyncOutcome } from "@/lib/outcome/written-outcome"; import { syncUserWhoop } from "@/lib/whoop/sync"; import { NextRequest } from "next/server"; @@ -27,6 +28,8 @@ export const POST = apiHandler(async (request: NextRequest) => { // no body provided -> default incremental sync } - const { imported } = await syncUserWhoop(user.id, { fullSync }); - return apiSuccess({ imported, fullSync }); + // Resolved server-side so the card and the native client cannot disagree on + // how the same run reads. + const result = await syncUserWhoop(user.id, { fullSync }); + return apiSuccess({ ...resolveSyncOutcome(result), fullSync }); }); diff --git a/src/app/api/withings/sync/route.ts b/src/app/api/withings/sync/route.ts index f881d62d8..508f8cc4a 100644 --- a/src/app/api/withings/sync/route.ts +++ b/src/app/api/withings/sync/route.ts @@ -1,6 +1,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess, apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; +import { resolveSyncOutcome } from "@/lib/outcome/written-outcome"; import { syncUserMeasurements } from "@/lib/withings/sync"; import { NextRequest } from "next/server"; @@ -24,8 +25,6 @@ export const POST = apiHandler(async (request: NextRequest) => { // no body provided -> default incremental sync } - const imported = await syncUserMeasurements(user.id, { - fullSync, - }); - return apiSuccess({ imported, fullSync }); + const result = await syncUserMeasurements(user.id, { fullSync }); + return apiSuccess({ ...resolveSyncOutcome(result), fullSync }); }); 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/medications/intake-import-result.tsx b/src/components/medications/intake-import-result.tsx index 4493fc219..d0945bc04 100644 --- a/src/components/medications/intake-import-result.tsx +++ b/src/components/medications/intake-import-result.tsx @@ -8,9 +8,9 @@ * the thing that has to be right is the sentence at the end of the run. * * The rules are the ones the settings import panel already follows, reused - * rather than restated: `classifyImportOutcome` decides the tone from the + * rather than restated: `classifyWrittenOutcome` decides the tone from the * counts, so an import that wrote nothing can never carry the success tick, - * and `ImportOutcomeLine` owns the tick and the success colour. The skips are + * and `WrittenOutcomeLine` owns the tick and the success colour. The skips are * grouped by reason with a count, because a file that trips one rule trips it * on every row and a per-row list is the same sentence a hundred times. * @@ -18,11 +18,11 @@ * cards; the medication intake import is the third import surface and it now * answers the same way. */ +import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; import { - classifyImportOutcome, - type ImportOutcome, -} from "@/components/settings/import-panel/import-result-state"; -import { ImportOutcomeLine } from "@/components/settings/import-panel/import-result-view"; + classifyWrittenOutcome, + type WrittenOutcome, +} from "@/lib/outcome/written-outcome"; import { useTranslations } from "@/lib/i18n/context"; /** A skip group exactly as the import job reports it. */ @@ -132,7 +132,7 @@ export function IntakeImportResultView({ ); } - const outcome: ImportOutcome = classifyImportOutcome({ + const outcome: WrittenOutcome = classifyWrittenOutcome({ written: result.imported, skipped: result.skipped, }); @@ -153,7 +153,7 @@ export function IntakeImportResultView({ return (
- = []; +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/outcome/outcome-toast.ts b/src/components/outcome/outcome-toast.ts new file mode 100644 index 000000000..84ca8d157 --- /dev/null +++ b/src/components/outcome/outcome-toast.ts @@ -0,0 +1,33 @@ +"use client"; + +import { toast } from "sonner"; + +import type { WrittenOutcome } from "@/lib/outcome/written-outcome"; + +/** + * The toast twin of `WrittenOutcomeLine`: one mapping from a written outcome + * to the toast variant that reports it. + * + * `toast.success` lives here and is reached only through the `success` key, so + * a surface that reports a write cannot raise a green toast for a run that + * wrote nothing. `empty` uses the neutral variant rather than the success one + * — "nothing to write" is an honest non-event, not an achievement. + */ +export function toastWrittenOutcome( + outcome: WrittenOutcome, + message: string, +): void { + switch (outcome) { + case "success": + toast.success(message); + return; + case "partial": + toast.warning(message); + return; + case "failed": + toast.error(message); + return; + case "empty": + toast.info(message); + } +} diff --git a/src/components/outcome/written-outcome-line.tsx b/src/components/outcome/written-outcome-line.tsx new file mode 100644 index 000000000..405bbeb1c --- /dev/null +++ b/src/components/outcome/written-outcome-line.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { AlertCircle, CheckCircle2, Info, TriangleAlert } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; + +import type { WrittenOutcome } from "@/lib/outcome/written-outcome"; + +/** + * The one mapping from a written outcome to how it looks. + * + * `CheckCircle2` and `text-success` appear on the `success` key and nowhere + * else in this file, so no future edit can attach a tick to a run that wrote + * nothing. Every import card and every provider-sync card renders its result + * line through here; `src/__tests__/success-affordance-guard.test.ts` holds + * that line. + */ +const OUTCOME_PRESENTATION: Record< + WrittenOutcome, + { Icon: LucideIcon; className: string } +> = { + success: { Icon: CheckCircle2, className: "text-success" }, + partial: { Icon: TriangleAlert, className: "text-warning" }, + failed: { Icon: AlertCircle, className: "text-destructive" }, + empty: { Icon: Info, className: "text-muted-foreground" }, +}; + +/** + * The headline line of a write result. Sits inside the caller's existing + * `aria-live` region where there is one, so the outcome is announced as it + * lands; a `failed` or `partial` line carries `role="alert"` itself, because + * a result that did not write is the case a user must not scroll past. + */ +export function WrittenOutcomeLine({ + outcome, + message, + testId, +}: { + outcome: WrittenOutcome; + message: string; + testId: string; +}) { + const { Icon, className } = OUTCOME_PRESENTATION[outcome]; + return ( +

+

+ ); +} 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/import-panel/apple-health-import-card.tsx b/src/components/settings/import-panel/apple-health-import-card.tsx index cfeb60c3f..1d8d12af8 100644 --- a/src/components/settings/import-panel/apple-health-import-card.tsx +++ b/src/components/settings/import-panel/apple-health-import-card.tsx @@ -11,8 +11,8 @@ import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; import { ImportCardShell } from "./import-card-shell"; -import { classifyImportOutcome } from "./import-result-state"; -import { ImportOutcomeLine } from "./import-result-view"; +import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; +import { classifyWrittenOutcome } from "@/lib/outcome/written-outcome"; /** Terminal states the status poll stops on. */ const TERMINAL_STATES: readonly string[] = ["done", "failed"]; @@ -239,8 +239,8 @@ export function AppleHealthImportCard() {
)} {isDone && ( - 0 ? "failed" : "empty"; - return skipped > 0 ? "partial" : "success"; -} - -export interface SkipReasonGroup { - /** The machine-readable reason as the route reported it. */ - reason: string; - count: number; -} - -/** A row of the per-row envelope, narrowed to what the grouping needs. */ -export interface ImportRowResult { - line: number; - status: string; - reason?: string; -} - -const UNKNOWN_REASON = "unknown"; - -/** - * Collapse the per-row envelope into one entry per skip reason. - * - * A sensor export that trips one validation trips it on every row, so the - * raw list is the same sentence a few thousand times. Grouping turns "scroll - * fifty identical lines" into one fact with a count. Ordered by count - * descending, then by reason, so the same result always renders the same way. - */ -export function groupSkipReasons( - rows: readonly ImportRowResult[], -): SkipReasonGroup[] { - const counts = new Map(); - for (const row of rows) { - if (row.status !== "skipped") continue; - const reason = row.reason ?? UNKNOWN_REASON; - counts.set(reason, (counts.get(reason) ?? 0) + 1); - } - return [...counts.entries()] - .map(([reason, count]) => ({ reason, count })) - .sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason)); -} diff --git a/src/components/settings/import-panel/import-result-view.tsx b/src/components/settings/import-panel/import-result-view.tsx index 78ac1fe44..b30b8fb47 100644 --- a/src/components/settings/import-panel/import-result-view.tsx +++ b/src/components/settings/import-panel/import-result-view.tsx @@ -1,61 +1,12 @@ "use client"; -import { AlertCircle, CheckCircle2, Info, TriangleAlert } from "lucide-react"; -import type { LucideIcon } from "lucide-react"; - +import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; import { useTranslations } from "@/lib/i18n/context"; import { - classifyImportOutcome, + classifyWrittenOutcome, groupSkipReasons, - type ImportOutcome, - type ImportRowResult, -} from "./import-result-state"; - -/** - * The one mapping from an import outcome to how it looks. - * - * `CheckCircle2` and `text-success` appear exactly once in the import panel, - * on the `success` key, so no future edit can attach a tick to a result that - * wrote nothing. `import-result-guard.test.ts` holds that line. - */ -const OUTCOME_PRESENTATION: Record< - ImportOutcome, - { Icon: LucideIcon; className: string } -> = { - success: { Icon: CheckCircle2, className: "text-success" }, - partial: { Icon: TriangleAlert, className: "text-warning" }, - failed: { Icon: AlertCircle, className: "text-destructive" }, - empty: { Icon: Info, className: "text-muted-foreground" }, -}; - -/** - * The headline line of an import result. Sits inside each card's existing - * `aria-live="polite"` region, so the outcome is announced as it lands. - */ -export function ImportOutcomeLine({ - outcome, - message, - testId, -}: { - outcome: ImportOutcome; - message: string; - testId: string; -}) { - const { Icon, className } = OUTCOME_PRESENTATION[outcome]; - return ( -

-

- ); -} + type WrittenRowResult, +} from "@/lib/outcome/written-outcome"; export interface CsvImportResult { inserted: number; @@ -63,7 +14,7 @@ export interface CsvImportResult { skipped: number; total: number; dryRun: boolean; - rows: ImportRowResult[]; + rows: WrittenRowResult[]; } /** @@ -118,7 +69,7 @@ export function CsvImportResultView({ result }: { result: CsvImportResult }) { const labelFor = useSkipReasonLabel(); const written = result.inserted + result.updated; - const outcome = classifyImportOutcome({ written, skipped: result.skipped }); + const outcome = classifyWrittenOutcome({ written, skipped: result.skipped }); const groups = groupSkipReasons(result.rows); const skippedRows = result.rows.filter((row) => row.status === "skipped"); @@ -162,7 +113,7 @@ export function CsvImportResultView({ result }: { result: CsvImportResult }) { return (
- - classifyImportOutcome({ + classifyWrittenOutcome({ written: r.measurements + r.moodEntries, skipped: r.skipped, }); @@ -191,7 +191,7 @@ export function JsonImportCard() {
{result && ( - { + 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/jobs/reminder/__tests__/polar-sync-legs.test.ts b/src/lib/jobs/reminder/__tests__/polar-sync-legs.test.ts index 557cf14e5..9ea8874ce 100644 --- a/src/lib/jobs/reminder/__tests__/polar-sync-legs.test.ts +++ b/src/lib/jobs/reminder/__tests__/polar-sync-legs.test.ts @@ -17,15 +17,18 @@ import { syncUserPolarLegs } from "../polar-sync"; beforeEach(() => { vi.clearAllMocks(); - syncUserPolar.mockResolvedValue(0); + syncUserPolar.mockResolvedValue({ imported: 0, failed: false }); syncUserPolarWorkouts.mockResolvedValue(0); }); describe("syncUserPolarLegs — composite independence on the shared polar ledger", () => { it("runs both legs and returns the summed count when both pass", async () => { - syncUserPolar.mockResolvedValue(5); + syncUserPolar.mockResolvedValue({ imported: 5, failed: false }); syncUserPolarWorkouts.mockResolvedValue(2); - await expect(syncUserPolarLegs("u1")).resolves.toBe(7); + await expect(syncUserPolarLegs("u1")).resolves.toEqual({ + imported: 7, + failed: false, + }); expect(syncUserPolar).toHaveBeenCalledWith("u1"); expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1"); }); @@ -35,9 +38,15 @@ describe("syncUserPolarLegs — composite independence on the shared polar ledge // its healthy-collections import count rather than throwing. The wrapper // must sum it with the workout leg and complete cleanly — a partial vitals // failure never starves the user's success accounting at the cohort level. - syncUserPolar.mockResolvedValue(4); + syncUserPolar.mockResolvedValue({ imported: 4, failed: true }); syncUserPolarWorkouts.mockResolvedValue(2); - await expect(syncUserPolarLegs("u1")).resolves.toBe(6); + // The partial is carried, not swallowed: the wrapper completes with the + // summed count AND says a leg did not settle, so the card cannot paint a + // run in which four of five collections failed as a clean one. + await expect(syncUserPolarLegs("u1")).resolves.toEqual({ + imported: 6, + failed: true, + }); expect(syncUserPolar).toHaveBeenCalledWith("u1"); expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1"); }); @@ -52,7 +61,7 @@ describe("syncUserPolarLegs — composite independence on the shared polar ledge }); it("leaves the vitals leg's result intact when the workout leg fails", async () => { - syncUserPolar.mockResolvedValue(4); + syncUserPolar.mockResolvedValue({ imported: 4, failed: false }); syncUserPolarWorkouts.mockRejectedValue(new Error("workouts down")); // The vitals leg ran to completion (its own success already recorded on // the shared ledger); the workout error is surfaced without undoing it. diff --git a/src/lib/jobs/reminder/nightscout-sync.ts b/src/lib/jobs/reminder/nightscout-sync.ts index 76cbc7b57..01bb0b25c 100644 --- a/src/lib/jobs/reminder/nightscout-sync.ts +++ b/src/lib/jobs/reminder/nightscout-sync.ts @@ -27,7 +27,9 @@ export const handleNightscoutSync = makePollCohortHandler({ }); return users.map((u) => u.id); }, - syncUser: syncUserNightscout, + // The cohort accounts in rows imported; the failure half of the result is + // recorded on the `nightscout` ledger by the sync itself. + syncUser: async (userId) => (await syncUserNightscout(userId)).imported, // The fetch path already records + marks its own failure (token-redacted), so // this fires only for a future UNMARKED escape — and redacts the token before // the message reaches the ledger. diff --git a/src/lib/jobs/reminder/oura-sync.ts b/src/lib/jobs/reminder/oura-sync.ts index a22d2f9ad..0b7c0288d 100644 --- a/src/lib/jobs/reminder/oura-sync.ts +++ b/src/lib/jobs/reminder/oura-sync.ts @@ -21,7 +21,9 @@ export const handleOuraSync = makePollCohortHandler({ }); return users.map((u) => u.id); }, - syncUser: syncUserOura, + // The cohort accounts in rows imported; the failure half of the result is + // recorded on the `oura` ledger by the sync itself. + syncUser: async (userId) => (await syncUserOura(userId)).imported, // Catches an UNMARKED escape — today the `upsertOuraMeasurements` write-path // throw (the fetch/refresh path already records + marks its own failures). recordFailure: recordOuraSyncFailure, diff --git a/src/lib/jobs/reminder/polar-sync.ts b/src/lib/jobs/reminder/polar-sync.ts index ee7c80253..97dd8dd50 100644 --- a/src/lib/jobs/reminder/polar-sync.ts +++ b/src/lib/jobs/reminder/polar-sync.ts @@ -15,6 +15,7 @@ */ import { recordPolarSyncFailure, syncUserPolar } from "@/lib/polar/sync"; import { syncUserPolarWorkouts } from "@/lib/polar/sync-workouts"; +import type { SyncWriteResult } from "@/lib/outcome/written-outcome"; import { makePollCohortHandler, type PollCohortPayload } from "./poll-cohort"; export type PolarSyncPayload = PollCohortPayload; @@ -34,13 +35,21 @@ export type PolarSyncPayload = PollCohortPayload; * healthy collections. So the vitals leg reaching this wrapper's catch means a * write-path/unexpected error, not a collection fetch failure. */ -export async function syncUserPolarLegs(userId: string): Promise { +export async function syncUserPolarLegs( + userId: string, +): Promise { let total = 0; let firstError: unknown; let hadError = false; + // A settled-but-partial vitals leg neither throws nor reduces the count, so + // it has to be carried explicitly — otherwise a run in which four of five + // collections failed reads exactly like a clean one. + let partial = false; try { - total += await syncUserPolar(userId); + const vitals = await syncUserPolar(userId); + total += vitals.imported; + partial = vitals.failed; } catch (err) { firstError = err; hadError = true; @@ -56,7 +65,7 @@ export async function syncUserPolarLegs(userId: string): Promise { } if (hadError) throw firstError; - return total; + return { imported: total, failed: partial }; } export const handlePolarSync = makePollCohortHandler({ @@ -68,7 +77,9 @@ export const handlePolarSync = makePollCohortHandler({ }); return users.map((u) => u.id); }, - syncUser: syncUserPolarLegs, + // The cohort accounts in rows imported; a partial vitals leg is already on + // the `polar` ledger, recorded by the leg that saw it. + syncUser: async (userId) => (await syncUserPolarLegs(userId)).imported, // Catches an UNMARKED escape — the `upsertPolarMeasurements` / // `upsertPolarWorkouts` write-path throw. The vitals leg records its own // per-collection partial failures inline and returns without rethrowing; the diff --git a/src/lib/jobs/reminder/withings-sync.ts b/src/lib/jobs/reminder/withings-sync.ts index 153283b58..efcf86750 100644 --- a/src/lib/jobs/reminder/withings-sync.ts +++ b/src/lib/jobs/reminder/withings-sync.ts @@ -89,7 +89,7 @@ export async function handleWithingsFallbackSync( for (const connection of connections) { try { - const imported = await syncUserMeasurements(connection.userId); + const { imported } = await syncUserMeasurements(connection.userId); usersSynced++; measurementsImported += imported; // v1.18.1 — a fresh reading landed; resolve this user's Vorsorge diff --git a/src/lib/nightscout/__tests__/sync.test.ts b/src/lib/nightscout/__tests__/sync.test.ts index 770eb67d4..2d903a3ec 100644 --- a/src/lib/nightscout/__tests__/sync.test.ts +++ b/src/lib/nightscout/__tests__/sync.test.ts @@ -90,18 +90,18 @@ afterEach(() => { }); describe("syncUserNightscout", () => { - it("returns 0 and records nothing when the user is not configured", async () => { + it("returns an empty result and records nothing when the user is not configured", async () => { getCredsMock.mockResolvedValue(null); - const n = await syncUserNightscout("u1"); - expect(n).toBe(0); + const result = await syncUserNightscout("u1"); + expect(result).toEqual({ imported: 0, failed: false }); expect(fetchSgvEntriesMock).not.toHaveBeenCalled(); expect(recordSuccessMock).not.toHaveBeenCalled(); }); it("writes each SGV entry as a BLOOD_GLUCOSE mg/dL measurement", async () => { fetchSgvEntriesMock.mockResolvedValue([ENTRY_A, ENTRY_B]); - const n = await syncUserNightscout("u1"); - expect(n).toBe(2); + const result = await syncUserNightscout("u1"); + expect(result).toEqual({ imported: 2, failed: false }); expect(upsertMock).toHaveBeenCalledTimes(2); const first = upsertMock.mock.calls[0]![0]; expect(first.create.type).toBe("BLOOD_GLUCOSE"); @@ -146,11 +146,11 @@ describe("syncUserNightscout", () => { // ENTRY_A already exists (a re-confirm); ENTRY_B is genuinely new. findManyMock.mockResolvedValue([{ externalId: "ns:abc" }]); - const n = await syncUserNightscout("u1"); + const result = await syncUserNightscout("u1"); // The inflated insert+reconfirm count used to fire the reminder-satisfy // trigger every tick; the honest count is the single fresh insert. - expect(n).toBe(1); + expect(result).toEqual({ imported: 1, failed: false }); // Both rows are still written (idempotent), but only the fresh one emits. expect(upsertMock).toHaveBeenCalledTimes(2); expect(emitArrivalsMock).toHaveBeenCalledTimes(1); @@ -172,10 +172,12 @@ describe("syncUserNightscout", () => { .mockResolvedValueOnce({ id: "r1" }) .mockRejectedValueOnce(new Error("bad row")); - const n = await syncUserNightscout("u1"); + const result = await syncUserNightscout("u1"); - // The one good row still landed; the tick is honest about the failed row. - expect(n).toBe(1); + // The one good row still landed; the tick is honest about the failed row — + // the count alone used to narrow that away, so the card called a window in + // which nothing wrote a success. + expect(result).toEqual({ imported: 1, failed: true }); expect(recordSuccessMock).not.toHaveBeenCalled(); expect(recordFailureMock).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/lib/nightscout/sync.ts b/src/lib/nightscout/sync.ts index 44588d512..724ccf46b 100644 --- a/src/lib/nightscout/sync.ts +++ b/src/lib/nightscout/sync.ts @@ -30,6 +30,7 @@ import { type FailureKind, } from "@/lib/integrations/status"; import type { IntegrationClassification } from "@/lib/integrations/http-status-classifier"; +import type { SyncWriteResult } from "@/lib/outcome/written-outcome"; import { collapseToTypeDayKeys, recomputeBucketsForMeasurement, @@ -122,19 +123,28 @@ export function classifyNightscoutFailure(err: unknown): FailureKind { } /** - * Sync one user's recent SGV entries. Returns the count of rows FRESHLY - * INSERTED this tick (re-confirmed rows the upsert merely touched are not - * counted — the old insert+reconfirm total inflated the cohort's - * `measurements_imported` and fired the reminder-satisfy trigger every tick - * regardless of whether new glucose actually arrived). A user with no - * configured instance is a clean no-op (returns 0, touches no status row). + * Sync one user's recent SGV entries. + * + * `imported` counts the rows FRESHLY INSERTED this tick (re-confirmed rows the + * upsert merely touched are not counted — the old insert+reconfirm total + * inflated the cohort's `measurements_imported` and fired the reminder-satisfy + * trigger every tick regardless of whether new glucose actually arrived). + * `failed` is true when any SGV row did not write. + * + * The count used to be the whole return value, which narrowed the failures + * away: a tick where every row was refused answered the same `0` as a tick + * that found nothing new, and the settings card rendered both as a green tick + * with a count. The caller now gets both halves and decides. + * + * A user with no configured instance is a clean no-op (nothing imported, + * nothing failed, no status row touched). */ export async function syncUserNightscout( userId: string, opts: { count?: number } = {}, -): Promise { +): Promise { const creds = await getUserNightscoutCredentials(userId); - if (!creds) return 0; + if (!creds) return { imported: 0, failed: false }; let entries: ParsedSgvEntry[]; try { @@ -168,11 +178,11 @@ export async function syncUserNightscout( kind: "transient", message: `${failedRows} of ${entries.length} SGV rows failed to write`, }); - return inserted; + return { imported: inserted, failed: true }; } await recordSyncSuccess(userId, "nightscout"); - return inserted; + return { imported: inserted, failed: false }; } /** diff --git a/src/lib/openapi/routes/ocr.ts b/src/lib/openapi/routes/ocr.ts index 6850f0f0a..2493c1f56 100644 --- a/src/lib/openapi/routes/ocr.ts +++ b/src/lib/openapi/routes/ocr.ts @@ -105,11 +105,15 @@ const commitResponse = z reason: z.literal("duplicate"), }), ), + outcome: z.enum(["empty", "failed", "partial", "success"]).meta({ + description: + "How the commit reads, resolved from the counts rather than the status code. `success` only when every confirmed row was written; `partial` when some were skipped; `failed` when nothing was written and at least one row was skipped; `empty` when there was nothing to write.", + }), }) .meta({ id: "OcrCommitResponse", description: - 'The rows written (`source: "OCR"`) and those skipped as commit-time duplicates.', + 'The rows written (`source: "OCR"`), those skipped as commit-time duplicates, and the resolved outcome.', }); export const ocrPaths: NonNullable = { diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index 2e13277b9..2c51946a0 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -220,7 +220,7 @@ function createdRows() { describe("syncUserOura", () => { it("no-ops cleanly when unconnected", async () => { getConnMock.mockResolvedValue(null); - expect(await syncUserOura("u1")).toBe(0); + expect(await syncUserOura("u1")).toEqual({ imported: 0, failed: false }); expect(recordSuccessMock).not.toHaveBeenCalled(); }); @@ -229,7 +229,7 @@ describe("syncUserOura", () => { fetchReadinessMock.mockResolvedValue([ { id: "x", day: "2026-06-10", score: 84 }, ]); - const imported = await syncUserOura("u1"); + const { imported } = await syncUserOura("u1"); expect(imported).toBe(1); const row = createdRows()[0]; expect(row).toMatchObject({ @@ -253,7 +253,7 @@ describe("syncUserOura", () => { refresh_token: "newRef", expires_in: 86400, }); - const imported = await syncUserOura("u1"); + const { imported } = await syncUserOura("u1"); expect(refreshMock).toHaveBeenCalledWith("ref", expect.anything()); expect(storeTokensMock).toHaveBeenCalledWith( "u1", @@ -278,7 +278,7 @@ describe("syncUserOura", () => { // A concurrent sync rotated first → the persist returns the PEER's token. storeTokensMock.mockResolvedValue("peerAcc"); - const imported = await syncUserOura("u1"); + const { imported } = await syncUserOura("u1"); // The retry runs with the peer's token rather than the invalidated one. expect(fetchReadinessMock).toHaveBeenLastCalledWith( @@ -461,7 +461,7 @@ describe("syncUserOura", () => { fetchResilienceMock.mockResolvedValue([ { id: "r", day: "2026-06-10", level: "strong" }, ]); - const imported = await syncUserOura("u1"); + const { imported } = await syncUserOura("u1"); expect(imported).toBe(1); const row = createdRows()[0]; expect(row).toMatchObject({ @@ -478,7 +478,7 @@ describe("syncUserOura", () => { fetchResilienceMock.mockResolvedValue([ { id: "r", day: "2026-06-10", level: "godlike" }, ]); - const imported = await syncUserOura("u1"); + const { imported } = await syncUserOura("u1"); expect(imported).toBe(0); expect(reconcileMock).not.toHaveBeenCalled(); }); @@ -561,10 +561,12 @@ describe("syncUserOura", () => { { id: "o", day: "2026-06-10", spo2_percentage: { average: 97 } }, ]); - const imported = await syncUserOura("u1"); + const result = await syncUserOura("u1"); - // The healthy collections still wrote their rows — the source is not blanked. - expect(imported).toBe(2); + // The healthy collections still wrote their rows — the source is not + // 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 written = createdRows().map((row) => row.type); expect(written).toContain("SLEEP_SCORE"); expect(written).toContain("OXYGEN_SATURATION"); @@ -596,7 +598,7 @@ describe("syncUserOura", () => { { id: "o", day: "2026-06-10", spo2_percentage: { average: 96 } }, ]); - const imported = await syncUserOura("u1"); + const { imported } = await syncUserOura("u1"); expect(imported).toBe(1); const written = createdRows().map((row) => row.type); @@ -620,7 +622,7 @@ describe("syncUserOura", () => { reason: "http_403", }), ); - const imported = await syncUserOura("u1"); + const { imported } = await syncUserOura("u1"); expect(imported).toBe(1); expect(recordSuccessMock).toHaveBeenCalledWith("u1", "oura"); expect(recordFailureMock).not.toHaveBeenCalled(); @@ -647,7 +649,7 @@ describe("syncUserOura", () => { { id: "s", day: "2026-06-10", score: 80 }, ]); fetchCyclePhasesMock.mockRejectedValue(new Error("boom")); - const imported = await syncUserOura("u1"); + const { imported } = await syncUserOura("u1"); expect(imported).toBe(1); expect(recordSuccessMock).toHaveBeenCalledWith("u1", "oura"); }); diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index fd6c1e2ff..4dfefaa83 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -52,6 +52,7 @@ import { type InsertedMeasurementArrivalRow, } from "@/lib/arrivals/measurement-emit"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; +import type { SyncWriteResult } from "@/lib/outcome/written-outcome"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; import { sweepStaleSleepSegments, @@ -411,16 +412,21 @@ async function fetchAll( } /** - * Sync one user's Oura data. Returns the count of measurement rows written. - * A user with no Oura connection is a clean no-op (returns 0, touches no - * status row). + * Sync one user's Oura data. + * + * `imported` is the count of measurement rows written; `failed` is true when a + * collection did not settle. The count used to be the whole return value, + * which hid the partial: a run where every collection failed answered `0`, the + * same as a run that found nothing new, and the settings card reported both as + * a success. A user with no Oura connection is a clean no-op (nothing + * imported, nothing failed, no status row touched). */ export async function syncUserOura( userId: string, opts: { lookbackDays?: number } = {}, -): Promise { +): Promise { const conn = await getOuraConnection(userId); - if (!conn) return 0; + if (!conn) return { imported: 0, failed: false }; // An explicit window from the caller wins; otherwise derive it from what we // already hold so a gap widens the window instead of being skipped over. @@ -516,11 +522,11 @@ export async function syncUserOura( ? String(firstErr.httpStatus) : undefined, }); - return imported; + return { imported, failed: true }; } await recordSyncSuccess(userId, "oura"); - return imported; + return { imported, failed: false }; } export async function upsertOuraMeasurements( diff --git a/src/components/settings/import-panel/__tests__/import-result-state.test.ts b/src/lib/outcome/__tests__/written-outcome.test.ts similarity index 56% rename from src/components/settings/import-panel/__tests__/import-result-state.test.ts rename to src/lib/outcome/__tests__/written-outcome.test.ts index 7870b680e..216c7f528 100644 --- a/src/components/settings/import-panel/__tests__/import-result-state.test.ts +++ b/src/lib/outcome/__tests__/written-outcome.test.ts @@ -1,42 +1,45 @@ /** - * #640 — an import that wrote nothing must never read as a success. + * #640 — a write that wrote nothing must never read as a success. * * The reported failure was not the parser refusing rows; it was the card * answering that refusal with a green tick, which is what turned a - * recoverable problem into a silent one. These pin the classifier that both - * import cards render from, and the structural guard that keeps the success - * affordance attached to the success outcome alone lives alongside in - * `import-result-guard.test.ts`. + * recoverable problem into a silent one. These pin the classifier every + * import card and every provider-sync card now renders from; the structural + * guard that keeps the success affordance attached to the success outcome + * alone lives in `src/__tests__/success-affordance-guard.test.ts`. */ import { describe, it, expect } from "vitest"; import { - classifyImportOutcome, + classifyWrittenOutcome, groupSkipReasons, -} from "../import-result-state"; + resolveSyncOutcome, +} from "../written-outcome"; -describe("classifyImportOutcome", () => { +describe("classifyWrittenOutcome", () => { it("calls the reported production result a failure", () => { // The audit envelope from the report: 1 597 rows in, nothing written. - expect(classifyImportOutcome({ written: 0, skipped: 1597 })).toBe("failed"); + expect(classifyWrittenOutcome({ written: 0, skipped: 1597 })).toBe( + "failed", + ); }); it("calls a file with no data rows empty, not a success", () => { - expect(classifyImportOutcome({ written: 0, skipped: 0 })).toBe("empty"); + expect(classifyWrittenOutcome({ written: 0, skipped: 0 })).toBe("empty"); }); it("calls a mixed result partial", () => { - expect(classifyImportOutcome({ written: 3, skipped: 2 })).toBe("partial"); + expect(classifyWrittenOutcome({ written: 3, skipped: 2 })).toBe("partial"); }); it("calls a clean result a success", () => { - expect(classifyImportOutcome({ written: 5, skipped: 0 })).toBe("success"); + expect(classifyWrittenOutcome({ written: 5, skipped: 0 })).toBe("success"); }); it("never reaches success while nothing was written", () => { for (let written = 0; written <= 4; written++) { for (let skipped = 0; skipped <= 4; skipped++) { - const outcome = classifyImportOutcome({ written, skipped }); + const outcome = classifyWrittenOutcome({ written, skipped }); if (written === 0) { expect(outcome).not.toBe("success"); expect(outcome).not.toBe("partial"); @@ -50,6 +53,43 @@ describe("classifyImportOutcome", () => { }); }); +describe("resolveSyncOutcome", () => { + it("calls a run that wrote nothing and failed a failure, not an empty run", () => { + // The defect: every row refused by a constraint, count 0, HTTP 200. + expect(resolveSyncOutcome({ imported: 0, failed: true })).toEqual({ + imported: 0, + failed: true, + outcome: "failed", + }); + }); + + it("calls a run that found nothing new empty", () => { + expect(resolveSyncOutcome({ imported: 0, failed: false }).outcome).toBe( + "empty", + ); + }); + + it("calls a run with a failed leg partial even when rows landed", () => { + expect(resolveSyncOutcome({ imported: 12, failed: true }).outcome).toBe( + "partial", + ); + }); + + it("calls a clean run a success", () => { + expect(resolveSyncOutcome({ imported: 12, failed: false }).outcome).toBe( + "success", + ); + }); + + it("never reaches success while the run reported a failure", () => { + for (const imported of [0, 1, 500]) { + expect(resolveSyncOutcome({ imported, failed: true }).outcome).not.toBe( + "success", + ); + } + }); +}); + describe("groupSkipReasons", () => { it("collapses a repeated reason to one entry with a count", () => { const rows = Array.from({ length: 1597 }, (_, i) => ({ diff --git a/src/lib/outcome/written-outcome.ts b/src/lib/outcome/written-outcome.ts new file mode 100644 index 000000000..786d42869 --- /dev/null +++ b/src/lib/outcome/written-outcome.ts @@ -0,0 +1,122 @@ +/** + * Written-outcome classification — the one place that decides whether a write + * reads as a success. + * + * The rule started in the import panel (`import-result-state.ts`, #640/#650) + * and is unchanged; only its reach is wider. A per-row importer answers HTTP + * 200 for a file where every single row was refused, and a provider sync + * answers HTTP 200 for a run where every reading failed a constraint. The + * transport succeeded, the write did not. Rendering either as a green tick is + * how a self-hoster's sensor export could write nothing and still look + * finished. So the counts, not the status code, pick the state: + * + * - nothing written, nothing refused → `empty` (there was nothing to write) + * - nothing written, rows refused → `failed` (never a success tone) + * - some written, some refused → `partial` (a warning, not a tick) + * - everything written → `success` + * + * `empty` is deliberately its own state rather than a failure: an import of a + * file with no data rows and a sync that finds nothing new are both honest + * non-events, and both would be a lie under a tick. + * + * Every surface that reports a write routes through this, and + * `success-affordance-guard.test.ts` holds the line that the tick and the + * success colour live only in the presentation module this feeds. + */ + +export type WrittenOutcome = "empty" | "failed" | "partial" | "success"; + +export interface WriteCounts { + /** Rows that reached the database — inserted + updated. */ + written: number; + /** Rows the write refused, for any reason. */ + skipped: number; +} + +export function classifyWrittenOutcome({ + written, + skipped, +}: WriteCounts): WrittenOutcome { + if (written <= 0) return skipped > 0 ? "failed" : "empty"; + return skipped > 0 ? "partial" : "success"; +} + +/** + * What a provider sync reports back to its route. + * + * Three providers (Fitbit, Google Health, WHOOP) already answered in this + * shape; the rest returned a bare count that narrowed their failures away — + * a Nightscout run whose every SGV row failed to write returned the same `0` + * as a run that found nothing new. The boolean is the honest floor: a sync + * knows a leg / collection / row did not land, but not always how many. + */ +export interface SyncWriteResult { + /** Rows that reached the database this run. */ + imported: number; + /** True when any part of the run did not land — a row, a collection, a leg. */ + failed: boolean; +} + +/** The sync envelope a route answers with: the counts plus the resolved tone. */ +export interface ResolvedSyncOutcome extends SyncWriteResult { + outcome: WrittenOutcome; +} + +/** + * Resolve a sync's write result to the outcome its card renders. + * + * Server-side on purpose: the verdict is the server's to compute, so the web + * card and the native client cannot drift on how the same run reads. A failed + * leg counts as one refusal — enough to move `success` to `partial` and + * `empty` to `failed`, which is the whole point. + */ +export function resolveSyncOutcome({ + imported, + failed, +}: SyncWriteResult): ResolvedSyncOutcome { + return { + imported, + failed, + outcome: classifyWrittenOutcome({ + written: imported, + skipped: failed ? 1 : 0, + }), + }; +} + +export interface SkipReasonGroup { + /** The machine-readable reason as the route reported it. */ + reason: string; + count: number; +} + +/** A row of the per-row envelope, narrowed to what the grouping needs. */ +export interface WrittenRowResult { + line: number; + status: string; + reason?: string; +} + +const UNKNOWN_REASON = "unknown"; + +/** + * Collapse the per-row envelope into one entry per skip reason. + * + * A sensor export that trips one validation trips it on every row, so the + * raw list is the same sentence a few thousand times. Grouping turns "scroll + * fifty identical lines" into one fact with a count. Ordered by count + * descending, then by reason, so the same result always renders the same way. + */ +export function groupSkipReasons( + rows: readonly WrittenRowResult[], +): SkipReasonGroup[] { + const counts = new Map(); + for (const row of rows) { + if (row.status !== "skipped") continue; + const reason = row.reason ?? UNKNOWN_REASON; + counts.set(reason, (counts.get(reason) ?? 0) + 1); + } + return [...counts.entries()] + .map(([reason, count]) => ({ reason, count })) + .sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason)); +} diff --git a/src/lib/polar/__tests__/sync.test.ts b/src/lib/polar/__tests__/sync.test.ts index f5d12ff9c..3a1351d1d 100644 --- a/src/lib/polar/__tests__/sync.test.ts +++ b/src/lib/polar/__tests__/sync.test.ts @@ -164,7 +164,7 @@ function createdRows() { describe("syncUserPolar", () => { it("no-ops cleanly for an unconnected user", async () => { getConnMock.mockResolvedValue(null); - expect(await syncUserPolar("u1")).toBe(0); + expect(await syncUserPolar("u1")).toEqual({ imported: 0, failed: false }); expect(createManyAndReturnMock).not.toHaveBeenCalled(); expect(recordSuccessMock).not.toHaveBeenCalled(); }); @@ -186,7 +186,7 @@ describe("syncUserPolar", () => { fetchRechargesMock.mockResolvedValue([ { date: "2026-06-10", nightly_recharge_status: 6 }, ]); - const imported = await syncUserPolar("u1"); + const { imported } = await syncUserPolar("u1"); expect(imported).toBe(1); const row = createdRows()[0]; expect(row).toMatchObject({ @@ -206,7 +206,7 @@ describe("syncUserPolar", () => { fetchCardioLoadsMock.mockResolvedValue([ { date: "2026-06-10", cardio_load: 123.45 }, ]); - const imported = await syncUserPolar("u1"); + const { imported } = await syncUserPolar("u1"); expect(imported).toBe(1); const row = createdRows()[0]; expect(row).toMatchObject({ @@ -348,7 +348,7 @@ describe("syncUserPolar", () => { { date: "2026-06-10", cardio_load: 42 }, ]); - const imported = await syncUserPolar("u1"); + const { imported } = await syncUserPolar("u1"); // No rethrow now — a fetch failure is isolated per-collection. expect(imported).toBe(1); @@ -397,8 +397,11 @@ describe("syncUserPolar", () => { { date: "2026-06-10", cardio_load: 55 }, ]); - const imported = await syncUserPolar("u1"); + const result = await syncUserPolar("u1"); + const imported = result.imported; + // The partial rides back with the count rather than being narrowed away. + expect(result.failed).toBe(true); // The healthy four still wrote their rows — the source is not blanked. const written = createdRows().map((row) => row.type); expect(written).toContain("RECOVERY_SCORE"); @@ -438,7 +441,7 @@ describe("syncUserPolar", () => { { date: "2026-06-10", cardio_load: 77 }, ]); - const imported = await syncUserPolar("u1"); + const { imported } = await syncUserPolar("u1"); expect(imported).toBe(1); expect(createdRows().map((row) => row.type)).toContain("CARDIO_LOAD"); @@ -463,7 +466,7 @@ describe("syncUserPolar", () => { { start_time: "2026-06-10T00:00:00", steps: 8000 }, ]); - const imported = await syncUserPolar("u1"); + const { imported } = await syncUserPolar("u1"); // The sweep never ran — a failed sleep collection pushes no sweep entries. expect(updateManyMock).not.toHaveBeenCalled(); diff --git a/src/lib/polar/sync.ts b/src/lib/polar/sync.ts index e6f62a920..17b6505c5 100644 --- a/src/lib/polar/sync.ts +++ b/src/lib/polar/sync.ts @@ -51,6 +51,7 @@ import { recomputeBucketsForMeasurement, } from "@/lib/rollups/measurement-rollups"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; +import type { SyncWriteResult } from "@/lib/outcome/written-outcome"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; import { emitInsertedMeasurementArrivals, @@ -161,9 +162,11 @@ export interface PolarCollectionFailure { } /** - * Sync one user's Polar data. Returns the count of measurement rows written. - * A user with no Polar connection is a clean no-op (returns 0, touches no - * status row). + * Sync one user's Polar data. Returns the rows written AND whether a + * collection did not settle — the bare count used to narrow the partial away, + * so a run whose every collection failed answered the same `0` as a quiet + * night. A user with no Polar connection is a clean no-op (nothing imported, + * nothing failed, no status row touched). * * The five vitals collections are settled independently: the healthy ones * import, a failing one is recorded as a single partial failure (no success @@ -171,9 +174,9 @@ export interface PolarCollectionFailure { * escapes now is a write-path/unexpected error out of `upsertPolarMeasurements` * — the poll-cohort boundary records that unmarked escape once. */ -export async function syncUserPolar(userId: string): Promise { +export async function syncUserPolar(userId: string): Promise { const conn = await getPolarConnection(userId); - if (!conn) return 0; + if (!conn) return { imported: 0, failed: false }; // Filled by the sleep collection's closure as it maps records; carried so the // caller can run the record-scoped sweep before the upsert. A collection that @@ -301,11 +304,11 @@ export async function syncUserPolar(userId: string): Promise { ? String(firstErr.httpStatus) : undefined, }); - return imported; + return { imported, failed: true }; } await recordSyncSuccess(userId, "polar", { leg: POLAR_LEG_VITALS }); - return imported; + return { imported, failed: false }; } /** 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). */ diff --git a/src/lib/withings/__tests__/sync-measure-watermark.test.ts b/src/lib/withings/__tests__/sync-measure-watermark.test.ts index c597f80b4..e8f37cd3e 100644 --- a/src/lib/withings/__tests__/sync-measure-watermark.test.ts +++ b/src/lib/withings/__tests__/sync-measure-watermark.test.ts @@ -171,10 +171,10 @@ describe("syncUserMeasurements — watermark hold on hard row failure (F4)", () .mockRejectedValueOnce(new Error("connection reset by peer")) .mockResolvedValue({} as never); - const imported = await syncUserMeasurements("user-1"); + const result = await syncUserMeasurements("user-1"); - // Only the second row persisted. - expect(imported).toBe(1); + // Only the second row persisted, and the caller is told the run lost one. + expect(result).toEqual({ imported: 1, failed: true }); // The watermark is HELD — no stamp — so the next tick refetches the window. expect(prisma.withingsConnection.update).not.toHaveBeenCalled(); expect(recordSyncSuccess).not.toHaveBeenCalled(); @@ -195,7 +195,7 @@ describe("syncUserMeasurements — watermark hold on hard row failure (F4)", () ] as never); vi.mocked(prisma.measurement.create).mockRejectedValueOnce(P2002); - const imported = await syncUserMeasurements("user-1"); + const { imported } = await syncUserMeasurements("user-1"); // P2002 means the row is already present — nothing lost — so the cycle is // still healthy: stamp the watermark + success, record no failure. @@ -216,7 +216,7 @@ describe("syncUserMeasurements — watermark hold on hard row failure (F4)", () }, ] as never); - const imported = await syncUserMeasurements("user-1"); + const { imported } = await syncUserMeasurements("user-1"); expect(imported).toBe(1); expect(prisma.withingsConnection.update).toHaveBeenCalledTimes(1); diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts index 3da585814..fcee1cc7c 100644 --- a/src/lib/withings/sync.ts +++ b/src/lib/withings/sync.ts @@ -32,6 +32,7 @@ import { } from "@/lib/rollups/measurement-rollups"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate"; +import type { SyncWriteResult } from "@/lib/outcome/written-outcome"; import { acquireProviderTokenRefreshLock, PROVIDER_REFRESH_TRANSACTION_OPTIONS, @@ -180,6 +181,9 @@ export const WITHINGS_INCREMENTAL_OVERLAP_MS = 10 * 60 * 1000; * Sync measurements from Withings for a given user. * Fetches data since last sync (or last 30 days if first sync). * + * Returns what was written AND whether any part of the run did not land, so + * the caller can tell a quiet week apart from a run that refused every row. + * * Status-tracking contract: * - On any failure (refresh failure, fetch failure, downstream * measurement-upsert that fails ALL items) we call @@ -193,26 +197,29 @@ export const WITHINGS_INCREMENTAL_OVERLAP_MS = 10 * 60 * 1000; export async function syncUserMeasurements( userId: string, opts: { fullSync?: boolean } = {}, -): Promise { +): Promise { // Park: if the last burst said reauth_required, do nothing until - // the OAuth callback flips state back to "connected". Returning 0 - // matches the existing contract for "no-op sync". + // the OAuth callback flips state back to "connected". The run wrote + // nothing AND did not complete, so it reports as a failure — a bare 0 + // read as "nothing new", which is how a parked connection could keep + // answering with a green tick. if (await isReauthRequired(userId, "withings")) { getEvent()?.addWarning( `withings sync skipped for ${userId}: parked at error_reauth`, ); - return 0; + return { imported: 0, failed: true }; } const tokenInfo = await getValidToken(userId); // Note: getValidToken already calls recordSyncFailure on the refresh // path, so we don't double-record here. - if (!tokenInfo) return 0; + if (!tokenInfo) return { imported: 0, failed: true }; const connection = await prisma.withingsConnection.findUnique({ where: { userId }, }); - if (!connection) return 0; + // No connection row is not a failure — there is nothing to sync. + if (!connection) return { imported: 0, failed: false }; const startDate = opts.fullSync ? undefined @@ -390,7 +397,7 @@ export async function syncUserMeasurements( firstRowError ?? new Error("Withings measure row write failed"), WITHINGS_LEG_MEASURES, ); - return imported; + return { imported, failed: true }; } // Update last synced timestamp @@ -407,7 +414,7 @@ export async function syncUserMeasurements( leg: WITHINGS_LEG_MEASURES, }); - return imported; + return { imported, failed: false }; } /**