mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
merge: s3-written into release/v1.34.0
This commit is contained in:
commit
602ca36ee2
57 files changed
+1629
-545
No files matched your search
+12
-1
@@ -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:
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -5148,6 +5148,11 @@
|
||||
"withingsFullSyncTitle": "Pełna synchronizacja?",
|
||||
"withingsFullSyncDescription": "Wszystkie dostępne dane Withings zostaną w pełni zsynchronizowane. Może to chwilę potrwać w zależności od historii.",
|
||||
"withingsSyncResult": "{count} pomiarów zsynchronizowano",
|
||||
"syncOutcome": {
|
||||
"empty": "Nie ma nic nowego do zaimportowania. Wszystko jest aktualne.",
|
||||
"failed": "Nic nie zostało zapisane. Następne uruchomienie spróbuje ponownie.",
|
||||
"partial": "Zsynchronizowano {count} pomiarów. Części danych nie udało się zapisać; następne uruchomienie spróbuje ponownie."
|
||||
},
|
||||
"withingsFullSyncResult": "{count} pomiarów w pełni zsynchronizowano",
|
||||
"withingsSyncFailed": "Synchronizacja nie powiodła się",
|
||||
"withingsOauthConnected": "Withings połączony — pierwsza synchronizacja rozpocznie się w tle.",
|
||||
@@ -7873,6 +7878,9 @@
|
||||
"saveSelected": "Zapisz {count} wybranych",
|
||||
"discardAll": "Odrzuć wszystko",
|
||||
"savedToast": "Zapisano {count} wyników laboratoryjnych",
|
||||
"savedPartialToast": "Zapisano {count} wyników laboratoryjnych, {skipped} pominięto jako duplikaty",
|
||||
"savedNothingToast": "Nic nie zapisano: {skipped} wyników było już zarejestrowanych",
|
||||
"savedEmptyToast": "Nie ma nic do zapisania",
|
||||
"noRows": "W tym wyniku nie znaleziono żadnych wartości.",
|
||||
"nothingToSave": "Wybierz co najmniej jeden wynik do zapisania.",
|
||||
"saveFailed": "Nie udało się zapisać wyników. Spróbuj ponownie.",
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* G3 — the success affordance is owned by the outcome module, everywhere.
|
||||
*
|
||||
* #640 established the rule inside the import panel: a write that wrote
|
||||
* nothing must not read as a success. #650 widened it to the medication
|
||||
* intake import. This widens it to the whole component tree, because the same
|
||||
* defect had been sitting in seven more surfaces the whole time — six
|
||||
* integration cards and the lab-OCR dialog reported "success" off `res.ok`,
|
||||
* so a sync that imported nothing because every reading was refused rendered
|
||||
* a green tick and a count.
|
||||
*
|
||||
* What this asserts:
|
||||
* 1. Every `CheckCircle2` / `text-success` / `toast.success` occurrence
|
||||
* under `src/components` is either inside the shared presentation module
|
||||
* (`src/components/outcome/`) or pinned in `PINNED_AFFORDANCES` with an
|
||||
* exact per-file count. A name-only allowlist would let a listed file add
|
||||
* a SECOND, dishonest tick without anyone noticing, which is why the
|
||||
* counts are pinned rather than the filenames.
|
||||
* 2. No pinned entry has gone stale — a file that no longer carries the
|
||||
* marker, or no longer exists, fails here rather than rotting silently.
|
||||
* This is the guard-of-the-guard: if the scanner ever stopped matching,
|
||||
* every entry would go stale at once and this is what would say so.
|
||||
* 3. Every surface that reports a write routes its result line through the
|
||||
* shared outcome module.
|
||||
*
|
||||
* HOW WEAK THIS GUARD IS, stated plainly: this is the weakest guard in the
|
||||
* release and it is not complete. It matches three literal tokens. It is
|
||||
* evadable by synonym — `text-emerald-500`, a raw checkmark glyph, an
|
||||
* `<svg>` copy of the icon, `toast(...)` with a success-looking variant, or a
|
||||
* component that wraps any of them. Nothing here proves a surface is honest;
|
||||
* it proves a surface did not reach for these three specific affordances
|
||||
* without being listed. It also says nothing about `src/app`, which is out of
|
||||
* scope by construction (the counted noise floor was measured over
|
||||
* `src/components`). Treat a green run as "the known evasions are closed",
|
||||
* not as "no surface can lie".
|
||||
*
|
||||
* Its forced failures, each watched fail before this was committed and then
|
||||
* reverted:
|
||||
* - append `toast.success("probe")` to `src/components/ui/alert-dialog.tsx`
|
||||
* (no entry) -> red on the first case, naming the file;
|
||||
* - append a second `CheckCircle2` reference to
|
||||
* `src/components/onboarding/done-screen.tsx` -> red on the second case:
|
||||
* "CheckCircle2 pinned at 2, found 3";
|
||||
* - append a second `text-success` literal to
|
||||
* `src/components/settings/test-connection-button.tsx`, which is pinned at
|
||||
* exactly 1 -> red: "text-success pinned at 1, found 2".
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, posix, relative, sep } from "node:path";
|
||||
|
||||
const REPO_ROOT = join(__dirname, "..", "..");
|
||||
const COMPONENT_ROOT = join(REPO_ROOT, "src", "components");
|
||||
|
||||
/** The tick component, the success colour token, the success toast. */
|
||||
const SUCCESS_MARKERS = [
|
||||
"CheckCircle2",
|
||||
"text-success",
|
||||
"toast.success",
|
||||
] as const;
|
||||
|
||||
type SuccessMarker = (typeof SUCCESS_MARKERS)[number];
|
||||
|
||||
/**
|
||||
* The one module allowed to reach for the affordance. `WrittenOutcomeLine`
|
||||
* and `toastWrittenOutcome` bind it to the `success` key of the outcome table
|
||||
* and nothing else can get at it.
|
||||
*/
|
||||
const PRESENTATION_DIR = "src/components/outcome/";
|
||||
|
||||
/**
|
||||
* Every OTHER file under `src/components` that mentions a marker, with the
|
||||
* exact number of times it does. Seeded from the tree as it stands; an entry
|
||||
* is not an endorsement, it is a record. Reducing one of these to zero is
|
||||
* always welcome — delete the entry in the same commit.
|
||||
*/
|
||||
const PINNED_AFFORDANCES: Record<
|
||||
string,
|
||||
Partial<Record<SuccessMarker, number>>
|
||||
> = {
|
||||
"src/components/admin/_shared.tsx": { "text-success": 2, "toast.success": 1 },
|
||||
"src/components/admin/ai-server-key-section.tsx": { "toast.success": 1 },
|
||||
"src/components/admin/api-token-overview-section.tsx": { "text-success": 1 },
|
||||
"src/components/admin/app-log-preview-section.tsx": {
|
||||
CheckCircle2: 2,
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/admin/assistant-section.tsx": { "toast.success": 1 },
|
||||
"src/components/admin/backups-section.tsx": { "toast.success": 4 },
|
||||
"src/components/admin/central-codex-section.tsx": { "toast.success": 2 },
|
||||
"src/components/admin/danger-zone-section.tsx": { "text-success": 1 },
|
||||
"src/components/admin/encryption-section.tsx": {
|
||||
"text-success": 1,
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/admin/glitchtip-section.tsx": { "toast.success": 1 },
|
||||
"src/components/admin/invite-tokens-section.tsx": {
|
||||
"text-success": 2,
|
||||
"toast.success": 3,
|
||||
},
|
||||
"src/components/admin/login-overview-section.tsx": {
|
||||
CheckCircle2: 2,
|
||||
"text-success": 2,
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/admin/module-availability-section.tsx": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/admin/recent-audit-preview.tsx": {
|
||||
CheckCircle2: 2,
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/admin/reminders-section.tsx": {
|
||||
CheckCircle2: 2,
|
||||
"text-success": 4,
|
||||
"toast.success": 2,
|
||||
},
|
||||
"src/components/admin/system-status-section.tsx": { "text-success": 6 },
|
||||
"src/components/admin/system-status-summary.tsx": { "text-success": 3 },
|
||||
"src/components/admin/umami-section.tsx": { "toast.success": 1 },
|
||||
"src/components/admin/user-management-section.tsx": {
|
||||
"text-success": 2,
|
||||
"toast.success": 2,
|
||||
},
|
||||
"src/components/admin/version-tile-section.tsx": {
|
||||
CheckCircle2: 2,
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/admin/web-push-vapid-section.tsx": { "toast.success": 1 },
|
||||
"src/components/charts/medication-compliance-chart.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/custom-metrics/custom-metric-detail.tsx": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/custom-metrics/custom-metric-entry-form.tsx": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/custom-metrics/custom-metric-form.tsx": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/custom-metrics/custom-metric-history-list.tsx": {
|
||||
"toast.success": 2,
|
||||
},
|
||||
"src/components/cycle/cycle-phase-crosstab.tsx": { "text-success": 1 },
|
||||
"src/components/dashboard/medication-intake-quick-add.tsx": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/dashboard/range-display.tsx": { "text-success": 2 },
|
||||
"src/components/documents/document-card.tsx": { "text-success": 1 },
|
||||
"src/components/documents/document-detail-sheet.tsx": { "toast.success": 2 },
|
||||
"src/components/documents/documents-view.tsx": { "toast.success": 3 },
|
||||
"src/components/error-details.tsx": { CheckCircle2: 2, "text-success": 1 },
|
||||
"src/components/illness/episode-menu.tsx": {
|
||||
CheckCircle2: 2,
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/insights/coach-panel/chat-bubble.tsx": { "text-success": 4 },
|
||||
"src/components/insights/coach-panel/history-rail.tsx": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/insights/coach-panel/plan-proposal-card.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/insights/coach-panel/reminder-suggestion-card.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/insights/coach-panel/self-context-adopt-offer.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/insights/coach-panel/suggested-action-card.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/insights/confidence-badge.ts": { "text-success": 1 },
|
||||
"src/components/insights/daily-briefing.tsx": { "text-success": 1 },
|
||||
"src/components/insights/derived/band-tokens.ts": { "text-success": 1 },
|
||||
"src/components/insights/health-score-card.tsx": { "text-success": 4 },
|
||||
"src/components/insights/insights-edit-mode.tsx": { "toast.success": 2 },
|
||||
"src/components/insights/insights-tab-strip.tsx": { "toast.success": 1 },
|
||||
"src/components/insights/mood/mood-better-days.tsx": { "text-success": 1 },
|
||||
"src/components/insights/mood/mood-factor-metric-crosstab.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/insights/mood/mood-tag-influence.tsx": { "text-success": 1 },
|
||||
"src/components/insights/mood/mood-tag-metric-crosstab.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/insights/nutrients/water-quick-add-sheet.tsx": {
|
||||
"toast.success": 2,
|
||||
},
|
||||
"src/components/insights/personal-record-badge.tsx": { "text-success": 1 },
|
||||
"src/components/insights/recommendation-feedback.tsx": { "text-success": 3 },
|
||||
"src/components/labs/biomarker-form.tsx": { "toast.success": 1 },
|
||||
"src/components/labs/biomarker-manager.tsx": { "toast.success": 2 },
|
||||
"src/components/labs/lab-biomarker-detail.tsx": { "toast.success": 1 },
|
||||
"src/components/labs/lab-form.tsx": { "toast.success": 1 },
|
||||
"src/components/labs/lab-history-list.tsx": { "toast.success": 3 },
|
||||
"src/components/measurement-reminders/vorsorge-dashboard-card.tsx": {
|
||||
CheckCircle2: 2,
|
||||
},
|
||||
"src/components/measurement-reminders/vorsorge-section.tsx": {
|
||||
CheckCircle2: 2,
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/measurements/measurement-form.tsx": { "toast.success": 1 },
|
||||
"src/components/measurements/measurement-list.tsx": { "toast.success": 3 },
|
||||
"src/components/medications/card-parts/medication-status-pill.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/medications/dose-history-add-dialog.tsx": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/medications/dose-history-ledger.tsx": {
|
||||
"text-success": 2,
|
||||
"toast.success": 4,
|
||||
},
|
||||
"src/components/medications/intake-edit-dialog.tsx": { "toast.success": 1 },
|
||||
"src/components/medications/intake-history-editable.tsx": {
|
||||
"toast.success": 2,
|
||||
},
|
||||
"src/components/medications/intake-history-list-v2.tsx": {
|
||||
"text-success": 2,
|
||||
},
|
||||
"src/components/medications/research-mode-acknowledgment-dialog.tsx": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/medications/scheduling/schedule-history-timeline.tsx": {
|
||||
"toast.success": 2,
|
||||
},
|
||||
"src/components/medications/scheduling/schedule-times-editor.tsx": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/medications/sections/api-tokens-row.tsx": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/medications/sections/destructive-zone-section.tsx": {
|
||||
"toast.success": 4,
|
||||
},
|
||||
"src/components/medications/sections/inventory-dialogs.tsx": {
|
||||
"toast.success": 3,
|
||||
},
|
||||
"src/components/medications/sections/inventory-section.tsx": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/medications/sections/notifications-section.tsx": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/medications/sections/phase-config-sheet.tsx": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/medications/sections/settings-section.tsx": {
|
||||
"toast.success": 2,
|
||||
},
|
||||
"src/components/medications/take-all-due.ts": { "toast.success": 1 },
|
||||
"src/components/medications/use-medication-intake.ts": { "toast.success": 4 },
|
||||
"src/components/medications/wizard/medication-wizard-dialog.tsx": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/mood/manage/archived-tags-card.tsx": { "toast.success": 2 },
|
||||
"src/components/mood/manage/tag-editor-sheet.tsx": { "toast.success": 2 },
|
||||
"src/components/mood/manage/tag-groups-card.tsx": { "toast.success": 4 },
|
||||
"src/components/mood/manage/tag-manager-card.tsx": { "toast.success": 2 },
|
||||
"src/components/mood/mood-form.tsx": { "toast.success": 1 },
|
||||
"src/components/mood/mood-list.tsx": { "toast.success": 3 },
|
||||
"src/components/onboarding/done-screen.tsx": { CheckCircle2: 2 },
|
||||
"src/components/onboarding/source-card-grid.tsx": { CheckCircle2: 2 },
|
||||
"src/components/records/allergy-form.tsx": { "toast.success": 1 },
|
||||
"src/components/records/allergy-manager.tsx": { "toast.success": 1 },
|
||||
"src/components/records/conditions-manager.tsx": { "toast.success": 1 },
|
||||
"src/components/records/family-history-form.tsx": { "toast.success": 1 },
|
||||
"src/components/records/family-history-manager.tsx": { "toast.success": 1 },
|
||||
"src/components/settings/_info-tile.tsx": { "text-success": 1 },
|
||||
"src/components/settings/about-me-section.tsx": { "toast.success": 1 },
|
||||
"src/components/settings/account-section/avatar-section.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/settings/account-section/index.tsx": { "text-success": 2 },
|
||||
"src/components/settings/advanced-section.tsx": { "text-success": 2 },
|
||||
"src/components/settings/ai/ai-insights-card.tsx": { "text-success": 1 },
|
||||
"src/components/settings/ai/anthropic-provider-form.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/settings/ai/codex-provider-form.tsx": { "text-success": 2 },
|
||||
"src/components/settings/ai/compat-provider-form.tsx": { "text-success": 1 },
|
||||
"src/components/settings/ai/fallback-chain-card.tsx": { "text-success": 1 },
|
||||
"src/components/settings/ai/local-provider-form.tsx": { "text-success": 1 },
|
||||
"src/components/settings/ai/openai-provider-form.tsx": { "text-success": 1 },
|
||||
"src/components/settings/ai/response-timeout-card.tsx": { "text-success": 1 },
|
||||
"src/components/settings/ai/runtime-actions-row.tsx": { "text-success": 2 },
|
||||
"src/components/settings/api-section.tsx": { "text-success": 3 },
|
||||
"src/components/settings/coach-memory-section.tsx": { "toast.success": 2 },
|
||||
"src/components/settings/coach-prefs-section.tsx": { "toast.success": 1 },
|
||||
"src/components/settings/dashboard-layout-section.tsx": {
|
||||
"toast.success": 2,
|
||||
},
|
||||
"src/components/settings/email-card.tsx": { "text-success": 1 },
|
||||
"src/components/settings/environment-section.tsx": { "toast.success": 4 },
|
||||
"src/components/settings/insights-pill-order-section.tsx": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
"src/components/settings/integration-status-pill.tsx": {
|
||||
CheckCircle2: 2,
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/settings/integrations/connections-panel.tsx": {
|
||||
"toast.success": 2,
|
||||
},
|
||||
"src/components/settings/integrations/fitbit-card.tsx": { "text-success": 2 },
|
||||
"src/components/settings/integrations/google-health-card.tsx": {
|
||||
"text-success": 2,
|
||||
},
|
||||
"src/components/settings/integrations/nightscout-card.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/settings/integrations/oauth-provider-card.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/settings/integrations/whoop-card.tsx": { "text-success": 2 },
|
||||
"src/components/settings/integrations/withings-card.tsx": {
|
||||
"text-success": 2,
|
||||
},
|
||||
"src/components/settings/mcp-section.tsx": {
|
||||
"text-success": 5,
|
||||
"toast.success": 2,
|
||||
},
|
||||
"src/components/settings/modules-section.tsx": { "toast.success": 1 },
|
||||
"src/components/settings/notification-status-card.tsx": {
|
||||
CheckCircle2: 2,
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/settings/ntfy-card.tsx": { "text-success": 1 },
|
||||
"src/components/settings/security-sessions-card.tsx": { "text-success": 1 },
|
||||
"src/components/settings/share-link-create-form.tsx": { "text-success": 3 },
|
||||
"src/components/settings/sharing-section.tsx": { "text-success": 1 },
|
||||
"src/components/settings/sources-section.tsx": { "toast.success": 2 },
|
||||
"src/components/settings/telegram-card.tsx": { "text-success": 1 },
|
||||
"src/components/settings/test-connection-button.tsx": {
|
||||
CheckCircle2: 2,
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/settings/thresholds-editor-section.tsx": {
|
||||
"toast.success": 2,
|
||||
},
|
||||
"src/components/settings/trusted-devices-card.tsx": { "text-success": 1 },
|
||||
"src/components/settings/web-push-card.tsx": { "text-success": 1 },
|
||||
"src/components/settings/webhook-card.tsx": { "text-success": 1 },
|
||||
"src/components/targets/target-edit-sheet.tsx": { "toast.success": 2 },
|
||||
"src/components/ui/password-strength.tsx": { "text-success": 2 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Every surface that reports the result of a write, and the shared module it
|
||||
* must render that result through. `forbidsMarkers` is set where the surface
|
||||
* carries no legitimate success affordance of its own, so any marker in the
|
||||
* file is by definition a hand-rolled verdict.
|
||||
*/
|
||||
const RESULT_SURFACES: ReadonlyArray<{
|
||||
path: string;
|
||||
through: RegExp;
|
||||
forbidsMarkers: boolean;
|
||||
}> = [
|
||||
// The import panel (#640) and the medication intake import (#650).
|
||||
{
|
||||
path: "src/components/settings/import-panel/csv-import-card.tsx",
|
||||
through: /CsvImportResultView|WrittenOutcomeLine/,
|
||||
forbidsMarkers: true,
|
||||
},
|
||||
{
|
||||
path: "src/components/settings/import-panel/json-import-card.tsx",
|
||||
through: /CsvImportResultView|WrittenOutcomeLine/,
|
||||
forbidsMarkers: true,
|
||||
},
|
||||
{
|
||||
path: "src/components/settings/import-panel/apple-health-import-card.tsx",
|
||||
through: /CsvImportResultView|WrittenOutcomeLine/,
|
||||
forbidsMarkers: true,
|
||||
},
|
||||
{
|
||||
path: "src/components/settings/import-panel/import-result-view.tsx",
|
||||
through: /WrittenOutcomeLine/,
|
||||
forbidsMarkers: true,
|
||||
},
|
||||
{
|
||||
path: "src/components/medications/intake-import-result.tsx",
|
||||
through: /WrittenOutcomeLine/,
|
||||
forbidsMarkers: true,
|
||||
},
|
||||
{
|
||||
path: "src/components/medications/intake-import-dialog.tsx",
|
||||
through: /IntakeImportResultView/,
|
||||
forbidsMarkers: true,
|
||||
},
|
||||
{
|
||||
path: "src/components/settings/import-panel/dose-history-import-card.tsx",
|
||||
through: /IntakeImportResultView/,
|
||||
forbidsMarkers: true,
|
||||
},
|
||||
{
|
||||
path: "src/components/settings/import-panel/dose-history-verdict.tsx",
|
||||
through: /IntakeImportSkipGroups/,
|
||||
forbidsMarkers: true,
|
||||
},
|
||||
// The seven surfaces that were still gated on the transport.
|
||||
{
|
||||
path: "src/components/settings/integrations/fitbit-card.tsx",
|
||||
through: /WrittenOutcomeLine/,
|
||||
forbidsMarkers: false,
|
||||
},
|
||||
{
|
||||
path: "src/components/settings/integrations/google-health-card.tsx",
|
||||
through: /WrittenOutcomeLine/,
|
||||
forbidsMarkers: false,
|
||||
},
|
||||
{
|
||||
path: "src/components/settings/integrations/nightscout-card.tsx",
|
||||
through: /WrittenOutcomeLine/,
|
||||
forbidsMarkers: false,
|
||||
},
|
||||
{
|
||||
path: "src/components/settings/integrations/whoop-card.tsx",
|
||||
through: /WrittenOutcomeLine/,
|
||||
forbidsMarkers: false,
|
||||
},
|
||||
{
|
||||
path: "src/components/settings/integrations/withings-card.tsx",
|
||||
through: /WrittenOutcomeLine/,
|
||||
forbidsMarkers: false,
|
||||
},
|
||||
{
|
||||
path: "src/components/settings/integrations/oauth-provider-card.tsx",
|
||||
through: /WrittenOutcomeLine/,
|
||||
forbidsMarkers: false,
|
||||
},
|
||||
{
|
||||
path: "src/components/labs/ocr-review-dialog.tsx",
|
||||
through: /toastWrittenOutcome/,
|
||||
forbidsMarkers: true,
|
||||
},
|
||||
];
|
||||
|
||||
/** Code only — a comment naming a marker is prose, not an affordance. */
|
||||
function stripComments(source: string): string {
|
||||
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
||||
}
|
||||
|
||||
function readCode(relativePath: string): string {
|
||||
return stripComments(readFileSync(join(REPO_ROOT, relativePath), "utf8"));
|
||||
}
|
||||
|
||||
/** Every non-test component source file, as a repo-relative posix path. */
|
||||
function componentSourceFiles(): string[] {
|
||||
const found: string[] = [];
|
||||
const walk = (dir: string) => {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
if (entry === "__tests__") continue;
|
||||
walk(full);
|
||||
continue;
|
||||
}
|
||||
if (!entry.endsWith(".ts") && !entry.endsWith(".tsx")) continue;
|
||||
found.push(relative(REPO_ROOT, full).split(sep).join(posix.sep));
|
||||
}
|
||||
};
|
||||
walk(COMPONENT_ROOT);
|
||||
return found.sort();
|
||||
}
|
||||
|
||||
function markerCounts(
|
||||
relativePath: string,
|
||||
): Partial<Record<SuccessMarker, number>> {
|
||||
const source = readCode(relativePath);
|
||||
const counts: Partial<Record<SuccessMarker, number>> = {};
|
||||
for (const marker of SUCCESS_MARKERS) {
|
||||
const uses = source.split(marker).length - 1;
|
||||
if (uses > 0) counts[marker] = uses;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
describe("success affordance — owned by the outcome module, pinned everywhere else", () => {
|
||||
it("lets no unlisted component reach for the success affordance", () => {
|
||||
const unlisted = componentSourceFiles()
|
||||
.filter((path) => !path.startsWith(PRESENTATION_DIR))
|
||||
.filter((path) => !(path in PINNED_AFFORDANCES))
|
||||
.filter((path) => Object.keys(markerCounts(path)).length > 0);
|
||||
expect(
|
||||
unlisted,
|
||||
unlisted.length > 0
|
||||
? `File(s) hand-rolling a success affordance. Render the result through ` +
|
||||
`WrittenOutcomeLine / toastWrittenOutcome, or add a pinned entry ` +
|
||||
`here with a reason in the commit message: ${unlisted.join(", ")}`
|
||||
: undefined,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("holds every listed file to the exact count it was pinned at", () => {
|
||||
const drifted: string[] = [];
|
||||
for (const [path, pinned] of Object.entries(PINNED_AFFORDANCES)) {
|
||||
if (!existsSync(join(REPO_ROOT, path))) continue;
|
||||
const actual = markerCounts(path);
|
||||
for (const marker of SUCCESS_MARKERS) {
|
||||
const was = pinned[marker] ?? 0;
|
||||
const now = actual[marker] ?? 0;
|
||||
if (was !== now) {
|
||||
drifted.push(`${path}: ${marker} pinned at ${was}, found ${now}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(
|
||||
drifted,
|
||||
drifted.length > 0
|
||||
? `A pinned file changed how often it reaches for the success ` +
|
||||
`affordance. An ADDED one is the defect this guard exists for; a ` +
|
||||
`REMOVED one is good news, update the count.\n${drifted.join("\n")}`
|
||||
: undefined,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("carries no stale entry — the guard-of-the-guard", () => {
|
||||
const stale = Object.keys(PINNED_AFFORDANCES).filter((path) => {
|
||||
if (!existsSync(join(REPO_ROOT, path))) return true;
|
||||
return Object.keys(markerCounts(path)).length === 0;
|
||||
});
|
||||
expect(
|
||||
stale,
|
||||
stale.length > 0
|
||||
? `Pinned entr(ies) that no longer carry any marker (or no longer ` +
|
||||
`exist). Delete them. If EVERY entry is listed here, the scanner ` +
|
||||
`stopped matching and the guard is inert: ${stale.join(", ")}`
|
||||
: undefined,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("routes every write-reporting surface through the shared outcome module", () => {
|
||||
for (const surface of RESULT_SURFACES) {
|
||||
expect(
|
||||
readCode(surface.path),
|
||||
`${surface.path} must render its outcome through the shared module`,
|
||||
).toMatch(surface.through);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the affordance out of the surfaces that own none of it", () => {
|
||||
const offenders = RESULT_SURFACES.filter(
|
||||
(surface) =>
|
||||
surface.forbidsMarkers &&
|
||||
Object.keys(markerCounts(surface.path)).length > 0,
|
||||
).map((surface) => surface.path);
|
||||
expect(
|
||||
offenders,
|
||||
offenders.length > 0
|
||||
? `Surface(s) hand-rolling the success affordance instead of ` +
|
||||
`classifying what was written: ${offenders.join(", ")}`
|
||||
: undefined,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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", {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
<div data-testid="intake-import-result" className="space-y-1.5">
|
||||
<ImportOutcomeLine
|
||||
<WrittenOutcomeLine
|
||||
outcome={outcome}
|
||||
message={message}
|
||||
testId="intake-import-outcome"
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* The outcome presentation, pinned per state.
|
||||
*
|
||||
* The classifier is proved in `src/lib/outcome/__tests__/written-outcome.test.ts`
|
||||
* and the structural rule in `src/__tests__/success-affordance-guard.test.ts`.
|
||||
* This is the middle piece: that the tick and the success colour are attached
|
||||
* to the `success` state and to no other, and that the toast twin picks the
|
||||
* matching sonner variant. Project convention is SSR-only component tests, so
|
||||
* the line is rendered directly.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
const toastCalls: Array<{ variant: string; message: string }> = [];
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: (m: string) => toastCalls.push({ variant: "success", message: m }),
|
||||
warning: (m: string) => toastCalls.push({ variant: "warning", message: m }),
|
||||
error: (m: string) => toastCalls.push({ variant: "error", message: m }),
|
||||
info: (m: string) => toastCalls.push({ variant: "info", message: m }),
|
||||
},
|
||||
}));
|
||||
|
||||
import { toastWrittenOutcome } from "../outcome-toast";
|
||||
import { WrittenOutcomeLine } from "../written-outcome-line";
|
||||
import type { WrittenOutcome } from "@/lib/outcome/written-outcome";
|
||||
|
||||
function render(outcome: WrittenOutcome) {
|
||||
return renderToStaticMarkup(
|
||||
<WrittenOutcomeLine outcome={outcome} message="msg" testId="probe" />,
|
||||
);
|
||||
}
|
||||
|
||||
describe("WrittenOutcomeLine", () => {
|
||||
it("carries the success colour only on the success state", () => {
|
||||
expect(render("success")).toContain("text-success");
|
||||
for (const other of ["partial", "failed", "empty"] as const) {
|
||||
expect(render(other)).not.toContain("text-success");
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes the outcome as a stable attribute for the tests below the UI", () => {
|
||||
for (const outcome of [
|
||||
"success",
|
||||
"partial",
|
||||
"failed",
|
||||
"empty",
|
||||
] as const satisfies readonly WrittenOutcome[]) {
|
||||
expect(render(outcome)).toContain(`data-outcome="${outcome}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it("alerts on a result that did not fully write, and only reports otherwise", () => {
|
||||
// A run that saved nothing is the one a user must not scroll past.
|
||||
expect(render("failed")).toContain('role="alert"');
|
||||
expect(render("partial")).toContain('role="alert"');
|
||||
expect(render("success")).toContain('role="status"');
|
||||
expect(render("empty")).toContain('role="status"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("toastWrittenOutcome", () => {
|
||||
it("raises the success toast only for a success", () => {
|
||||
toastCalls.length = 0;
|
||||
toastWrittenOutcome("success", "a");
|
||||
toastWrittenOutcome("partial", "b");
|
||||
toastWrittenOutcome("failed", "c");
|
||||
toastWrittenOutcome("empty", "d");
|
||||
expect(toastCalls).toEqual([
|
||||
{ variant: "success", message: "a" },
|
||||
{ variant: "warning", message: "b" },
|
||||
{ variant: "error", message: "c" },
|
||||
{ variant: "info", message: "d" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<p
|
||||
data-testid={testId}
|
||||
data-outcome={outcome}
|
||||
role={outcome === "failed" || outcome === "partial" ? "alert" : "status"}
|
||||
className="text-foreground flex items-start gap-2 text-xs"
|
||||
>
|
||||
<Icon
|
||||
className={`${className} mt-0.5 h-3.5 w-3.5 shrink-0`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{message}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
</div>
|
||||
)}
|
||||
{isDone && (
|
||||
<ImportOutcomeLine
|
||||
outcome={classifyImportOutcome({
|
||||
<WrittenOutcomeLine
|
||||
outcome={classifyWrittenOutcome({
|
||||
written: status?.result?.totals?.rowsUpserted ?? 0,
|
||||
// The clinical count is a documented, deliberate exclusion
|
||||
// (electrocardiograms, lab documents), not a refused row — it
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* Import result classification — the one place that decides whether an
|
||||
* import reads as a success.
|
||||
*
|
||||
* A per-row importer answers HTTP 200 for a file where every single row was
|
||||
* refused; the transport succeeded, the import did not. Rendering that as a
|
||||
* green tick is how a self-hoster's 1 597-reading sensor export could write
|
||||
* nothing and still look finished. So the counts, not the status code, pick
|
||||
* the state:
|
||||
*
|
||||
* - nothing accepted, nothing skipped → `empty` (the file carried no data)
|
||||
* - nothing accepted, rows skipped → `failed` (never a success tone)
|
||||
* - some accepted, some skipped → `partial` (a warning, not a tick)
|
||||
* - all accepted → `success`
|
||||
*
|
||||
* Both import cards render from this, and `import-result-state.test.ts` pins
|
||||
* the invariant that `success` is unreachable while `written === 0`.
|
||||
*/
|
||||
|
||||
export type ImportOutcome = "empty" | "failed" | "partial" | "success";
|
||||
|
||||
export interface ImportCounts {
|
||||
/** Rows that reached the database — inserted + updated. */
|
||||
written: number;
|
||||
/** Rows the importer refused, for any reason. */
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
export function classifyImportOutcome({
|
||||
written,
|
||||
skipped,
|
||||
}: ImportCounts): ImportOutcome {
|
||||
if (written <= 0) return skipped > 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<string, number>();
|
||||
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));
|
||||
}
|
||||
@@ -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 (
|
||||
<p
|
||||
data-testid={testId}
|
||||
data-outcome={outcome}
|
||||
className="text-foreground flex items-start gap-2 text-xs"
|
||||
>
|
||||
<Icon
|
||||
className={`${className} mt-0.5 h-3.5 w-3.5 shrink-0`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{message}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<div data-testid="import-csv-result" className="space-y-1.5">
|
||||
<ImportOutcomeLine
|
||||
<WrittenOutcomeLine
|
||||
outcome={outcome}
|
||||
message={message}
|
||||
testId="import-csv-outcome"
|
||||
|
||||
@@ -12,8 +12,8 @@ import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { ImportCardShell } from "./import-card-shell";
|
||||
import { MAX_PASTE_CHARS } from "./constants";
|
||||
import { EXAMPLE_IMPORT, parseImportJson } from "./import-examples";
|
||||
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";
|
||||
|
||||
interface JsonImportResult {
|
||||
measurements: number;
|
||||
@@ -28,7 +28,7 @@ export function JsonImportCard() {
|
||||
// concept count envelope, so a payload where every entry was refused used
|
||||
// to render the success tick. The counts decide the tone.
|
||||
const jsonOutcome = (r: JsonImportResult) =>
|
||||
classifyImportOutcome({
|
||||
classifyWrittenOutcome({
|
||||
written: r.measurements + r.moodEntries,
|
||||
skipped: r.skipped,
|
||||
});
|
||||
@@ -191,7 +191,7 @@ export function JsonImportCard() {
|
||||
|
||||
<div aria-live="polite" className="space-y-2">
|
||||
{result && (
|
||||
<ImportOutcomeLine
|
||||
<WrittenOutcomeLine
|
||||
outcome={jsonOutcome(result)}
|
||||
message={jsonMessage(result)}
|
||||
testId="import-json-result"
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* What a card is allowed to believe about a sync response.
|
||||
*
|
||||
* The cards used to read `res.ok` and print `json.data.imported`. They now
|
||||
* read the resolved outcome the route computed, and refuse to guess: an
|
||||
* envelope that does not carry all three fields is treated as a failed run,
|
||||
* not as a success with a missing count. An unreadable answer is not evidence
|
||||
* of a write.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { readSyncOutcome } from "../sync-outcome";
|
||||
|
||||
describe("readSyncOutcome", () => {
|
||||
it("narrows a well-formed envelope", () => {
|
||||
expect(
|
||||
readSyncOutcome({
|
||||
data: { imported: 0, failed: true, outcome: "failed" },
|
||||
error: null,
|
||||
}),
|
||||
).toEqual({ imported: 0, failed: true, outcome: "failed" });
|
||||
});
|
||||
|
||||
it("refuses an envelope that carries only the count", () => {
|
||||
// The pre-fix shape. A card that accepted it would be back to reporting a
|
||||
// number with no verdict attached.
|
||||
expect(readSyncOutcome({ data: { imported: 12 }, error: null })).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses an unknown outcome value", () => {
|
||||
expect(
|
||||
readSyncOutcome({
|
||||
data: { imported: 1, failed: false, outcome: "ok" },
|
||||
error: null,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses an error envelope and anything that is not one at all", () => {
|
||||
expect(readSyncOutcome({ data: null, error: "boom" })).toBeNull();
|
||||
expect(readSyncOutcome(null)).toBeNull();
|
||||
expect(readSyncOutcome("nope")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
|
||||
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
invalidateKeys,
|
||||
@@ -56,6 +57,11 @@ import {
|
||||
type IntegrationStatusViewModel,
|
||||
} from "./shared";
|
||||
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
|
||||
import {
|
||||
readSyncOutcome,
|
||||
useSyncOutcomeMessage,
|
||||
type SyncOutcomeState,
|
||||
} from "./sync-outcome";
|
||||
import {
|
||||
IntegrationCardDescription,
|
||||
IntegrationRedirectGuide,
|
||||
@@ -67,11 +73,9 @@ export function FitbitCard({
|
||||
viewModel: IntegrationStatusViewModel | undefined;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const describeSyncOutcome = useSyncOutcomeMessage();
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncMsg, setSyncMsg] = useState<string | null>(null);
|
||||
const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
|
||||
null,
|
||||
);
|
||||
const [syncResult, setSyncResult] = useState<SyncOutcomeState | null>(null);
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [clientSecret, setClientSecret] = useState("");
|
||||
const [credsSaving, setCredsSaving] = useState(false);
|
||||
@@ -116,8 +120,7 @@ export function FitbitCard({
|
||||
|
||||
async function handleSync(fullSync = false) {
|
||||
setSyncing(true);
|
||||
setSyncMsg(null);
|
||||
setSyncMsgType(null);
|
||||
setSyncResult(null);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/fitbit/sync", {
|
||||
method: "POST",
|
||||
@@ -125,23 +128,32 @@ export function FitbitCard({
|
||||
body: JSON.stringify({ fullSync }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (res.ok) {
|
||||
setSyncMsg(
|
||||
t("settings.fitbitSyncResult", { count: json.data.imported }),
|
||||
);
|
||||
setSyncMsgType("success");
|
||||
const result = res.ok ? readSyncOutcome(json) : null;
|
||||
if (result) {
|
||||
// The tone comes off what the run wrote, not off `res.ok`.
|
||||
setSyncResult({
|
||||
outcome: result.outcome,
|
||||
message: describeSyncOutcome(
|
||||
result,
|
||||
t("settings.fitbitSyncResult", { count: result.imported }),
|
||||
),
|
||||
});
|
||||
void invalidateKeys(queryClient, measurementDependentKeys);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
});
|
||||
} else {
|
||||
setSyncMsg(json.error || t("settings.fitbitSyncFailed"));
|
||||
setSyncMsgType("error");
|
||||
setSyncResult({
|
||||
outcome: "failed",
|
||||
message: json?.error || t("settings.fitbitSyncFailed"),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setSyncMsg(t("settings.fitbitSyncFailed"));
|
||||
setSyncMsgType("error");
|
||||
setSyncResult({
|
||||
outcome: "failed",
|
||||
message: t("settings.fitbitSyncFailed"),
|
||||
});
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
@@ -472,13 +484,12 @@ export function FitbitCard({
|
||||
{t("settings.fitbitBackfillInProgress")}
|
||||
</p>
|
||||
)}
|
||||
{syncMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${syncMsgType === "success" ? "text-success" : "text-destructive"}`}
|
||||
>
|
||||
{syncMsg}
|
||||
</p>
|
||||
{syncResult && (
|
||||
<WrittenOutcomeLine
|
||||
outcome={syncResult.outcome}
|
||||
message={syncResult.message}
|
||||
testId="fitbit-sync-result"
|
||||
/>
|
||||
)}
|
||||
{/* connect→data loop: a discreet link to where this provider's
|
||||
readings now surface — doubles as the "your data is richer"
|
||||
|
||||
@@ -50,6 +50,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
|
||||
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
invalidateKeys,
|
||||
@@ -64,6 +65,11 @@ import {
|
||||
type IntegrationStatusViewModel,
|
||||
} from "./shared";
|
||||
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
|
||||
import {
|
||||
readSyncOutcome,
|
||||
useSyncOutcomeMessage,
|
||||
type SyncOutcomeState,
|
||||
} from "./sync-outcome";
|
||||
import { IntegrationCardDescription } from "./setup-guide-link";
|
||||
|
||||
export function GoogleHealthCard({
|
||||
@@ -72,11 +78,9 @@ export function GoogleHealthCard({
|
||||
viewModel: IntegrationStatusViewModel | undefined;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const describeSyncOutcome = useSyncOutcomeMessage();
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncMsg, setSyncMsg] = useState<string | null>(null);
|
||||
const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
|
||||
null,
|
||||
);
|
||||
const [syncResult, setSyncResult] = useState<SyncOutcomeState | null>(null);
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [clientSecret, setClientSecret] = useState("");
|
||||
const [credsSaving, setCredsSaving] = useState(false);
|
||||
@@ -120,8 +124,7 @@ export function GoogleHealthCard({
|
||||
|
||||
async function handleSync(fullSync = false) {
|
||||
setSyncing(true);
|
||||
setSyncMsg(null);
|
||||
setSyncMsgType(null);
|
||||
setSyncResult(null);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/google-health/sync", {
|
||||
method: "POST",
|
||||
@@ -129,23 +132,32 @@ export function GoogleHealthCard({
|
||||
body: JSON.stringify({ fullSync }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (res.ok) {
|
||||
setSyncMsg(
|
||||
t("settings.googleHealthSyncResult", { count: json.data.imported }),
|
||||
);
|
||||
setSyncMsgType("success");
|
||||
const result = res.ok ? readSyncOutcome(json) : null;
|
||||
if (result) {
|
||||
// The tone comes off what the run wrote, not off `res.ok`.
|
||||
setSyncResult({
|
||||
outcome: result.outcome,
|
||||
message: describeSyncOutcome(
|
||||
result,
|
||||
t("settings.googleHealthSyncResult", { count: result.imported }),
|
||||
),
|
||||
});
|
||||
void invalidateKeys(queryClient, measurementDependentKeys);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.googleHealth() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
});
|
||||
} else {
|
||||
setSyncMsg(json.error || t("settings.googleHealthSyncFailed"));
|
||||
setSyncMsgType("error");
|
||||
setSyncResult({
|
||||
outcome: "failed",
|
||||
message: json?.error || t("settings.googleHealthSyncFailed"),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setSyncMsg(t("settings.googleHealthSyncFailed"));
|
||||
setSyncMsgType("error");
|
||||
setSyncResult({
|
||||
outcome: "failed",
|
||||
message: t("settings.googleHealthSyncFailed"),
|
||||
});
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
@@ -501,13 +513,12 @@ export function GoogleHealthCard({
|
||||
{t("settings.googleHealthBackfillInProgress")}
|
||||
</p>
|
||||
)}
|
||||
{syncMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${syncMsgType === "success" ? "text-success" : "text-destructive"}`}
|
||||
>
|
||||
{syncMsg}
|
||||
</p>
|
||||
{syncResult && (
|
||||
<WrittenOutcomeLine
|
||||
outcome={syncResult.outcome}
|
||||
message={syncResult.message}
|
||||
testId="google-health-sync-result"
|
||||
/>
|
||||
)}
|
||||
{/* connect→data loop: a discreet link to where this provider's
|
||||
readings now surface — doubles as the "your data is richer"
|
||||
|
||||
@@ -43,6 +43,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
|
||||
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
invalidateKeys,
|
||||
@@ -51,6 +52,11 @@ import {
|
||||
} from "@/lib/query-keys";
|
||||
|
||||
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
|
||||
import {
|
||||
readSyncOutcome,
|
||||
useSyncOutcomeMessage,
|
||||
type SyncOutcomeState,
|
||||
} from "./sync-outcome";
|
||||
import {
|
||||
IntegrationErrorMessage,
|
||||
pillStateForVerdict,
|
||||
@@ -65,6 +71,7 @@ export function NightscoutCard({
|
||||
viewModel?: IntegrationStatusViewModel;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const describeSyncOutcome = useSyncOutcomeMessage();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [url, setUrl] = useState("");
|
||||
@@ -74,10 +81,7 @@ export function NightscoutCard({
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncMsg, setSyncMsg] = useState<string | null>(null);
|
||||
const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
|
||||
null,
|
||||
);
|
||||
const [syncResult, setSyncResult] = useState<SyncOutcomeState | null>(null);
|
||||
|
||||
const status = viewModel;
|
||||
|
||||
@@ -144,27 +148,35 @@ export function NightscoutCard({
|
||||
// full-history dialog — Nightscout has no full-sync arm to call.
|
||||
async function handleSync() {
|
||||
setSyncing(true);
|
||||
setSyncMsg(null);
|
||||
setSyncMsgType(null);
|
||||
setSyncResult(null);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/nightscout/sync", { method: "POST" });
|
||||
const json = await res.json();
|
||||
if (res.ok) {
|
||||
setSyncMsg(
|
||||
t("settings.nightscoutSyncResult", { count: json.data.imported }),
|
||||
);
|
||||
setSyncMsgType("success");
|
||||
const result = res.ok ? readSyncOutcome(json) : null;
|
||||
if (result) {
|
||||
// The tone comes off what the run wrote, not off `res.ok`.
|
||||
setSyncResult({
|
||||
outcome: result.outcome,
|
||||
message: describeSyncOutcome(
|
||||
result,
|
||||
t("settings.nightscoutSyncResult", { count: result.imported }),
|
||||
),
|
||||
});
|
||||
void invalidateKeys(queryClient, measurementDependentKeys);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
});
|
||||
} else {
|
||||
setSyncMsg(json.error || t("settings.nightscoutSyncFailed"));
|
||||
setSyncMsgType("error");
|
||||
setSyncResult({
|
||||
outcome: "failed",
|
||||
message: json?.error || t("settings.nightscoutSyncFailed"),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setSyncMsg(t("settings.nightscoutSyncFailed"));
|
||||
setSyncMsgType("error");
|
||||
setSyncResult({
|
||||
outcome: "failed",
|
||||
message: t("settings.nightscoutSyncFailed"),
|
||||
});
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
@@ -401,14 +413,12 @@ export function NightscoutCard({
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
{syncMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
data-testid="nightscout-sync-result"
|
||||
className={`text-sm ${syncMsgType === "success" ? "text-success" : "text-destructive"}`}
|
||||
>
|
||||
{syncMsg}
|
||||
</p>
|
||||
{syncResult && (
|
||||
<WrittenOutcomeLine
|
||||
outcome={syncResult.outcome}
|
||||
message={syncResult.message}
|
||||
testId="nightscout-sync-result"
|
||||
/>
|
||||
)}
|
||||
{/* connect→data loop: a discreet link to where the glucose
|
||||
readings now surface — doubles as the "your data is richer"
|
||||
|
||||
@@ -41,6 +41,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
|
||||
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type {
|
||||
MetricFreshnessEntry,
|
||||
@@ -52,6 +53,11 @@ import {
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
|
||||
import {
|
||||
readSyncOutcome,
|
||||
useSyncOutcomeMessage,
|
||||
type SyncOutcomeState,
|
||||
} from "./sync-outcome";
|
||||
import { IntegrationErrorMessage, pillStateForVerdict } from "./shared";
|
||||
import {
|
||||
IntegrationCardDescription,
|
||||
@@ -116,13 +122,11 @@ export function OAuthProviderCard({
|
||||
viewModel,
|
||||
}: OAuthProviderCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const describeSyncOutcome = useSyncOutcomeMessage();
|
||||
const queryClient = useQueryClient();
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncMsg, setSyncMsg] = useState<string | null>(null);
|
||||
const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
|
||||
null,
|
||||
);
|
||||
const [syncResult, setSyncResult] = useState<SyncOutcomeState | null>(null);
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [clientSecret, setClientSecret] = useState("");
|
||||
const [credsSaving, setCredsSaving] = useState(false);
|
||||
@@ -137,28 +141,38 @@ export function OAuthProviderCard({
|
||||
// full-history dialog — none of these providers has a full-sync arm to call.
|
||||
async function handleSync() {
|
||||
setSyncing(true);
|
||||
setSyncMsg(null);
|
||||
setSyncMsgType(null);
|
||||
setSyncResult(null);
|
||||
try {
|
||||
const res = await apiFetchRaw(`/api/${provider}/sync`, {
|
||||
method: "POST",
|
||||
});
|
||||
const json = await res.json();
|
||||
if (res.ok) {
|
||||
setSyncMsg(t(`${i18nPrefix}SyncResult`, { count: json.data.imported }));
|
||||
setSyncMsgType("success");
|
||||
const result = res.ok ? readSyncOutcome(json) : null;
|
||||
if (result) {
|
||||
// The tone comes off what the run wrote, not off `res.ok`.
|
||||
setSyncResult({
|
||||
outcome: result.outcome,
|
||||
message: describeSyncOutcome(
|
||||
result,
|
||||
t(`${i18nPrefix}SyncResult`, { count: result.imported }),
|
||||
),
|
||||
});
|
||||
void invalidateKeys(queryClient, measurementDependentKeys);
|
||||
queryClient.invalidateQueries({ queryKey: statusQueryKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
});
|
||||
} else {
|
||||
setSyncMsg(json.error || t(`${i18nPrefix}SyncFailed`));
|
||||
setSyncMsgType("error");
|
||||
setSyncResult({
|
||||
outcome: "failed",
|
||||
message: json?.error || t(`${i18nPrefix}SyncFailed`),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setSyncMsg(t(`${i18nPrefix}SyncFailed`));
|
||||
setSyncMsgType("error");
|
||||
setSyncResult({
|
||||
outcome: "failed",
|
||||
message: t(`${i18nPrefix}SyncFailed`),
|
||||
});
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
@@ -463,14 +477,12 @@ export function OAuthProviderCard({
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
{syncMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
data-testid={`${provider}-sync-result`}
|
||||
className={`text-sm ${syncMsgType === "success" ? "text-success" : "text-destructive"}`}
|
||||
>
|
||||
{syncMsg}
|
||||
</p>
|
||||
{syncResult && (
|
||||
<WrittenOutcomeLine
|
||||
outcome={syncResult.outcome}
|
||||
message={syncResult.message}
|
||||
testId={`${provider}-sync-result`}
|
||||
/>
|
||||
)}
|
||||
{/* connect→data loop: a discreet link to where this provider's
|
||||
readings now surface — doubles as the "your data is richer"
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { WrittenOutcome } from "@/lib/outcome/written-outcome";
|
||||
|
||||
/**
|
||||
* What every provider-sync route answers with, and how a card says it.
|
||||
*
|
||||
* The cards used to key their tone off `res.ok`: the request came back, so the
|
||||
* line went green and printed a count. A run that imported nothing because
|
||||
* every reading was refused looked exactly like a clean one. The route now
|
||||
* resolves the outcome from what was written, and the card only renders it.
|
||||
*/
|
||||
export interface SyncOutcomeResponse {
|
||||
/** Rows that reached the database this run. */
|
||||
imported: number;
|
||||
/** True when any part of the run did not land. */
|
||||
failed: boolean;
|
||||
/** The resolved verdict — the card never recomputes this. */
|
||||
outcome: WrittenOutcome;
|
||||
}
|
||||
|
||||
/** What a card holds after a sync attempt, transport failures included. */
|
||||
export interface SyncOutcomeState {
|
||||
outcome: WrittenOutcome;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow an envelope body to the resolved outcome, or `null` when the route
|
||||
* answered something else. A card that gets `null` treats the run as failed
|
||||
* rather than guessing — an unreadable answer is not evidence of a write.
|
||||
*/
|
||||
export function readSyncOutcome(body: unknown): SyncOutcomeResponse | null {
|
||||
if (typeof body !== "object" || body === null) return null;
|
||||
const data = (body as { data?: unknown }).data;
|
||||
if (typeof data !== "object" || data === null) return null;
|
||||
const { imported, failed, outcome } = data as Record<string, unknown>;
|
||||
if (typeof imported !== "number") return null;
|
||||
if (typeof failed !== "boolean") return null;
|
||||
if (
|
||||
outcome !== "empty" &&
|
||||
outcome !== "failed" &&
|
||||
outcome !== "partial" &&
|
||||
outcome !== "success"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { imported, failed, outcome };
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a resolved outcome into the sentence a card shows.
|
||||
*
|
||||
* The success wording stays per-provider (it names the provider's own count),
|
||||
* so the caller passes it in; everything else is shared, because "some of it
|
||||
* did not save" reads the same whoever the provider is. Written as literal
|
||||
* `t(...)` calls so the i18n call-site coverage guard sees every key.
|
||||
*/
|
||||
export function useSyncOutcomeMessage(): (
|
||||
result: SyncOutcomeResponse,
|
||||
successMessage: string,
|
||||
) => string {
|
||||
const { t } = useTranslations();
|
||||
return (result, successMessage) => {
|
||||
switch (result.outcome) {
|
||||
case "success":
|
||||
return successMessage;
|
||||
case "empty":
|
||||
return t("settings.syncOutcome.empty");
|
||||
case "partial":
|
||||
return t("settings.syncOutcome.partial", { count: result.imported });
|
||||
case "failed":
|
||||
return t("settings.syncOutcome.failed");
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
|
||||
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
invalidateKeys,
|
||||
@@ -47,6 +48,11 @@ import {
|
||||
type IntegrationStatusViewModel,
|
||||
} from "./shared";
|
||||
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
|
||||
import {
|
||||
readSyncOutcome,
|
||||
useSyncOutcomeMessage,
|
||||
type SyncOutcomeState,
|
||||
} from "./sync-outcome";
|
||||
import {
|
||||
IntegrationCardDescription,
|
||||
IntegrationRedirectGuide,
|
||||
@@ -58,11 +64,9 @@ export function WhoopCard({
|
||||
viewModel: IntegrationStatusViewModel | undefined;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const describeSyncOutcome = useSyncOutcomeMessage();
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncMsg, setSyncMsg] = useState<string | null>(null);
|
||||
const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
|
||||
null,
|
||||
);
|
||||
const [syncResult, setSyncResult] = useState<SyncOutcomeState | null>(null);
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [clientSecret, setClientSecret] = useState("");
|
||||
const [credsSaving, setCredsSaving] = useState(false);
|
||||
@@ -109,8 +113,7 @@ export function WhoopCard({
|
||||
|
||||
async function handleSync(fullSync = false) {
|
||||
setSyncing(true);
|
||||
setSyncMsg(null);
|
||||
setSyncMsgType(null);
|
||||
setSyncResult(null);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/whoop/sync", {
|
||||
method: "POST",
|
||||
@@ -118,23 +121,32 @@ export function WhoopCard({
|
||||
body: JSON.stringify({ fullSync }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (res.ok) {
|
||||
setSyncMsg(
|
||||
t("settings.whoopSyncResult", { count: json.data.imported }),
|
||||
);
|
||||
setSyncMsgType("success");
|
||||
const result = res.ok ? readSyncOutcome(json) : null;
|
||||
if (result) {
|
||||
// The tone comes off what the run wrote, not off `res.ok`.
|
||||
setSyncResult({
|
||||
outcome: result.outcome,
|
||||
message: describeSyncOutcome(
|
||||
result,
|
||||
t("settings.whoopSyncResult", { count: result.imported }),
|
||||
),
|
||||
});
|
||||
void invalidateKeys(queryClient, measurementDependentKeys);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.whoop() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
});
|
||||
} else {
|
||||
setSyncMsg(json.error || t("settings.whoopSyncFailed"));
|
||||
setSyncMsgType("error");
|
||||
setSyncResult({
|
||||
outcome: "failed",
|
||||
message: json?.error || t("settings.whoopSyncFailed"),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setSyncMsg(t("settings.whoopSyncFailed"));
|
||||
setSyncMsgType("error");
|
||||
setSyncResult({
|
||||
outcome: "failed",
|
||||
message: t("settings.whoopSyncFailed"),
|
||||
});
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
@@ -454,13 +466,12 @@ export function WhoopCard({
|
||||
{t("settings.whoopBackfillInProgress")}
|
||||
</p>
|
||||
)}
|
||||
{syncMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${syncMsgType === "success" ? "text-success" : "text-destructive"}`}
|
||||
>
|
||||
{syncMsg}
|
||||
</p>
|
||||
{syncResult && (
|
||||
<WrittenOutcomeLine
|
||||
outcome={syncResult.outcome}
|
||||
message={syncResult.message}
|
||||
testId="whoop-sync-result"
|
||||
/>
|
||||
)}
|
||||
{/* connect→data loop: a discreet link to where this provider's
|
||||
readings now surface — doubles as the "your data is richer"
|
||||
|
||||
@@ -32,6 +32,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
|
||||
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
invalidateKeys,
|
||||
@@ -46,6 +47,11 @@ import {
|
||||
type IntegrationStatusViewModel,
|
||||
} from "./shared";
|
||||
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
|
||||
import {
|
||||
readSyncOutcome,
|
||||
useSyncOutcomeMessage,
|
||||
type SyncOutcomeState,
|
||||
} from "./sync-outcome";
|
||||
import {
|
||||
IntegrationCardDescription,
|
||||
IntegrationRedirectGuide,
|
||||
@@ -57,11 +63,9 @@ export function WithingsCard({
|
||||
viewModel: IntegrationStatusViewModel | undefined;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const describeSyncOutcome = useSyncOutcomeMessage();
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncMsg, setSyncMsg] = useState<string | null>(null);
|
||||
const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
|
||||
null,
|
||||
);
|
||||
const [syncResult, setSyncResult] = useState<SyncOutcomeState | null>(null);
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [clientSecret, setClientSecret] = useState("");
|
||||
const [credsSaving, setCredsSaving] = useState(false);
|
||||
@@ -111,8 +115,7 @@ export function WithingsCard({
|
||||
|
||||
async function handleSync(fullSync = false) {
|
||||
setSyncing(true);
|
||||
setSyncMsg(null);
|
||||
setSyncMsgType(null);
|
||||
setSyncResult(null);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/withings/sync", {
|
||||
method: "POST",
|
||||
@@ -120,26 +123,33 @@ export function WithingsCard({
|
||||
body: JSON.stringify({ fullSync }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (res.ok) {
|
||||
setSyncMsg(
|
||||
fullSync
|
||||
? t("settings.withingsFullSyncResult", {
|
||||
count: json.data.imported,
|
||||
})
|
||||
: t("settings.withingsSyncResult", { count: json.data.imported }),
|
||||
);
|
||||
setSyncMsgType("success");
|
||||
const result = res.ok ? readSyncOutcome(json) : null;
|
||||
if (result) {
|
||||
// The tone comes off what the run wrote, not off `res.ok`.
|
||||
setSyncResult({
|
||||
outcome: result.outcome,
|
||||
message: describeSyncOutcome(
|
||||
result,
|
||||
fullSync
|
||||
? t("settings.withingsFullSyncResult", { count: result.imported })
|
||||
: t("settings.withingsSyncResult", { count: result.imported }),
|
||||
),
|
||||
});
|
||||
void invalidateKeys(queryClient, measurementDependentKeys);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
});
|
||||
} else {
|
||||
setSyncMsg(json.error || t("settings.withingsSyncFailed"));
|
||||
setSyncMsgType("error");
|
||||
setSyncResult({
|
||||
outcome: "failed",
|
||||
message: json?.error || t("settings.withingsSyncFailed"),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setSyncMsg(t("settings.withingsSyncFailed"));
|
||||
setSyncMsgType("error");
|
||||
setSyncResult({
|
||||
outcome: "failed",
|
||||
message: t("settings.withingsSyncFailed"),
|
||||
});
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
@@ -505,13 +515,12 @@ export function WithingsCard({
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
{syncMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${syncMsgType === "success" ? "text-success" : "text-destructive"}`}
|
||||
>
|
||||
{syncMsg}
|
||||
</p>
|
||||
{syncResult && (
|
||||
<WrittenOutcomeLine
|
||||
outcome={syncResult.outcome}
|
||||
message={syncResult.message}
|
||||
testId="withings-sync-result"
|
||||
/>
|
||||
)}
|
||||
{/* connect→data loop: a discreet link to where this provider's
|
||||
readings now surface — doubles as the "your data is richer"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<number> {
|
||||
export async function syncUserPolarLegs(
|
||||
userId: string,
|
||||
): Promise<SyncWriteResult> {
|
||||
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<number> {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
+20
-10
@@ -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<number> {
|
||||
): Promise<SyncWriteResult> {
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
+13
-7
@@ -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<number> {
|
||||
): Promise<SyncWriteResult> {
|
||||
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(
|
||||
|
||||
+53
-13
@@ -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) => ({
|
||||
@@ -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<string, number>();
|
||||
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));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
+10
-7
@@ -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<number> {
|
||||
export async function syncUserPolar(userId: string): Promise<SyncWriteResult> {
|
||||
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<number> {
|
||||
? String(firstErr.httpStatus)
|
||||
: undefined,
|
||||
});
|
||||
return imported;
|
||||
return { imported, failed: true };
|
||||
}
|
||||
|
||||
await recordSyncSuccess(userId, "polar", { leg: POLAR_LEG_VITALS });
|
||||
return imported;
|
||||
return { imported, failed: false };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<number> {
|
||||
): Promise<SyncWriteResult> {
|
||||
// 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user