diff --git a/messages/de.json b/messages/de.json
index e633cfc99..6e7ec78e3 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -5148,6 +5148,11 @@
"withingsFullSyncTitle": "Vollsynchronisierung?",
"withingsFullSyncDescription": "Alle verfügbaren Withings-Daten werden vollständig synchronisiert. Das kann je nach Historie etwas dauern.",
"withingsSyncResult": "{count} Messwerte synchronisiert",
+ "syncOutcome": {
+ "empty": "Nichts Neues zu importieren. Alles ist aktuell.",
+ "failed": "Es wurde nichts gespeichert. Der nächste Lauf versucht es erneut.",
+ "partial": "{count} Messwerte synchronisiert. Ein Teil der Daten konnte nicht gespeichert werden; der nächste Lauf versucht es erneut."
+ },
"withingsFullSyncResult": "{count} Messwerte vollständig synchronisiert",
"withingsSyncFailed": "Sync fehlgeschlagen",
"withingsOauthConnected": "Withings verbunden — der erste Sync startet im Hintergrund.",
@@ -7873,6 +7878,9 @@
"saveSelected": "{count} ausgewählte speichern",
"discardAll": "Alle verwerfen",
"savedToast": "{count} Laborwerte gespeichert",
+ "savedPartialToast": "{count} Laborwerte gespeichert, {skipped} als Dubletten übersprungen",
+ "savedNothingToast": "Nichts gespeichert: {skipped} Werte waren bereits erfasst",
+ "savedEmptyToast": "Nichts zu speichern",
"noRows": "In diesem Befund konnten keine Werte gefunden werden.",
"nothingToSave": "Wähle mindestens einen Wert zum Speichern aus.",
"saveFailed": "Werte konnten nicht gespeichert werden. Bitte erneut versuchen.",
diff --git a/messages/en.json b/messages/en.json
index d7183b5c9..2cb2ed52b 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5148,6 +5148,11 @@
"withingsFullSyncTitle": "Full synchronization?",
"withingsFullSyncDescription": "All available Withings data will be fully synchronized. This may take some time depending on your history.",
"withingsSyncResult": "{count} measurements synchronized",
+ "syncOutcome": {
+ "empty": "Nothing new to import. Everything is up to date.",
+ "failed": "Nothing was saved. The next run retries it.",
+ "partial": "{count} measurements synchronized. Some data could not be saved; the next run retries it."
+ },
"withingsFullSyncResult": "{count} measurements fully synchronized",
"withingsSyncFailed": "Sync failed",
"withingsOauthConnected": "Withings connected — the first sync starts in the background.",
@@ -7873,6 +7878,9 @@
"saveSelected": "Save {count} selected",
"discardAll": "Discard all",
"savedToast": "{count} lab results saved",
+ "savedPartialToast": "{count} lab results saved, {skipped} skipped as duplicates",
+ "savedNothingToast": "Nothing saved: {skipped} readings were already recorded",
+ "savedEmptyToast": "Nothing to save",
"noRows": "No readings could be found in this report.",
"nothingToSave": "Select at least one reading to save.",
"saveFailed": "Couldn't save the readings. Please try again.",
diff --git a/messages/es.json b/messages/es.json
index 642747c38..0fdf76133 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -5148,6 +5148,11 @@
"withingsFullSyncTitle": "¿Sincronización completa?",
"withingsFullSyncDescription": "Se sincronizarán por completo todos los datos disponibles de Withings. Esto puede tardar un poco según tu historial.",
"withingsSyncResult": "{count} mediciones sincronizadas",
+ "syncOutcome": {
+ "empty": "No hay nada nuevo que importar. Todo está al día.",
+ "failed": "No se guardó nada. El próximo intento lo repetirá.",
+ "partial": "{count} mediciones sincronizadas. Parte de los datos no se pudo guardar; el próximo intento lo repetirá."
+ },
"withingsFullSyncResult": "{count} mediciones sincronizadas por completo",
"withingsSyncFailed": "Falló la sincronización",
"withingsOauthConnected": "Withings conectado — la primera sincronización comienza en segundo plano.",
@@ -7873,6 +7878,9 @@
"saveSelected": "Guardar {count} seleccionados",
"discardAll": "Descartar todo",
"savedToast": "{count} resultados de laboratorio guardados",
+ "savedPartialToast": "{count} resultados de laboratorio guardados, {skipped} omitidos por duplicados",
+ "savedNothingToast": "No se guardó nada: {skipped} valores ya estaban registrados",
+ "savedEmptyToast": "No hay nada que guardar",
"noRows": "No se encontraron valores en este informe.",
"nothingToSave": "Selecciona al menos un valor para guardar.",
"saveFailed": "No se pudieron guardar los valores. Inténtalo de nuevo.",
diff --git a/messages/fr.json b/messages/fr.json
index 604b61659..dab63fc75 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -5148,6 +5148,11 @@
"withingsFullSyncTitle": "Synchronisation complète ?",
"withingsFullSyncDescription": "Toutes les données Withings disponibles seront entièrement synchronisées. Cela peut prendre un certain temps selon votre historique.",
"withingsSyncResult": "{count} mesures synchronisées",
+ "syncOutcome": {
+ "empty": "Rien de nouveau à importer. Tout est à jour.",
+ "failed": "Rien n'a été enregistré. La prochaine synchronisation réessaiera.",
+ "partial": "{count} mesures synchronisées. Une partie des données n'a pas pu être enregistrée ; la prochaine synchronisation réessaiera."
+ },
"withingsFullSyncResult": "{count} mesures entièrement synchronisées",
"withingsSyncFailed": "Échec de la synchronisation",
"withingsOauthConnected": "Withings connecté — la première synchronisation démarre en arrière-plan.",
@@ -7873,6 +7878,9 @@
"saveSelected": "Enregistrer {count} sélectionnées",
"discardAll": "Tout abandonner",
"savedToast": "{count} résultats de laboratoire enregistrés",
+ "savedPartialToast": "{count} résultats de laboratoire enregistrés, {skipped} ignorés car en double",
+ "savedNothingToast": "Rien enregistré : {skipped} valeurs étaient déjà présentes",
+ "savedEmptyToast": "Rien à enregistrer",
"noRows": "Aucune valeur n'a pu être trouvée dans ce compte rendu.",
"nothingToSave": "Sélectionnez au moins une valeur à enregistrer.",
"saveFailed": "Impossible d'enregistrer les valeurs. Veuillez réessayer.",
diff --git a/messages/it.json b/messages/it.json
index 715a74ce6..4967cf325 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -5148,6 +5148,11 @@
"withingsFullSyncTitle": "Sincronizzazione completa?",
"withingsFullSyncDescription": "Tutti i dati Withings disponibili verranno sincronizzati completamente. Potrebbe richiedere del tempo a seconda del tuo storico.",
"withingsSyncResult": "{count} misurazioni sincronizzate",
+ "syncOutcome": {
+ "empty": "Non c'è nulla di nuovo da importare. Tutto è aggiornato.",
+ "failed": "Non è stato salvato nulla. La prossima esecuzione riprova.",
+ "partial": "{count} misurazioni sincronizzate. Una parte dei dati non è stata salvata; la prossima esecuzione riprova."
+ },
"withingsFullSyncResult": "{count} misurazioni sincronizzate completamente",
"withingsSyncFailed": "Sincronizzazione non riuscita",
"withingsOauthConnected": "Withings collegato — la prima sincronizzazione parte in background.",
@@ -7873,6 +7878,9 @@
"saveSelected": "Salva {count} selezionati",
"discardAll": "Scarta tutto",
"savedToast": "{count} risultati di laboratorio salvati",
+ "savedPartialToast": "{count} risultati di laboratorio salvati, {skipped} ignorati perché duplicati",
+ "savedNothingToast": "Nulla salvato: {skipped} valori erano già registrati",
+ "savedEmptyToast": "Nulla da salvare",
"noRows": "Nessun valore trovato in questo referto.",
"nothingToSave": "Seleziona almeno un valore da salvare.",
"saveFailed": "Impossibile salvare i valori. Riprova.",
diff --git a/messages/pl.json b/messages/pl.json
index 927e9b7e5..273d0af37 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -5148,6 +5148,11 @@
"withingsFullSyncTitle": "Pełna synchronizacja?",
"withingsFullSyncDescription": "Wszystkie dostępne dane Withings zostaną w pełni zsynchronizowane. Może to chwilę potrwać w zależności od historii.",
"withingsSyncResult": "{count} pomiarów zsynchronizowano",
+ "syncOutcome": {
+ "empty": "Nie ma nic nowego do zaimportowania. Wszystko jest aktualne.",
+ "failed": "Nic nie zostało zapisane. Następne uruchomienie spróbuje ponownie.",
+ "partial": "Zsynchronizowano {count} pomiarów. Części danych nie udało się zapisać; następne uruchomienie spróbuje ponownie."
+ },
"withingsFullSyncResult": "{count} pomiarów w pełni zsynchronizowano",
"withingsSyncFailed": "Synchronizacja nie powiodła się",
"withingsOauthConnected": "Withings połączony — pierwsza synchronizacja rozpocznie się w tle.",
@@ -7873,6 +7878,9 @@
"saveSelected": "Zapisz {count} wybranych",
"discardAll": "Odrzuć wszystko",
"savedToast": "Zapisano {count} wyników laboratoryjnych",
+ "savedPartialToast": "Zapisano {count} wyników laboratoryjnych, {skipped} pominięto jako duplikaty",
+ "savedNothingToast": "Nic nie zapisano: {skipped} wyników było już zarejestrowanych",
+ "savedEmptyToast": "Nie ma nic do zapisania",
"noRows": "W tym wyniku nie znaleziono żadnych wartości.",
"nothingToSave": "Wybierz co najmniej jeden wynik do zapisania.",
"saveFailed": "Nie udało się zapisać wyników. Spróbuj ponownie.",
diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts
new file mode 100644
index 000000000..2bec7a7b9
--- /dev/null
+++ b/src/__tests__/success-affordance-guard.test.ts
@@ -0,0 +1,558 @@
+/**
+ * G3 — the success affordance is owned by the outcome module, everywhere.
+ *
+ * #640 established the rule inside the import panel: a write that wrote
+ * nothing must not read as a success. #650 widened it to the medication
+ * intake import. This widens it to the whole component tree, because the same
+ * defect had been sitting in seven more surfaces the whole time — six
+ * integration cards and the lab-OCR dialog reported "success" off `res.ok`,
+ * so a sync that imported nothing because every reading was refused rendered
+ * a green tick and a count.
+ *
+ * What this asserts:
+ * 1. Every `CheckCircle2` / `text-success` / `toast.success` occurrence
+ * under `src/components` is either inside the shared presentation module
+ * (`src/components/outcome/`) or pinned in `PINNED_AFFORDANCES` with an
+ * exact per-file count. A name-only allowlist would let a listed file add
+ * a SECOND, dishonest tick without anyone noticing, which is why the
+ * counts are pinned rather than the filenames.
+ * 2. No pinned entry has gone stale — a file that no longer carries the
+ * marker, or no longer exists, fails here rather than rotting silently.
+ * This is the guard-of-the-guard: if the scanner ever stopped matching,
+ * every entry would go stale at once and this is what would say so.
+ * 3. Every surface that reports a write routes its result line through the
+ * shared outcome module.
+ *
+ * HOW WEAK THIS GUARD IS, stated plainly: this is the weakest guard in the
+ * release and it is not complete. It matches three literal tokens. It is
+ * evadable by synonym — `text-emerald-500`, a raw checkmark glyph, an
+ * `
)}
- {syncMsg && (
-
- {syncMsg}
-
+ {syncResult && (
+
)}
{/* connect→data loop: a discreet link to where this provider's
readings now surface — doubles as the "your data is richer"
diff --git a/src/components/settings/integrations/google-health-card.tsx b/src/components/settings/integrations/google-health-card.tsx
index f88d2f591..f6efb5a58 100644
--- a/src/components/settings/integrations/google-health-card.tsx
+++ b/src/components/settings/integrations/google-health-card.tsx
@@ -50,6 +50,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
+import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
@@ -64,6 +65,11 @@ import {
type IntegrationStatusViewModel,
} from "./shared";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
+import {
+ readSyncOutcome,
+ useSyncOutcomeMessage,
+ type SyncOutcomeState,
+} from "./sync-outcome";
import { IntegrationCardDescription } from "./setup-guide-link";
export function GoogleHealthCard({
@@ -72,11 +78,9 @@ export function GoogleHealthCard({
viewModel: IntegrationStatusViewModel | undefined;
}) {
const { t } = useTranslations();
+ const describeSyncOutcome = useSyncOutcomeMessage();
const [syncing, setSyncing] = useState(false);
- const [syncMsg, setSyncMsg] = useState(null);
- const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
- null,
- );
+ const [syncResult, setSyncResult] = useState(null);
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [credsSaving, setCredsSaving] = useState(false);
@@ -120,8 +124,7 @@ export function GoogleHealthCard({
async function handleSync(fullSync = false) {
setSyncing(true);
- setSyncMsg(null);
- setSyncMsgType(null);
+ setSyncResult(null);
try {
const res = await apiFetchRaw("/api/google-health/sync", {
method: "POST",
@@ -129,23 +132,32 @@ export function GoogleHealthCard({
body: JSON.stringify({ fullSync }),
});
const json = await res.json();
- if (res.ok) {
- setSyncMsg(
- t("settings.googleHealthSyncResult", { count: json.data.imported }),
- );
- setSyncMsgType("success");
+ const result = res.ok ? readSyncOutcome(json) : null;
+ if (result) {
+ // The tone comes off what the run wrote, not off `res.ok`.
+ setSyncResult({
+ outcome: result.outcome,
+ message: describeSyncOutcome(
+ result,
+ t("settings.googleHealthSyncResult", { count: result.imported }),
+ ),
+ });
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({ queryKey: queryKeys.googleHealth() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
- setSyncMsg(json.error || t("settings.googleHealthSyncFailed"));
- setSyncMsgType("error");
+ setSyncResult({
+ outcome: "failed",
+ message: json?.error || t("settings.googleHealthSyncFailed"),
+ });
}
} catch {
- setSyncMsg(t("settings.googleHealthSyncFailed"));
- setSyncMsgType("error");
+ setSyncResult({
+ outcome: "failed",
+ message: t("settings.googleHealthSyncFailed"),
+ });
} finally {
setSyncing(false);
}
@@ -501,13 +513,12 @@ export function GoogleHealthCard({
{t("settings.googleHealthBackfillInProgress")}
)}
- {syncMsg && (
-
- {syncMsg}
-
+ {syncResult && (
+
)}
{/* connect→data loop: a discreet link to where this provider's
readings now surface — doubles as the "your data is richer"
diff --git a/src/components/settings/integrations/nightscout-card.tsx b/src/components/settings/integrations/nightscout-card.tsx
index afd9a98b2..aa5e2e0de 100644
--- a/src/components/settings/integrations/nightscout-card.tsx
+++ b/src/components/settings/integrations/nightscout-card.tsx
@@ -43,6 +43,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
+import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
@@ -51,6 +52,11 @@ import {
} from "@/lib/query-keys";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
+import {
+ readSyncOutcome,
+ useSyncOutcomeMessage,
+ type SyncOutcomeState,
+} from "./sync-outcome";
import {
IntegrationErrorMessage,
pillStateForVerdict,
@@ -65,6 +71,7 @@ export function NightscoutCard({
viewModel?: IntegrationStatusViewModel;
}) {
const { t } = useTranslations();
+ const describeSyncOutcome = useSyncOutcomeMessage();
const queryClient = useQueryClient();
const [url, setUrl] = useState("");
@@ -74,10 +81,7 @@ export function NightscoutCard({
const [msg, setMsg] = useState(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const [syncing, setSyncing] = useState(false);
- const [syncMsg, setSyncMsg] = useState(null);
- const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
- null,
- );
+ const [syncResult, setSyncResult] = useState(null);
const status = viewModel;
@@ -144,27 +148,35 @@ export function NightscoutCard({
// full-history dialog — Nightscout has no full-sync arm to call.
async function handleSync() {
setSyncing(true);
- setSyncMsg(null);
- setSyncMsgType(null);
+ setSyncResult(null);
try {
const res = await apiFetchRaw("/api/nightscout/sync", { method: "POST" });
const json = await res.json();
- if (res.ok) {
- setSyncMsg(
- t("settings.nightscoutSyncResult", { count: json.data.imported }),
- );
- setSyncMsgType("success");
+ const result = res.ok ? readSyncOutcome(json) : null;
+ if (result) {
+ // The tone comes off what the run wrote, not off `res.ok`.
+ setSyncResult({
+ outcome: result.outcome,
+ message: describeSyncOutcome(
+ result,
+ t("settings.nightscoutSyncResult", { count: result.imported }),
+ ),
+ });
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
- setSyncMsg(json.error || t("settings.nightscoutSyncFailed"));
- setSyncMsgType("error");
+ setSyncResult({
+ outcome: "failed",
+ message: json?.error || t("settings.nightscoutSyncFailed"),
+ });
}
} catch {
- setSyncMsg(t("settings.nightscoutSyncFailed"));
- setSyncMsgType("error");
+ setSyncResult({
+ outcome: "failed",
+ message: t("settings.nightscoutSyncFailed"),
+ });
} finally {
setSyncing(false);
}
@@ -401,14 +413,12 @@ export function NightscoutCard({
- {syncMsg && (
-
- {syncMsg}
-
+ {syncResult && (
+
)}
{/* connect→data loop: a discreet link to where the glucose
readings now surface — doubles as the "your data is richer"
diff --git a/src/components/settings/integrations/oauth-provider-card.tsx b/src/components/settings/integrations/oauth-provider-card.tsx
index c0290aeee..e0ed8a5f7 100644
--- a/src/components/settings/integrations/oauth-provider-card.tsx
+++ b/src/components/settings/integrations/oauth-provider-card.tsx
@@ -41,6 +41,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
+import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
import { useTranslations } from "@/lib/i18n/context";
import type {
MetricFreshnessEntry,
@@ -52,6 +53,11 @@ import {
queryKeys,
} from "@/lib/query-keys";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
+import {
+ readSyncOutcome,
+ useSyncOutcomeMessage,
+ type SyncOutcomeState,
+} from "./sync-outcome";
import { IntegrationErrorMessage, pillStateForVerdict } from "./shared";
import {
IntegrationCardDescription,
@@ -116,13 +122,11 @@ export function OAuthProviderCard({
viewModel,
}: OAuthProviderCardProps) {
const { t } = useTranslations();
+ const describeSyncOutcome = useSyncOutcomeMessage();
const queryClient = useQueryClient();
const [msg, setMsg] = useState(null);
const [syncing, setSyncing] = useState(false);
- const [syncMsg, setSyncMsg] = useState(null);
- const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
- null,
- );
+ const [syncResult, setSyncResult] = useState(null);
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [credsSaving, setCredsSaving] = useState(false);
@@ -137,28 +141,38 @@ export function OAuthProviderCard({
// full-history dialog — none of these providers has a full-sync arm to call.
async function handleSync() {
setSyncing(true);
- setSyncMsg(null);
- setSyncMsgType(null);
+ setSyncResult(null);
try {
const res = await apiFetchRaw(`/api/${provider}/sync`, {
method: "POST",
});
const json = await res.json();
- if (res.ok) {
- setSyncMsg(t(`${i18nPrefix}SyncResult`, { count: json.data.imported }));
- setSyncMsgType("success");
+ const result = res.ok ? readSyncOutcome(json) : null;
+ if (result) {
+ // The tone comes off what the run wrote, not off `res.ok`.
+ setSyncResult({
+ outcome: result.outcome,
+ message: describeSyncOutcome(
+ result,
+ t(`${i18nPrefix}SyncResult`, { count: result.imported }),
+ ),
+ });
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({ queryKey: statusQueryKey });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
- setSyncMsg(json.error || t(`${i18nPrefix}SyncFailed`));
- setSyncMsgType("error");
+ setSyncResult({
+ outcome: "failed",
+ message: json?.error || t(`${i18nPrefix}SyncFailed`),
+ });
}
} catch {
- setSyncMsg(t(`${i18nPrefix}SyncFailed`));
- setSyncMsgType("error");
+ setSyncResult({
+ outcome: "failed",
+ message: t(`${i18nPrefix}SyncFailed`),
+ });
} finally {
setSyncing(false);
}
@@ -463,14 +477,12 @@ export function OAuthProviderCard({
- {syncMsg && (
-
- {syncMsg}
-
+ {syncResult && (
+
)}
{/* connect→data loop: a discreet link to where this provider's
readings now surface — doubles as the "your data is richer"
diff --git a/src/components/settings/integrations/sync-outcome.ts b/src/components/settings/integrations/sync-outcome.ts
new file mode 100644
index 000000000..3eccbb1cc
--- /dev/null
+++ b/src/components/settings/integrations/sync-outcome.ts
@@ -0,0 +1,77 @@
+"use client";
+
+import { useTranslations } from "@/lib/i18n/context";
+import type { WrittenOutcome } from "@/lib/outcome/written-outcome";
+
+/**
+ * What every provider-sync route answers with, and how a card says it.
+ *
+ * The cards used to key their tone off `res.ok`: the request came back, so the
+ * line went green and printed a count. A run that imported nothing because
+ * every reading was refused looked exactly like a clean one. The route now
+ * resolves the outcome from what was written, and the card only renders it.
+ */
+export interface SyncOutcomeResponse {
+ /** Rows that reached the database this run. */
+ imported: number;
+ /** True when any part of the run did not land. */
+ failed: boolean;
+ /** The resolved verdict — the card never recomputes this. */
+ outcome: WrittenOutcome;
+}
+
+/** What a card holds after a sync attempt, transport failures included. */
+export interface SyncOutcomeState {
+ outcome: WrittenOutcome;
+ message: string;
+}
+
+/**
+ * Narrow an envelope body to the resolved outcome, or `null` when the route
+ * answered something else. A card that gets `null` treats the run as failed
+ * rather than guessing — an unreadable answer is not evidence of a write.
+ */
+export function readSyncOutcome(body: unknown): SyncOutcomeResponse | null {
+ if (typeof body !== "object" || body === null) return null;
+ const data = (body as { data?: unknown }).data;
+ if (typeof data !== "object" || data === null) return null;
+ const { imported, failed, outcome } = data as Record;
+ if (typeof imported !== "number") return null;
+ if (typeof failed !== "boolean") return null;
+ if (
+ outcome !== "empty" &&
+ outcome !== "failed" &&
+ outcome !== "partial" &&
+ outcome !== "success"
+ ) {
+ return null;
+ }
+ return { imported, failed, outcome };
+}
+
+/**
+ * Turn a resolved outcome into the sentence a card shows.
+ *
+ * The success wording stays per-provider (it names the provider's own count),
+ * so the caller passes it in; everything else is shared, because "some of it
+ * did not save" reads the same whoever the provider is. Written as literal
+ * `t(...)` calls so the i18n call-site coverage guard sees every key.
+ */
+export function useSyncOutcomeMessage(): (
+ result: SyncOutcomeResponse,
+ successMessage: string,
+) => string {
+ const { t } = useTranslations();
+ return (result, successMessage) => {
+ switch (result.outcome) {
+ case "success":
+ return successMessage;
+ case "empty":
+ return t("settings.syncOutcome.empty");
+ case "partial":
+ return t("settings.syncOutcome.partial", { count: result.imported });
+ case "failed":
+ return t("settings.syncOutcome.failed");
+ }
+ };
+}
diff --git a/src/components/settings/integrations/whoop-card.tsx b/src/components/settings/integrations/whoop-card.tsx
index bdd5f4397..8d697411a 100644
--- a/src/components/settings/integrations/whoop-card.tsx
+++ b/src/components/settings/integrations/whoop-card.tsx
@@ -33,6 +33,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
+import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
@@ -47,6 +48,11 @@ import {
type IntegrationStatusViewModel,
} from "./shared";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
+import {
+ readSyncOutcome,
+ useSyncOutcomeMessage,
+ type SyncOutcomeState,
+} from "./sync-outcome";
import {
IntegrationCardDescription,
IntegrationRedirectGuide,
@@ -58,11 +64,9 @@ export function WhoopCard({
viewModel: IntegrationStatusViewModel | undefined;
}) {
const { t } = useTranslations();
+ const describeSyncOutcome = useSyncOutcomeMessage();
const [syncing, setSyncing] = useState(false);
- const [syncMsg, setSyncMsg] = useState(null);
- const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
- null,
- );
+ const [syncResult, setSyncResult] = useState(null);
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [credsSaving, setCredsSaving] = useState(false);
@@ -109,8 +113,7 @@ export function WhoopCard({
async function handleSync(fullSync = false) {
setSyncing(true);
- setSyncMsg(null);
- setSyncMsgType(null);
+ setSyncResult(null);
try {
const res = await apiFetchRaw("/api/whoop/sync", {
method: "POST",
@@ -118,23 +121,32 @@ export function WhoopCard({
body: JSON.stringify({ fullSync }),
});
const json = await res.json();
- if (res.ok) {
- setSyncMsg(
- t("settings.whoopSyncResult", { count: json.data.imported }),
- );
- setSyncMsgType("success");
+ const result = res.ok ? readSyncOutcome(json) : null;
+ if (result) {
+ // The tone comes off what the run wrote, not off `res.ok`.
+ setSyncResult({
+ outcome: result.outcome,
+ message: describeSyncOutcome(
+ result,
+ t("settings.whoopSyncResult", { count: result.imported }),
+ ),
+ });
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({ queryKey: queryKeys.whoop() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
- setSyncMsg(json.error || t("settings.whoopSyncFailed"));
- setSyncMsgType("error");
+ setSyncResult({
+ outcome: "failed",
+ message: json?.error || t("settings.whoopSyncFailed"),
+ });
}
} catch {
- setSyncMsg(t("settings.whoopSyncFailed"));
- setSyncMsgType("error");
+ setSyncResult({
+ outcome: "failed",
+ message: t("settings.whoopSyncFailed"),
+ });
} finally {
setSyncing(false);
}
@@ -454,13 +466,12 @@ export function WhoopCard({
{t("settings.whoopBackfillInProgress")}
)}
- {syncMsg && (
-
- {syncMsg}
-
+ {syncResult && (
+
)}
{/* connect→data loop: a discreet link to where this provider's
readings now surface — doubles as the "your data is richer"
diff --git a/src/components/settings/integrations/withings-card.tsx b/src/components/settings/integrations/withings-card.tsx
index 15f929a85..c3048e72b 100644
--- a/src/components/settings/integrations/withings-card.tsx
+++ b/src/components/settings/integrations/withings-card.tsx
@@ -32,6 +32,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
+import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
@@ -46,6 +47,11 @@ import {
type IntegrationStatusViewModel,
} from "./shared";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
+import {
+ readSyncOutcome,
+ useSyncOutcomeMessage,
+ type SyncOutcomeState,
+} from "./sync-outcome";
import {
IntegrationCardDescription,
IntegrationRedirectGuide,
@@ -57,11 +63,9 @@ export function WithingsCard({
viewModel: IntegrationStatusViewModel | undefined;
}) {
const { t } = useTranslations();
+ const describeSyncOutcome = useSyncOutcomeMessage();
const [syncing, setSyncing] = useState(false);
- const [syncMsg, setSyncMsg] = useState(null);
- const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
- null,
- );
+ const [syncResult, setSyncResult] = useState(null);
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [credsSaving, setCredsSaving] = useState(false);
@@ -111,8 +115,7 @@ export function WithingsCard({
async function handleSync(fullSync = false) {
setSyncing(true);
- setSyncMsg(null);
- setSyncMsgType(null);
+ setSyncResult(null);
try {
const res = await apiFetchRaw("/api/withings/sync", {
method: "POST",
@@ -120,26 +123,33 @@ export function WithingsCard({
body: JSON.stringify({ fullSync }),
});
const json = await res.json();
- if (res.ok) {
- setSyncMsg(
- fullSync
- ? t("settings.withingsFullSyncResult", {
- count: json.data.imported,
- })
- : t("settings.withingsSyncResult", { count: json.data.imported }),
- );
- setSyncMsgType("success");
+ const result = res.ok ? readSyncOutcome(json) : null;
+ if (result) {
+ // The tone comes off what the run wrote, not off `res.ok`.
+ setSyncResult({
+ outcome: result.outcome,
+ message: describeSyncOutcome(
+ result,
+ fullSync
+ ? t("settings.withingsFullSyncResult", { count: result.imported })
+ : t("settings.withingsSyncResult", { count: result.imported }),
+ ),
+ });
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
- setSyncMsg(json.error || t("settings.withingsSyncFailed"));
- setSyncMsgType("error");
+ setSyncResult({
+ outcome: "failed",
+ message: json?.error || t("settings.withingsSyncFailed"),
+ });
}
} catch {
- setSyncMsg(t("settings.withingsSyncFailed"));
- setSyncMsgType("error");
+ setSyncResult({
+ outcome: "failed",
+ message: t("settings.withingsSyncFailed"),
+ });
} finally {
setSyncing(false);
}
@@ -505,13 +515,12 @@ export function WithingsCard({
- {syncMsg && (
-
- {syncMsg}
-
+ {syncResult && (
+
)}
{/* connect→data loop: a discreet link to where this provider's
readings now surface — doubles as the "your data is richer"
diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts
index 7851a749a..2c51946a0 100644
--- a/src/lib/oura/__tests__/sync.test.ts
+++ b/src/lib/oura/__tests__/sync.test.ts
@@ -567,7 +567,6 @@ describe("syncUserOura", () => {
// blanked — and the partial rides back with the count, so the card can
// report a warning rather than a tick over a run that lost a collection.
expect(result).toEqual({ imported: 2, failed: true });
- const imported = result.imported;
const written = createdRows().map((row) => row.type);
expect(written).toContain("SLEEP_SCORE");
expect(written).toContain("OXYGEN_SATURATION");
diff --git a/src/lib/validations/labs-ocr.ts b/src/lib/validations/labs-ocr.ts
index 13eab118d..e9f3ff516 100644
--- a/src/lib/validations/labs-ocr.ts
+++ b/src/lib/validations/labs-ocr.ts
@@ -18,6 +18,8 @@
*/
import { z } from "zod/v4";
+import type { WrittenOutcome } from "@/lib/outcome/written-outcome";
+
import { validateEntryInstant } from "@/lib/validations/entry-instant";
/** Max rows a single scan / commit may carry — a dense panel is ~30 analytes. */
@@ -200,6 +202,13 @@ export interface OcrSkippedRowDto {
export interface OcrCommitResponseDto {
inserted: unknown[];
skipped: OcrSkippedRowDto[];
+ /**
+ * How the commit reads, resolved server-side from the two lists. The dialog
+ * renders this rather than assuming a 200 means readings were saved — a
+ * re-scan in which every confirmed row turned out to be a duplicate writes
+ * nothing and must not report a green toast.
+ */
+ outcome: WrittenOutcome;
}
/** The capability-probe response (drives whether the UI shows the scan entry). */