From 52288b7f7706a8462e02a8b28817aef2d0687784 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 27 Jul 2026 22:43:27 +0200
Subject: [PATCH 1/3] refactor(outcome): lift the write-outcome classifier out
of the import panel
The rule that a write which wrote nothing must not read as a success was
scoped to the import panel. Provider syncs need the same rule, so the
classifier moves to src/lib/outcome/written-outcome.ts and the tick plus the
success colour move to one presentation component the whole tree renders
through. Adds the sync shape: a run reports what it imported and whether any
part of it failed, and the outcome is resolved from both.
---
.../medications/intake-import-result.tsx | 16 +--
src/components/outcome/outcome-toast.ts | 33 +++++
.../outcome/written-outcome-line.tsx | 57 ++++++++
.../import-panel/apple-health-import-card.tsx | 8 +-
.../import-panel/import-result-state.ts | 72 -----------
.../import-panel/import-result-view.tsx | 63 +--------
.../import-panel/json-import-card.tsx | 8 +-
.../__tests__/written-outcome.test.ts} | 66 ++++++++--
src/lib/outcome/written-outcome.ts | 122 ++++++++++++++++++
9 files changed, 288 insertions(+), 157 deletions(-)
create mode 100644 src/components/outcome/outcome-toast.ts
create mode 100644 src/components/outcome/written-outcome-line.tsx
delete mode 100644 src/components/settings/import-panel/import-result-state.ts
rename src/{components/settings/import-panel/__tests__/import-result-state.test.ts => lib/outcome/__tests__/written-outcome.test.ts} (56%)
create mode 100644 src/lib/outcome/written-outcome.ts
diff --git a/src/components/medications/intake-import-result.tsx b/src/components/medications/intake-import-result.tsx
index 4493fc219..d0945bc04 100644
--- a/src/components/medications/intake-import-result.tsx
+++ b/src/components/medications/intake-import-result.tsx
@@ -8,9 +8,9 @@
* the thing that has to be right is the sentence at the end of the run.
*
* The rules are the ones the settings import panel already follows, reused
- * rather than restated: `classifyImportOutcome` decides the tone from the
+ * rather than restated: `classifyWrittenOutcome` decides the tone from the
* counts, so an import that wrote nothing can never carry the success tick,
- * and `ImportOutcomeLine` owns the tick and the success colour. The skips are
+ * and `WrittenOutcomeLine` owns the tick and the success colour. The skips are
* grouped by reason with a count, because a file that trips one rule trips it
* on every row and a per-row list is the same sentence a hundred times.
*
@@ -18,11 +18,11 @@
* cards; the medication intake import is the third import surface and it now
* answers the same way.
*/
+import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
import {
- classifyImportOutcome,
- type ImportOutcome,
-} from "@/components/settings/import-panel/import-result-state";
-import { ImportOutcomeLine } from "@/components/settings/import-panel/import-result-view";
+ classifyWrittenOutcome,
+ type WrittenOutcome,
+} from "@/lib/outcome/written-outcome";
import { useTranslations } from "@/lib/i18n/context";
/** A skip group exactly as the import job reports it. */
@@ -132,7 +132,7 @@ export function IntakeImportResultView({
);
}
- const outcome: ImportOutcome = classifyImportOutcome({
+ const outcome: WrittenOutcome = classifyWrittenOutcome({
written: result.imported,
skipped: result.skipped,
});
@@ -153,7 +153,7 @@ export function IntakeImportResultView({
return (
- = {
+ 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 (
+
+
+ {message}
+
+ );
+}
diff --git a/src/components/settings/import-panel/apple-health-import-card.tsx b/src/components/settings/import-panel/apple-health-import-card.tsx
index cfeb60c3f..1d8d12af8 100644
--- a/src/components/settings/import-panel/apple-health-import-card.tsx
+++ b/src/components/settings/import-panel/apple-health-import-card.tsx
@@ -11,8 +11,8 @@ import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { ImportCardShell } from "./import-card-shell";
-import { classifyImportOutcome } from "./import-result-state";
-import { ImportOutcomeLine } from "./import-result-view";
+import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
+import { classifyWrittenOutcome } from "@/lib/outcome/written-outcome";
/** Terminal states the status poll stops on. */
const TERMINAL_STATES: readonly string[] = ["done", "failed"];
@@ -239,8 +239,8 @@ export function AppleHealthImportCard() {
)}
{isDone && (
- 0 ? "failed" : "empty";
- return skipped > 0 ? "partial" : "success";
-}
-
-export interface SkipReasonGroup {
- /** The machine-readable reason as the route reported it. */
- reason: string;
- count: number;
-}
-
-/** A row of the per-row envelope, narrowed to what the grouping needs. */
-export interface ImportRowResult {
- line: number;
- status: string;
- reason?: string;
-}
-
-const UNKNOWN_REASON = "unknown";
-
-/**
- * Collapse the per-row envelope into one entry per skip reason.
- *
- * A sensor export that trips one validation trips it on every row, so the
- * raw list is the same sentence a few thousand times. Grouping turns "scroll
- * fifty identical lines" into one fact with a count. Ordered by count
- * descending, then by reason, so the same result always renders the same way.
- */
-export function groupSkipReasons(
- rows: readonly ImportRowResult[],
-): SkipReasonGroup[] {
- const counts = new Map();
- for (const row of rows) {
- if (row.status !== "skipped") continue;
- const reason = row.reason ?? UNKNOWN_REASON;
- counts.set(reason, (counts.get(reason) ?? 0) + 1);
- }
- return [...counts.entries()]
- .map(([reason, count]) => ({ reason, count }))
- .sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason));
-}
diff --git a/src/components/settings/import-panel/import-result-view.tsx b/src/components/settings/import-panel/import-result-view.tsx
index 78ac1fe44..b30b8fb47 100644
--- a/src/components/settings/import-panel/import-result-view.tsx
+++ b/src/components/settings/import-panel/import-result-view.tsx
@@ -1,61 +1,12 @@
"use client";
-import { AlertCircle, CheckCircle2, Info, TriangleAlert } from "lucide-react";
-import type { LucideIcon } from "lucide-react";
-
+import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
import { useTranslations } from "@/lib/i18n/context";
import {
- classifyImportOutcome,
+ classifyWrittenOutcome,
groupSkipReasons,
- type ImportOutcome,
- type ImportRowResult,
-} from "./import-result-state";
-
-/**
- * The one mapping from an import outcome to how it looks.
- *
- * `CheckCircle2` and `text-success` appear exactly once in the import panel,
- * on the `success` key, so no future edit can attach a tick to a result that
- * wrote nothing. `import-result-guard.test.ts` holds that line.
- */
-const OUTCOME_PRESENTATION: Record<
- ImportOutcome,
- { Icon: LucideIcon; className: string }
-> = {
- success: { Icon: CheckCircle2, className: "text-success" },
- partial: { Icon: TriangleAlert, className: "text-warning" },
- failed: { Icon: AlertCircle, className: "text-destructive" },
- empty: { Icon: Info, className: "text-muted-foreground" },
-};
-
-/**
- * The headline line of an import result. Sits inside each card's existing
- * `aria-live="polite"` region, so the outcome is announced as it lands.
- */
-export function ImportOutcomeLine({
- outcome,
- message,
- testId,
-}: {
- outcome: ImportOutcome;
- message: string;
- testId: string;
-}) {
- const { Icon, className } = OUTCOME_PRESENTATION[outcome];
- return (
-
{result && (
- {
+describe("classifyWrittenOutcome", () => {
it("calls the reported production result a failure", () => {
// The audit envelope from the report: 1 597 rows in, nothing written.
- expect(classifyImportOutcome({ written: 0, skipped: 1597 })).toBe("failed");
+ expect(classifyWrittenOutcome({ written: 0, skipped: 1597 })).toBe(
+ "failed",
+ );
});
it("calls a file with no data rows empty, not a success", () => {
- expect(classifyImportOutcome({ written: 0, skipped: 0 })).toBe("empty");
+ expect(classifyWrittenOutcome({ written: 0, skipped: 0 })).toBe("empty");
});
it("calls a mixed result partial", () => {
- expect(classifyImportOutcome({ written: 3, skipped: 2 })).toBe("partial");
+ expect(classifyWrittenOutcome({ written: 3, skipped: 2 })).toBe("partial");
});
it("calls a clean result a success", () => {
- expect(classifyImportOutcome({ written: 5, skipped: 0 })).toBe("success");
+ expect(classifyWrittenOutcome({ written: 5, skipped: 0 })).toBe("success");
});
it("never reaches success while nothing was written", () => {
for (let written = 0; written <= 4; written++) {
for (let skipped = 0; skipped <= 4; skipped++) {
- const outcome = classifyImportOutcome({ written, skipped });
+ const outcome = classifyWrittenOutcome({ written, skipped });
if (written === 0) {
expect(outcome).not.toBe("success");
expect(outcome).not.toBe("partial");
@@ -50,6 +53,43 @@ describe("classifyImportOutcome", () => {
});
});
+describe("resolveSyncOutcome", () => {
+ it("calls a run that wrote nothing and failed a failure, not an empty run", () => {
+ // The defect: every row refused by a constraint, count 0, HTTP 200.
+ expect(resolveSyncOutcome({ imported: 0, failed: true })).toEqual({
+ imported: 0,
+ failed: true,
+ outcome: "failed",
+ });
+ });
+
+ it("calls a run that found nothing new empty", () => {
+ expect(resolveSyncOutcome({ imported: 0, failed: false }).outcome).toBe(
+ "empty",
+ );
+ });
+
+ it("calls a run with a failed leg partial even when rows landed", () => {
+ expect(resolveSyncOutcome({ imported: 12, failed: true }).outcome).toBe(
+ "partial",
+ );
+ });
+
+ it("calls a clean run a success", () => {
+ expect(resolveSyncOutcome({ imported: 12, failed: false }).outcome).toBe(
+ "success",
+ );
+ });
+
+ it("never reaches success while the run reported a failure", () => {
+ for (const imported of [0, 1, 500]) {
+ expect(resolveSyncOutcome({ imported, failed: true }).outcome).not.toBe(
+ "success",
+ );
+ }
+ });
+});
+
describe("groupSkipReasons", () => {
it("collapses a repeated reason to one entry with a count", () => {
const rows = Array.from({ length: 1597 }, (_, i) => ({
diff --git a/src/lib/outcome/written-outcome.ts b/src/lib/outcome/written-outcome.ts
new file mode 100644
index 000000000..786d42869
--- /dev/null
+++ b/src/lib/outcome/written-outcome.ts
@@ -0,0 +1,122 @@
+/**
+ * Written-outcome classification — the one place that decides whether a write
+ * reads as a success.
+ *
+ * The rule started in the import panel (`import-result-state.ts`, #640/#650)
+ * and is unchanged; only its reach is wider. A per-row importer answers HTTP
+ * 200 for a file where every single row was refused, and a provider sync
+ * answers HTTP 200 for a run where every reading failed a constraint. The
+ * transport succeeded, the write did not. Rendering either as a green tick is
+ * how a self-hoster's sensor export could write nothing and still look
+ * finished. So the counts, not the status code, pick the state:
+ *
+ * - nothing written, nothing refused → `empty` (there was nothing to write)
+ * - nothing written, rows refused → `failed` (never a success tone)
+ * - some written, some refused → `partial` (a warning, not a tick)
+ * - everything written → `success`
+ *
+ * `empty` is deliberately its own state rather than a failure: an import of a
+ * file with no data rows and a sync that finds nothing new are both honest
+ * non-events, and both would be a lie under a tick.
+ *
+ * Every surface that reports a write routes through this, and
+ * `success-affordance-guard.test.ts` holds the line that the tick and the
+ * success colour live only in the presentation module this feeds.
+ */
+
+export type WrittenOutcome = "empty" | "failed" | "partial" | "success";
+
+export interface WriteCounts {
+ /** Rows that reached the database — inserted + updated. */
+ written: number;
+ /** Rows the write refused, for any reason. */
+ skipped: number;
+}
+
+export function classifyWrittenOutcome({
+ written,
+ skipped,
+}: WriteCounts): WrittenOutcome {
+ if (written <= 0) return skipped > 0 ? "failed" : "empty";
+ return skipped > 0 ? "partial" : "success";
+}
+
+/**
+ * What a provider sync reports back to its route.
+ *
+ * Three providers (Fitbit, Google Health, WHOOP) already answered in this
+ * shape; the rest returned a bare count that narrowed their failures away —
+ * a Nightscout run whose every SGV row failed to write returned the same `0`
+ * as a run that found nothing new. The boolean is the honest floor: a sync
+ * knows a leg / collection / row did not land, but not always how many.
+ */
+export interface SyncWriteResult {
+ /** Rows that reached the database this run. */
+ imported: number;
+ /** True when any part of the run did not land — a row, a collection, a leg. */
+ failed: boolean;
+}
+
+/** The sync envelope a route answers with: the counts plus the resolved tone. */
+export interface ResolvedSyncOutcome extends SyncWriteResult {
+ outcome: WrittenOutcome;
+}
+
+/**
+ * Resolve a sync's write result to the outcome its card renders.
+ *
+ * Server-side on purpose: the verdict is the server's to compute, so the web
+ * card and the native client cannot drift on how the same run reads. A failed
+ * leg counts as one refusal — enough to move `success` to `partial` and
+ * `empty` to `failed`, which is the whole point.
+ */
+export function resolveSyncOutcome({
+ imported,
+ failed,
+}: SyncWriteResult): ResolvedSyncOutcome {
+ return {
+ imported,
+ failed,
+ outcome: classifyWrittenOutcome({
+ written: imported,
+ skipped: failed ? 1 : 0,
+ }),
+ };
+}
+
+export interface SkipReasonGroup {
+ /** The machine-readable reason as the route reported it. */
+ reason: string;
+ count: number;
+}
+
+/** A row of the per-row envelope, narrowed to what the grouping needs. */
+export interface WrittenRowResult {
+ line: number;
+ status: string;
+ reason?: string;
+}
+
+const UNKNOWN_REASON = "unknown";
+
+/**
+ * Collapse the per-row envelope into one entry per skip reason.
+ *
+ * A sensor export that trips one validation trips it on every row, so the
+ * raw list is the same sentence a few thousand times. Grouping turns "scroll
+ * fifty identical lines" into one fact with a count. Ordered by count
+ * descending, then by reason, so the same result always renders the same way.
+ */
+export function groupSkipReasons(
+ rows: readonly WrittenRowResult[],
+): SkipReasonGroup[] {
+ const counts = new Map();
+ for (const row of rows) {
+ if (row.status !== "skipped") continue;
+ const reason = row.reason ?? UNKNOWN_REASON;
+ counts.set(reason, (counts.get(reason) ?? 0) + 1);
+ }
+ return [...counts.entries()]
+ .map(([reason, count]) => ({ reason, count }))
+ .sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason));
+}
From 530f78dcf573f4b640a29cc4581d5d0511fe9d97 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 27 Jul 2026 22:53:43 +0200
Subject: [PATCH 2/3] fix(sync): answer with what a run wrote, not that it
completed
Six provider syncs returned a bare count, which narrowed their failures away:
a Nightscout window in which every SGV row was refused by a constraint
returned the same 0 as a quiet night, and an Oura or Polar run whose
collections all failed returned 0 too. The routes then answered 200 with a
count and the settings card drew a green tick over it.
Every sync now reports what it imported AND whether any part of it did not
land, and the route resolves the outcome server-side so the web card and the
native client cannot disagree. Google Health keeps its 502 for a run that
failed and wrote nothing, but a run that failed after writing some resources
is now a partial rather than a discarded result. The labs OCR commit carries
the same resolved outcome.
---
docs/api/openapi.yaml | 13 ++++-
src/app/api/fitbit/sync/route.ts | 8 ++-
.../sync/__tests__/route.test.ts | 24 ++++++++-
src/app/api/google-health/sync/route.ts | 12 +++--
src/app/api/labs/ocr/commit/route.ts | 15 +++++-
.../nightscout/sync/__tests__/route.test.ts | 51 ++++++++++++++++++-
src/app/api/nightscout/sync/route.ts | 9 ++--
src/app/api/oura/sync/__tests__/route.test.ts | 15 +++++-
src/app/api/oura/sync/route.ts | 5 +-
.../api/polar/sync/__tests__/route.test.ts | 20 ++++++--
src/app/api/polar/sync/route.ts | 5 +-
.../api/strava/sync/__tests__/route.test.ts | 2 +-
src/app/api/strava/sync/route.ts | 7 ++-
src/app/api/whoop/sync/route.ts | 7 ++-
src/app/api/withings/sync/route.ts | 7 ++-
.../__tests__/polar-sync-legs.test.ts | 21 +++++---
src/lib/jobs/reminder/nightscout-sync.ts | 4 +-
src/lib/jobs/reminder/oura-sync.ts | 4 +-
src/lib/jobs/reminder/polar-sync.ts | 19 +++++--
src/lib/jobs/reminder/withings-sync.ts | 2 +-
src/lib/nightscout/__tests__/sync.test.ts | 22 ++++----
src/lib/nightscout/sync.ts | 30 +++++++----
src/lib/openapi/routes/ocr.ts | 6 ++-
src/lib/oura/__tests__/sync.test.ts | 27 +++++-----
src/lib/oura/sync.ts | 20 +++++---
src/lib/polar/__tests__/sync.test.ts | 17 ++++---
src/lib/polar/sync.ts | 17 ++++---
.../__tests__/sync-measure-watermark.test.ts | 10 ++--
src/lib/withings/sync.ts | 23 ++++++---
29 files changed, 310 insertions(+), 112 deletions(-)
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 58dc9d0be..1013348a3 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -29688,11 +29688,22 @@ components:
- analyte
- reason
additionalProperties: false
+ outcome:
+ type: string
+ enum:
+ - empty
+ - failed
+ - partial
+ - success
+ description: How the commit reads, resolved from the counts rather than the status code. `success` only when every
+ confirmed row was written; `partial` when some were skipped; `failed` when nothing was written and at least
+ one row was skipped; `empty` when there was nothing to write.
required:
- inserted
- skipped
+ - outcome
additionalProperties: false
- description: 'The rows written (`source: "OCR"`) and those skipped as commit-time duplicates.'
+ description: 'The rows written (`source: "OCR"`), those skipped as commit-time duplicates, and the resolved outcome.'
OcrCommittedLabResult:
type: object
properties:
diff --git a/src/app/api/fitbit/sync/route.ts b/src/app/api/fitbit/sync/route.ts
index 2f7641fc7..00be8309a 100644
--- a/src/app/api/fitbit/sync/route.ts
+++ b/src/app/api/fitbit/sync/route.ts
@@ -3,6 +3,7 @@ import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { syncUserFitbit } from "@/lib/fitbit/sync";
+import { resolveSyncOutcome } from "@/lib/outcome/written-outcome";
import { NextRequest } from "next/server";
/**
@@ -58,6 +59,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
- const { imported } = await syncUserFitbit(user.id, { fullSync });
- return apiSuccess({ imported, fullSync });
+ // The verdict is resolved here, not in the card: a run that imported nothing
+ // because every resource failed is not the same answer as a quiet hour, and
+ // both used to render as a green tick with a count.
+ const result = await syncUserFitbit(user.id, { fullSync });
+ return apiSuccess({ ...resolveSyncOutcome(result), fullSync });
});
diff --git a/src/app/api/google-health/sync/__tests__/route.test.ts b/src/app/api/google-health/sync/__tests__/route.test.ts
index 63b9686ab..e51d4df0d 100644
--- a/src/app/api/google-health/sync/__tests__/route.test.ts
+++ b/src/app/api/google-health/sync/__tests__/route.test.ts
@@ -56,6 +56,28 @@ describe("POST /api/google-health/sync", () => {
expect(envelope.error).toBe("Google Health sync failed");
});
+ it("reports a run that failed after writing some resources as a partial", async () => {
+ // A 502 threw the honest half away: rows DID land, and the card showed the
+ // generic error instead of "imported N, some data is behind".
+ vi.mocked(syncUserGoogleHealth).mockResolvedValue({
+ imported: 7,
+ failed: true,
+ });
+
+ const response = await POST(request());
+ const envelope = (await response.json()) as {
+ data: { imported: number; failed: boolean; outcome: string };
+ error: string | null;
+ };
+
+ expect(response.status).toBe(200);
+ expect(envelope.data).toMatchObject({
+ imported: 7,
+ failed: true,
+ outcome: "partial",
+ });
+ });
+
it("keeps the successful imported-count response unchanged", async () => {
vi.mocked(syncUserGoogleHealth).mockResolvedValue({
imported: 7,
@@ -70,7 +92,7 @@ describe("POST /api/google-health/sync", () => {
expect(response.status).toBe(200);
expect(envelope).toEqual({
- data: { imported: 7, fullSync: false },
+ data: { imported: 7, failed: false, outcome: "success", fullSync: false },
error: null,
});
expect(syncUserGoogleHealth).toHaveBeenCalledWith("u1", {
diff --git a/src/app/api/google-health/sync/route.ts b/src/app/api/google-health/sync/route.ts
index b504ba329..2cb7b99b3 100644
--- a/src/app/api/google-health/sync/route.ts
+++ b/src/app/api/google-health/sync/route.ts
@@ -3,6 +3,7 @@ import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { syncUserGoogleHealth } from "@/lib/google-health/sync";
+import { resolveSyncOutcome } from "@/lib/outcome/written-outcome";
import { NextRequest } from "next/server";
/**
@@ -55,11 +56,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
- const { imported, failed } = await syncUserGoogleHealth(user.id, {
- fullSync,
- });
- if (failed) {
+ const result = await syncUserGoogleHealth(user.id, { fullSync });
+ // A run that failed AND wrote nothing is an error, as it always was. A run
+ // that failed after writing some of its resources is a partial, and answering
+ // 502 for it threw away the honest half of the result.
+ if (result.failed && result.imported === 0) {
return apiError("Google Health sync failed", 502);
}
- return apiSuccess({ imported, fullSync });
+ return apiSuccess({ ...resolveSyncOutcome(result), fullSync });
});
diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts
index 5d3f37b82..42b6b1d77 100644
--- a/src/app/api/labs/ocr/commit/route.ts
+++ b/src/app/api/labs/ocr/commit/route.ts
@@ -9,6 +9,11 @@
*
* `userId` is always narrowed from the session — never a body field. The write
* `data` object is built field-by-field (no mass assignment).
+ *
+ * The response carries the resolved `outcome` alongside the two lists. The
+ * dialog used to raise a green toast on any 200, so a re-scan in which every
+ * confirmed row turned out to be a duplicate reported "saved 0 readings" under
+ * a tick. The verdict is the server's to compute; the dialog only renders it.
*/
import { NextRequest } from "next/server";
import { fireAndForget } from "@/lib/logging/fire-and-forget";
@@ -33,6 +38,7 @@ import {
type InsertedLabForLink,
} from "@/lib/labs/vault-link";
import { annotate } from "@/lib/logging/context";
+import { classifyWrittenOutcome } from "@/lib/outcome/written-outcome";
import {
ocrCommitSchema,
type OcrCommitRow,
@@ -235,5 +241,12 @@ async function commitOcrRows(request: NextRequest) {
}
}
- return apiSuccess({ inserted, skipped });
+ return apiSuccess({
+ inserted,
+ skipped,
+ outcome: classifyWrittenOutcome({
+ written: inserted.length,
+ skipped: skipped.length,
+ }),
+ });
}
diff --git a/src/app/api/nightscout/sync/__tests__/route.test.ts b/src/app/api/nightscout/sync/__tests__/route.test.ts
index a133cb8c5..15585a50b 100644
--- a/src/app/api/nightscout/sync/__tests__/route.test.ts
+++ b/src/app/api/nightscout/sync/__tests__/route.test.ts
@@ -57,18 +57,65 @@ beforeEach(() => {
describe("POST /api/nightscout/sync", () => {
it("syncs the session user and answers with the freshly inserted count", async () => {
- vi.mocked(syncUserNightscout).mockResolvedValue(36);
+ vi.mocked(syncUserNightscout).mockResolvedValue({
+ imported: 36,
+ failed: false,
+ });
const response = await post(request());
expect(response.status).toBe(200);
expect(await envelope(response)).toEqual({
- data: { imported: 36 },
+ data: { imported: 36, failed: false, outcome: "success" },
error: null,
});
expect(syncUserNightscout).toHaveBeenCalledWith("u1");
});
+ it("does not call a window whose every row was refused a success", async () => {
+ // The defect: 200 + `imported: 0` rendered as a green tick with a count,
+ // when in fact not one SGV row reached the database.
+ vi.mocked(syncUserNightscout).mockResolvedValue({
+ imported: 0,
+ failed: true,
+ });
+
+ const response = await post(request());
+
+ expect(response.status).toBe(200);
+ expect((await envelope(response)).data).toEqual({
+ imported: 0,
+ failed: true,
+ outcome: "failed",
+ });
+ });
+
+ it("calls a window that wrote some rows and lost others a partial", async () => {
+ vi.mocked(syncUserNightscout).mockResolvedValue({
+ imported: 30,
+ failed: true,
+ });
+
+ const response = await post(request());
+
+ expect((await envelope(response)).data).toMatchObject({
+ outcome: "partial",
+ });
+ });
+
+ it("calls a window that found nothing new empty, not a success", async () => {
+ vi.mocked(syncUserNightscout).mockResolvedValue({
+ imported: 0,
+ failed: false,
+ });
+
+ const response = await post(request());
+
+ expect((await envelope(response)).data).toMatchObject({
+ outcome: "empty",
+ });
+ });
+
it("refuses an unauthenticated caller before syncing anything", async () => {
vi.mocked(requireAuth).mockRejectedValue(
new HttpError(401, "Not authenticated"),
diff --git a/src/app/api/nightscout/sync/route.ts b/src/app/api/nightscout/sync/route.ts
index 1c379ce79..99504bb4d 100644
--- a/src/app/api/nightscout/sync/route.ts
+++ b/src/app/api/nightscout/sync/route.ts
@@ -3,12 +3,15 @@ import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { syncUserNightscout } from "@/lib/nightscout/sync";
+import { resolveSyncOutcome } from "@/lib/outcome/written-outcome";
/**
* Manually trigger a Nightscout sync for the current user (v1.32.28).
*
* Pulls the recent SGV window from the configured instance; `imported` counts
- * the readings that were genuinely new this run, not the ones re-confirmed.
+ * the readings that were genuinely new this run, not the ones re-confirmed,
+ * and `outcome` says how that count reads — a window in which every row was
+ * refused by a constraint imports 0 and must not answer like a quiet night.
*
* No body and no `fullSync` flag: there is no full-history arm to call.
*
@@ -34,8 +37,8 @@ export const POST = apiHandler(async () => {
}
try {
- const imported = await syncUserNightscout(user.id);
- return apiSuccess({ imported });
+ const result = await syncUserNightscout(user.id);
+ return apiSuccess(resolveSyncOutcome(result));
} catch {
return apiError("Nightscout sync failed", 502);
}
diff --git a/src/app/api/oura/sync/__tests__/route.test.ts b/src/app/api/oura/sync/__tests__/route.test.ts
index 8dd1fd51a..eea02fca6 100644
--- a/src/app/api/oura/sync/__tests__/route.test.ts
+++ b/src/app/api/oura/sync/__tests__/route.test.ts
@@ -53,19 +53,30 @@ beforeEach(() => {
describe("POST /api/oura/sync", () => {
it("syncs the session user and answers with the imported count", async () => {
- vi.mocked(syncUserOura).mockResolvedValue(12);
+ vi.mocked(syncUserOura).mockResolvedValue({ imported: 12, failed: false });
const response = await post(request());
expect(response.status).toBe(200);
expect(await envelope(response)).toEqual({
- data: { imported: 12 },
+ data: { imported: 12, failed: false, outcome: "success" },
error: null,
});
// The id comes from the session, never from the request.
expect(syncUserOura).toHaveBeenCalledWith("u1");
});
+ it("does not call a run whose every collection failed a success", async () => {
+ vi.mocked(syncUserOura).mockResolvedValue({ imported: 0, failed: true });
+
+ const response = await post(request());
+
+ expect(response.status).toBe(200);
+ expect((await envelope(response)).data).toMatchObject({
+ outcome: "failed",
+ });
+ });
+
it("refuses an unauthenticated caller before syncing anything", async () => {
vi.mocked(requireAuth).mockRejectedValue(
new HttpError(401, "Not authenticated"),
diff --git a/src/app/api/oura/sync/route.ts b/src/app/api/oura/sync/route.ts
index 98602ed80..3f47ab631 100644
--- a/src/app/api/oura/sync/route.ts
+++ b/src/app/api/oura/sync/route.ts
@@ -2,6 +2,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
+import { resolveSyncOutcome } from "@/lib/outcome/written-outcome";
import { syncUserOura } from "@/lib/oura/sync";
/**
@@ -31,8 +32,8 @@ export const POST = apiHandler(async () => {
}
try {
- const imported = await syncUserOura(user.id);
- return apiSuccess({ imported });
+ const result = await syncUserOura(user.id);
+ return apiSuccess(resolveSyncOutcome(result));
} catch {
// The failure is already on the `oura` ledger, classified by the provider
// that owns the error shape. Answer generically — an upstream message can
diff --git a/src/app/api/polar/sync/__tests__/route.test.ts b/src/app/api/polar/sync/__tests__/route.test.ts
index f18d71c4f..a3f870aed 100644
--- a/src/app/api/polar/sync/__tests__/route.test.ts
+++ b/src/app/api/polar/sync/__tests__/route.test.ts
@@ -67,26 +67,40 @@ beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(requireAuth).mockResolvedValue({ user: { id: "u1" } } as never);
- syncUserPolarMock.mockResolvedValue(0);
+ syncUserPolarMock.mockResolvedValue({ imported: 0, failed: false });
syncUserPolarWorkoutsMock.mockResolvedValue(0);
});
describe("POST /api/polar/sync", () => {
it("runs both Polar legs and totals what they imported", async () => {
- syncUserPolarMock.mockResolvedValue(5);
+ syncUserPolarMock.mockResolvedValue({ imported: 5, failed: false });
syncUserPolarWorkoutsMock.mockResolvedValue(2);
const response = await post(request());
expect(response.status).toBe(200);
expect(await envelope(response)).toEqual({
- data: { imported: 7 },
+ data: { imported: 7, failed: false, outcome: "success" },
error: null,
});
expect(syncUserPolarMock).toHaveBeenCalledWith("u1");
expect(syncUserPolarWorkoutsMock).toHaveBeenCalledWith("u1");
});
+ it("does not call a run whose vitals collections all failed a success", async () => {
+ syncUserPolarMock.mockResolvedValue({ imported: 0, failed: true });
+ syncUserPolarWorkoutsMock.mockResolvedValue(0);
+
+ const response = await post(request());
+
+ expect(response.status).toBe(200);
+ expect((await envelope(response)).data).toEqual({
+ imported: 0,
+ failed: true,
+ outcome: "failed",
+ });
+ });
+
it("goes through the shared legs entry point, not the provider internals", () => {
const source = readFileSync(join(__dirname, "..", "route.ts"), "utf8");
expect(source).toContain("syncUserPolarLegs");
diff --git a/src/app/api/polar/sync/route.ts b/src/app/api/polar/sync/route.ts
index 01b528dcd..f743459af 100644
--- a/src/app/api/polar/sync/route.ts
+++ b/src/app/api/polar/sync/route.ts
@@ -3,6 +3,7 @@ import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { syncUserPolarLegs } from "@/lib/jobs/reminder/polar-sync";
+import { resolveSyncOutcome } from "@/lib/outcome/written-outcome";
/**
* Manually trigger a Polar sync for the current user (v1.32.28).
@@ -31,8 +32,8 @@ export const POST = apiHandler(async () => {
}
try {
- const imported = await syncUserPolarLegs(user.id);
- return apiSuccess({ imported });
+ const result = await syncUserPolarLegs(user.id);
+ return apiSuccess(resolveSyncOutcome(result));
} catch {
// Both legs record their own classified failure on the `polar` ledger.
// Answer generically so no upstream detail rides the response body.
diff --git a/src/app/api/strava/sync/__tests__/route.test.ts b/src/app/api/strava/sync/__tests__/route.test.ts
index 45fcd1bcf..c72617871 100644
--- a/src/app/api/strava/sync/__tests__/route.test.ts
+++ b/src/app/api/strava/sync/__tests__/route.test.ts
@@ -60,7 +60,7 @@ describe("POST /api/strava/sync", () => {
expect(response.status).toBe(200);
expect(await envelope(response)).toEqual({
- data: { imported: 4 },
+ data: { imported: 4, failed: false, outcome: "success" },
error: null,
});
expect(syncUserStrava).toHaveBeenCalledWith("u1");
diff --git a/src/app/api/strava/sync/route.ts b/src/app/api/strava/sync/route.ts
index 0c36326cd..8810ccb64 100644
--- a/src/app/api/strava/sync/route.ts
+++ b/src/app/api/strava/sync/route.ts
@@ -2,6 +2,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
+import { resolveSyncOutcome } from "@/lib/outcome/written-outcome";
import { syncUserStrava } from "@/lib/strava/sync";
/**
@@ -28,7 +29,11 @@ export const POST = apiHandler(async () => {
try {
const imported = await syncUserStrava(user.id);
- return apiSuccess({ imported });
+ // `failed: false` is a statement, not a placeholder: the Strava walk has no
+ // settled-but-partial arm. `upsertStravaWorkouts` rethrows the first row
+ // error after attempting the batch and the page walk rethrows its own, so
+ // reaching this line means every fetched activity was written.
+ return apiSuccess(resolveSyncOutcome({ imported, failed: false }));
} catch {
// Already recorded on the `strava` ledger with its classification. Answer
// generically so no upstream detail rides the response body.
diff --git a/src/app/api/whoop/sync/route.ts b/src/app/api/whoop/sync/route.ts
index d3a331aca..1a7579cfe 100644
--- a/src/app/api/whoop/sync/route.ts
+++ b/src/app/api/whoop/sync/route.ts
@@ -1,6 +1,7 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
+import { resolveSyncOutcome } from "@/lib/outcome/written-outcome";
import { syncUserWhoop } from "@/lib/whoop/sync";
import { NextRequest } from "next/server";
@@ -27,6 +28,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
// no body provided -> default incremental sync
}
- const { imported } = await syncUserWhoop(user.id, { fullSync });
- return apiSuccess({ imported, fullSync });
+ // Resolved server-side so the card and the native client cannot disagree on
+ // how the same run reads.
+ const result = await syncUserWhoop(user.id, { fullSync });
+ return apiSuccess({ ...resolveSyncOutcome(result), fullSync });
});
diff --git a/src/app/api/withings/sync/route.ts b/src/app/api/withings/sync/route.ts
index f881d62d8..508f8cc4a 100644
--- a/src/app/api/withings/sync/route.ts
+++ b/src/app/api/withings/sync/route.ts
@@ -1,6 +1,7 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
+import { resolveSyncOutcome } from "@/lib/outcome/written-outcome";
import { syncUserMeasurements } from "@/lib/withings/sync";
import { NextRequest } from "next/server";
@@ -24,8 +25,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
// no body provided -> default incremental sync
}
- const imported = await syncUserMeasurements(user.id, {
- fullSync,
- });
- return apiSuccess({ imported, fullSync });
+ const result = await syncUserMeasurements(user.id, { fullSync });
+ return apiSuccess({ ...resolveSyncOutcome(result), fullSync });
});
diff --git a/src/lib/jobs/reminder/__tests__/polar-sync-legs.test.ts b/src/lib/jobs/reminder/__tests__/polar-sync-legs.test.ts
index 557cf14e5..9ea8874ce 100644
--- a/src/lib/jobs/reminder/__tests__/polar-sync-legs.test.ts
+++ b/src/lib/jobs/reminder/__tests__/polar-sync-legs.test.ts
@@ -17,15 +17,18 @@ import { syncUserPolarLegs } from "../polar-sync";
beforeEach(() => {
vi.clearAllMocks();
- syncUserPolar.mockResolvedValue(0);
+ syncUserPolar.mockResolvedValue({ imported: 0, failed: false });
syncUserPolarWorkouts.mockResolvedValue(0);
});
describe("syncUserPolarLegs — composite independence on the shared polar ledger", () => {
it("runs both legs and returns the summed count when both pass", async () => {
- syncUserPolar.mockResolvedValue(5);
+ syncUserPolar.mockResolvedValue({ imported: 5, failed: false });
syncUserPolarWorkouts.mockResolvedValue(2);
- await expect(syncUserPolarLegs("u1")).resolves.toBe(7);
+ await expect(syncUserPolarLegs("u1")).resolves.toEqual({
+ imported: 7,
+ failed: false,
+ });
expect(syncUserPolar).toHaveBeenCalledWith("u1");
expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1");
});
@@ -35,9 +38,15 @@ describe("syncUserPolarLegs — composite independence on the shared polar ledge
// its healthy-collections import count rather than throwing. The wrapper
// must sum it with the workout leg and complete cleanly — a partial vitals
// failure never starves the user's success accounting at the cohort level.
- syncUserPolar.mockResolvedValue(4);
+ syncUserPolar.mockResolvedValue({ imported: 4, failed: true });
syncUserPolarWorkouts.mockResolvedValue(2);
- await expect(syncUserPolarLegs("u1")).resolves.toBe(6);
+ // The partial is carried, not swallowed: the wrapper completes with the
+ // summed count AND says a leg did not settle, so the card cannot paint a
+ // run in which four of five collections failed as a clean one.
+ await expect(syncUserPolarLegs("u1")).resolves.toEqual({
+ imported: 6,
+ failed: true,
+ });
expect(syncUserPolar).toHaveBeenCalledWith("u1");
expect(syncUserPolarWorkouts).toHaveBeenCalledWith("u1");
});
@@ -52,7 +61,7 @@ describe("syncUserPolarLegs — composite independence on the shared polar ledge
});
it("leaves the vitals leg's result intact when the workout leg fails", async () => {
- syncUserPolar.mockResolvedValue(4);
+ syncUserPolar.mockResolvedValue({ imported: 4, failed: false });
syncUserPolarWorkouts.mockRejectedValue(new Error("workouts down"));
// The vitals leg ran to completion (its own success already recorded on
// the shared ledger); the workout error is surfaced without undoing it.
diff --git a/src/lib/jobs/reminder/nightscout-sync.ts b/src/lib/jobs/reminder/nightscout-sync.ts
index 76cbc7b57..01bb0b25c 100644
--- a/src/lib/jobs/reminder/nightscout-sync.ts
+++ b/src/lib/jobs/reminder/nightscout-sync.ts
@@ -27,7 +27,9 @@ export const handleNightscoutSync = makePollCohortHandler({
});
return users.map((u) => u.id);
},
- syncUser: syncUserNightscout,
+ // The cohort accounts in rows imported; the failure half of the result is
+ // recorded on the `nightscout` ledger by the sync itself.
+ syncUser: async (userId) => (await syncUserNightscout(userId)).imported,
// The fetch path already records + marks its own failure (token-redacted), so
// this fires only for a future UNMARKED escape — and redacts the token before
// the message reaches the ledger.
diff --git a/src/lib/jobs/reminder/oura-sync.ts b/src/lib/jobs/reminder/oura-sync.ts
index a22d2f9ad..0b7c0288d 100644
--- a/src/lib/jobs/reminder/oura-sync.ts
+++ b/src/lib/jobs/reminder/oura-sync.ts
@@ -21,7 +21,9 @@ export const handleOuraSync = makePollCohortHandler({
});
return users.map((u) => u.id);
},
- syncUser: syncUserOura,
+ // The cohort accounts in rows imported; the failure half of the result is
+ // recorded on the `oura` ledger by the sync itself.
+ syncUser: async (userId) => (await syncUserOura(userId)).imported,
// Catches an UNMARKED escape — today the `upsertOuraMeasurements` write-path
// throw (the fetch/refresh path already records + marks its own failures).
recordFailure: recordOuraSyncFailure,
diff --git a/src/lib/jobs/reminder/polar-sync.ts b/src/lib/jobs/reminder/polar-sync.ts
index ee7c80253..97dd8dd50 100644
--- a/src/lib/jobs/reminder/polar-sync.ts
+++ b/src/lib/jobs/reminder/polar-sync.ts
@@ -15,6 +15,7 @@
*/
import { recordPolarSyncFailure, syncUserPolar } from "@/lib/polar/sync";
import { syncUserPolarWorkouts } from "@/lib/polar/sync-workouts";
+import type { SyncWriteResult } from "@/lib/outcome/written-outcome";
import { makePollCohortHandler, type PollCohortPayload } from "./poll-cohort";
export type PolarSyncPayload = PollCohortPayload;
@@ -34,13 +35,21 @@ export type PolarSyncPayload = PollCohortPayload;
* healthy collections. So the vitals leg reaching this wrapper's catch means a
* write-path/unexpected error, not a collection fetch failure.
*/
-export async function syncUserPolarLegs(userId: string): Promise {
+export async function syncUserPolarLegs(
+ userId: string,
+): Promise {
let total = 0;
let firstError: unknown;
let hadError = false;
+ // A settled-but-partial vitals leg neither throws nor reduces the count, so
+ // it has to be carried explicitly — otherwise a run in which four of five
+ // collections failed reads exactly like a clean one.
+ let partial = false;
try {
- total += await syncUserPolar(userId);
+ const vitals = await syncUserPolar(userId);
+ total += vitals.imported;
+ partial = vitals.failed;
} catch (err) {
firstError = err;
hadError = true;
@@ -56,7 +65,7 @@ export async function syncUserPolarLegs(userId: string): Promise {
}
if (hadError) throw firstError;
- return total;
+ return { imported: total, failed: partial };
}
export const handlePolarSync = makePollCohortHandler({
@@ -68,7 +77,9 @@ export const handlePolarSync = makePollCohortHandler({
});
return users.map((u) => u.id);
},
- syncUser: syncUserPolarLegs,
+ // The cohort accounts in rows imported; a partial vitals leg is already on
+ // the `polar` ledger, recorded by the leg that saw it.
+ syncUser: async (userId) => (await syncUserPolarLegs(userId)).imported,
// Catches an UNMARKED escape — the `upsertPolarMeasurements` /
// `upsertPolarWorkouts` write-path throw. The vitals leg records its own
// per-collection partial failures inline and returns without rethrowing; the
diff --git a/src/lib/jobs/reminder/withings-sync.ts b/src/lib/jobs/reminder/withings-sync.ts
index 96be0b354..d5ac1ab35 100644
--- a/src/lib/jobs/reminder/withings-sync.ts
+++ b/src/lib/jobs/reminder/withings-sync.ts
@@ -76,7 +76,7 @@ export async function handleWithingsFallbackSync(
for (const connection of connections) {
try {
- const imported = await syncUserMeasurements(connection.userId);
+ const { imported } = await syncUserMeasurements(connection.userId);
usersSynced++;
measurementsImported += imported;
// v1.18.1 — a fresh reading landed; resolve this user's Vorsorge
diff --git a/src/lib/nightscout/__tests__/sync.test.ts b/src/lib/nightscout/__tests__/sync.test.ts
index 770eb67d4..2d903a3ec 100644
--- a/src/lib/nightscout/__tests__/sync.test.ts
+++ b/src/lib/nightscout/__tests__/sync.test.ts
@@ -90,18 +90,18 @@ afterEach(() => {
});
describe("syncUserNightscout", () => {
- it("returns 0 and records nothing when the user is not configured", async () => {
+ it("returns an empty result and records nothing when the user is not configured", async () => {
getCredsMock.mockResolvedValue(null);
- const n = await syncUserNightscout("u1");
- expect(n).toBe(0);
+ const result = await syncUserNightscout("u1");
+ expect(result).toEqual({ imported: 0, failed: false });
expect(fetchSgvEntriesMock).not.toHaveBeenCalled();
expect(recordSuccessMock).not.toHaveBeenCalled();
});
it("writes each SGV entry as a BLOOD_GLUCOSE mg/dL measurement", async () => {
fetchSgvEntriesMock.mockResolvedValue([ENTRY_A, ENTRY_B]);
- const n = await syncUserNightscout("u1");
- expect(n).toBe(2);
+ const result = await syncUserNightscout("u1");
+ expect(result).toEqual({ imported: 2, failed: false });
expect(upsertMock).toHaveBeenCalledTimes(2);
const first = upsertMock.mock.calls[0]![0];
expect(first.create.type).toBe("BLOOD_GLUCOSE");
@@ -146,11 +146,11 @@ describe("syncUserNightscout", () => {
// ENTRY_A already exists (a re-confirm); ENTRY_B is genuinely new.
findManyMock.mockResolvedValue([{ externalId: "ns:abc" }]);
- const n = await syncUserNightscout("u1");
+ const result = await syncUserNightscout("u1");
// The inflated insert+reconfirm count used to fire the reminder-satisfy
// trigger every tick; the honest count is the single fresh insert.
- expect(n).toBe(1);
+ expect(result).toEqual({ imported: 1, failed: false });
// Both rows are still written (idempotent), but only the fresh one emits.
expect(upsertMock).toHaveBeenCalledTimes(2);
expect(emitArrivalsMock).toHaveBeenCalledTimes(1);
@@ -172,10 +172,12 @@ describe("syncUserNightscout", () => {
.mockResolvedValueOnce({ id: "r1" })
.mockRejectedValueOnce(new Error("bad row"));
- const n = await syncUserNightscout("u1");
+ const result = await syncUserNightscout("u1");
- // The one good row still landed; the tick is honest about the failed row.
- expect(n).toBe(1);
+ // The one good row still landed; the tick is honest about the failed row —
+ // the count alone used to narrow that away, so the card called a window in
+ // which nothing wrote a success.
+ expect(result).toEqual({ imported: 1, failed: true });
expect(recordSuccessMock).not.toHaveBeenCalled();
expect(recordFailureMock).toHaveBeenCalledWith(
expect.objectContaining({
diff --git a/src/lib/nightscout/sync.ts b/src/lib/nightscout/sync.ts
index 44588d512..724ccf46b 100644
--- a/src/lib/nightscout/sync.ts
+++ b/src/lib/nightscout/sync.ts
@@ -30,6 +30,7 @@ import {
type FailureKind,
} from "@/lib/integrations/status";
import type { IntegrationClassification } from "@/lib/integrations/http-status-classifier";
+import type { SyncWriteResult } from "@/lib/outcome/written-outcome";
import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
@@ -122,19 +123,28 @@ export function classifyNightscoutFailure(err: unknown): FailureKind {
}
/**
- * Sync one user's recent SGV entries. Returns the count of rows FRESHLY
- * INSERTED this tick (re-confirmed rows the upsert merely touched are not
- * counted — the old insert+reconfirm total inflated the cohort's
- * `measurements_imported` and fired the reminder-satisfy trigger every tick
- * regardless of whether new glucose actually arrived). A user with no
- * configured instance is a clean no-op (returns 0, touches no status row).
+ * Sync one user's recent SGV entries.
+ *
+ * `imported` counts the rows FRESHLY INSERTED this tick (re-confirmed rows the
+ * upsert merely touched are not counted — the old insert+reconfirm total
+ * inflated the cohort's `measurements_imported` and fired the reminder-satisfy
+ * trigger every tick regardless of whether new glucose actually arrived).
+ * `failed` is true when any SGV row did not write.
+ *
+ * The count used to be the whole return value, which narrowed the failures
+ * away: a tick where every row was refused answered the same `0` as a tick
+ * that found nothing new, and the settings card rendered both as a green tick
+ * with a count. The caller now gets both halves and decides.
+ *
+ * A user with no configured instance is a clean no-op (nothing imported,
+ * nothing failed, no status row touched).
*/
export async function syncUserNightscout(
userId: string,
opts: { count?: number } = {},
-): Promise {
+): Promise {
const creds = await getUserNightscoutCredentials(userId);
- if (!creds) return 0;
+ if (!creds) return { imported: 0, failed: false };
let entries: ParsedSgvEntry[];
try {
@@ -168,11 +178,11 @@ export async function syncUserNightscout(
kind: "transient",
message: `${failedRows} of ${entries.length} SGV rows failed to write`,
});
- return inserted;
+ return { imported: inserted, failed: true };
}
await recordSyncSuccess(userId, "nightscout");
- return inserted;
+ return { imported: inserted, failed: false };
}
/**
diff --git a/src/lib/openapi/routes/ocr.ts b/src/lib/openapi/routes/ocr.ts
index 6850f0f0a..2493c1f56 100644
--- a/src/lib/openapi/routes/ocr.ts
+++ b/src/lib/openapi/routes/ocr.ts
@@ -105,11 +105,15 @@ const commitResponse = z
reason: z.literal("duplicate"),
}),
),
+ outcome: z.enum(["empty", "failed", "partial", "success"]).meta({
+ description:
+ "How the commit reads, resolved from the counts rather than the status code. `success` only when every confirmed row was written; `partial` when some were skipped; `failed` when nothing was written and at least one row was skipped; `empty` when there was nothing to write.",
+ }),
})
.meta({
id: "OcrCommitResponse",
description:
- 'The rows written (`source: "OCR"`) and those skipped as commit-time duplicates.',
+ 'The rows written (`source: "OCR"`), those skipped as commit-time duplicates, and the resolved outcome.',
});
export const ocrPaths: NonNullable = {
diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts
index 2e13277b9..7851a749a 100644
--- a/src/lib/oura/__tests__/sync.test.ts
+++ b/src/lib/oura/__tests__/sync.test.ts
@@ -220,7 +220,7 @@ function createdRows() {
describe("syncUserOura", () => {
it("no-ops cleanly when unconnected", async () => {
getConnMock.mockResolvedValue(null);
- expect(await syncUserOura("u1")).toBe(0);
+ expect(await syncUserOura("u1")).toEqual({ imported: 0, failed: false });
expect(recordSuccessMock).not.toHaveBeenCalled();
});
@@ -229,7 +229,7 @@ describe("syncUserOura", () => {
fetchReadinessMock.mockResolvedValue([
{ id: "x", day: "2026-06-10", score: 84 },
]);
- const imported = await syncUserOura("u1");
+ const { imported } = await syncUserOura("u1");
expect(imported).toBe(1);
const row = createdRows()[0];
expect(row).toMatchObject({
@@ -253,7 +253,7 @@ describe("syncUserOura", () => {
refresh_token: "newRef",
expires_in: 86400,
});
- const imported = await syncUserOura("u1");
+ const { imported } = await syncUserOura("u1");
expect(refreshMock).toHaveBeenCalledWith("ref", expect.anything());
expect(storeTokensMock).toHaveBeenCalledWith(
"u1",
@@ -278,7 +278,7 @@ describe("syncUserOura", () => {
// A concurrent sync rotated first → the persist returns the PEER's token.
storeTokensMock.mockResolvedValue("peerAcc");
- const imported = await syncUserOura("u1");
+ const { imported } = await syncUserOura("u1");
// The retry runs with the peer's token rather than the invalidated one.
expect(fetchReadinessMock).toHaveBeenLastCalledWith(
@@ -461,7 +461,7 @@ describe("syncUserOura", () => {
fetchResilienceMock.mockResolvedValue([
{ id: "r", day: "2026-06-10", level: "strong" },
]);
- const imported = await syncUserOura("u1");
+ const { imported } = await syncUserOura("u1");
expect(imported).toBe(1);
const row = createdRows()[0];
expect(row).toMatchObject({
@@ -478,7 +478,7 @@ describe("syncUserOura", () => {
fetchResilienceMock.mockResolvedValue([
{ id: "r", day: "2026-06-10", level: "godlike" },
]);
- const imported = await syncUserOura("u1");
+ const { imported } = await syncUserOura("u1");
expect(imported).toBe(0);
expect(reconcileMock).not.toHaveBeenCalled();
});
@@ -561,10 +561,13 @@ 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 imported = result.imported;
const written = createdRows().map((row) => row.type);
expect(written).toContain("SLEEP_SCORE");
expect(written).toContain("OXYGEN_SATURATION");
@@ -596,7 +599,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 +623,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 +650,7 @@ describe("syncUserOura", () => {
{ id: "s", day: "2026-06-10", score: 80 },
]);
fetchCyclePhasesMock.mockRejectedValue(new Error("boom"));
- const imported = await syncUserOura("u1");
+ const { imported } = await syncUserOura("u1");
expect(imported).toBe(1);
expect(recordSuccessMock).toHaveBeenCalledWith("u1", "oura");
});
diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts
index fd6c1e2ff..4dfefaa83 100644
--- a/src/lib/oura/sync.ts
+++ b/src/lib/oura/sync.ts
@@ -52,6 +52,7 @@ import {
type InsertedMeasurementArrivalRow,
} from "@/lib/arrivals/measurement-emit";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
+import type { SyncWriteResult } from "@/lib/outcome/written-outcome";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
import {
sweepStaleSleepSegments,
@@ -411,16 +412,21 @@ async function fetchAll(
}
/**
- * Sync one user's Oura data. Returns the count of measurement rows written.
- * A user with no Oura connection is a clean no-op (returns 0, touches no
- * status row).
+ * Sync one user's Oura data.
+ *
+ * `imported` is the count of measurement rows written; `failed` is true when a
+ * collection did not settle. The count used to be the whole return value,
+ * which hid the partial: a run where every collection failed answered `0`, the
+ * same as a run that found nothing new, and the settings card reported both as
+ * a success. A user with no Oura connection is a clean no-op (nothing
+ * imported, nothing failed, no status row touched).
*/
export async function syncUserOura(
userId: string,
opts: { lookbackDays?: number } = {},
-): Promise {
+): Promise {
const conn = await getOuraConnection(userId);
- if (!conn) return 0;
+ if (!conn) return { imported: 0, failed: false };
// An explicit window from the caller wins; otherwise derive it from what we
// already hold so a gap widens the window instead of being skipped over.
@@ -516,11 +522,11 @@ export async function syncUserOura(
? String(firstErr.httpStatus)
: undefined,
});
- return imported;
+ return { imported, failed: true };
}
await recordSyncSuccess(userId, "oura");
- return imported;
+ return { imported, failed: false };
}
export async function upsertOuraMeasurements(
diff --git a/src/lib/polar/__tests__/sync.test.ts b/src/lib/polar/__tests__/sync.test.ts
index f5d12ff9c..3a1351d1d 100644
--- a/src/lib/polar/__tests__/sync.test.ts
+++ b/src/lib/polar/__tests__/sync.test.ts
@@ -164,7 +164,7 @@ function createdRows() {
describe("syncUserPolar", () => {
it("no-ops cleanly for an unconnected user", async () => {
getConnMock.mockResolvedValue(null);
- expect(await syncUserPolar("u1")).toBe(0);
+ expect(await syncUserPolar("u1")).toEqual({ imported: 0, failed: false });
expect(createManyAndReturnMock).not.toHaveBeenCalled();
expect(recordSuccessMock).not.toHaveBeenCalled();
});
@@ -186,7 +186,7 @@ describe("syncUserPolar", () => {
fetchRechargesMock.mockResolvedValue([
{ date: "2026-06-10", nightly_recharge_status: 6 },
]);
- const imported = await syncUserPolar("u1");
+ const { imported } = await syncUserPolar("u1");
expect(imported).toBe(1);
const row = createdRows()[0];
expect(row).toMatchObject({
@@ -206,7 +206,7 @@ describe("syncUserPolar", () => {
fetchCardioLoadsMock.mockResolvedValue([
{ date: "2026-06-10", cardio_load: 123.45 },
]);
- const imported = await syncUserPolar("u1");
+ const { imported } = await syncUserPolar("u1");
expect(imported).toBe(1);
const row = createdRows()[0];
expect(row).toMatchObject({
@@ -348,7 +348,7 @@ describe("syncUserPolar", () => {
{ date: "2026-06-10", cardio_load: 42 },
]);
- const imported = await syncUserPolar("u1");
+ const { imported } = await syncUserPolar("u1");
// No rethrow now — a fetch failure is isolated per-collection.
expect(imported).toBe(1);
@@ -397,8 +397,11 @@ describe("syncUserPolar", () => {
{ date: "2026-06-10", cardio_load: 55 },
]);
- const imported = await syncUserPolar("u1");
+ const result = await syncUserPolar("u1");
+ const imported = result.imported;
+ // The partial rides back with the count rather than being narrowed away.
+ expect(result.failed).toBe(true);
// The healthy four still wrote their rows — the source is not blanked.
const written = createdRows().map((row) => row.type);
expect(written).toContain("RECOVERY_SCORE");
@@ -438,7 +441,7 @@ describe("syncUserPolar", () => {
{ date: "2026-06-10", cardio_load: 77 },
]);
- const imported = await syncUserPolar("u1");
+ const { imported } = await syncUserPolar("u1");
expect(imported).toBe(1);
expect(createdRows().map((row) => row.type)).toContain("CARDIO_LOAD");
@@ -463,7 +466,7 @@ describe("syncUserPolar", () => {
{ start_time: "2026-06-10T00:00:00", steps: 8000 },
]);
- const imported = await syncUserPolar("u1");
+ const { imported } = await syncUserPolar("u1");
// The sweep never ran — a failed sleep collection pushes no sweep entries.
expect(updateManyMock).not.toHaveBeenCalled();
diff --git a/src/lib/polar/sync.ts b/src/lib/polar/sync.ts
index e6f62a920..17b6505c5 100644
--- a/src/lib/polar/sync.ts
+++ b/src/lib/polar/sync.ts
@@ -51,6 +51,7 @@ import {
recomputeBucketsForMeasurement,
} from "@/lib/rollups/measurement-rollups";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
+import type { SyncWriteResult } from "@/lib/outcome/written-outcome";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
import {
emitInsertedMeasurementArrivals,
@@ -161,9 +162,11 @@ export interface PolarCollectionFailure {
}
/**
- * Sync one user's Polar data. Returns the count of measurement rows written.
- * A user with no Polar connection is a clean no-op (returns 0, touches no
- * status row).
+ * Sync one user's Polar data. Returns the rows written AND whether a
+ * collection did not settle — the bare count used to narrow the partial away,
+ * so a run whose every collection failed answered the same `0` as a quiet
+ * night. A user with no Polar connection is a clean no-op (nothing imported,
+ * nothing failed, no status row touched).
*
* The five vitals collections are settled independently: the healthy ones
* import, a failing one is recorded as a single partial failure (no success
@@ -171,9 +174,9 @@ export interface PolarCollectionFailure {
* escapes now is a write-path/unexpected error out of `upsertPolarMeasurements`
* — the poll-cohort boundary records that unmarked escape once.
*/
-export async function syncUserPolar(userId: string): Promise {
+export async function syncUserPolar(userId: string): Promise {
const conn = await getPolarConnection(userId);
- if (!conn) return 0;
+ if (!conn) return { imported: 0, failed: false };
// Filled by the sleep collection's closure as it maps records; carried so the
// caller can run the record-scoped sweep before the upsert. A collection that
@@ -301,11 +304,11 @@ export async function syncUserPolar(userId: string): Promise {
? String(firstErr.httpStatus)
: undefined,
});
- return imported;
+ return { imported, failed: true };
}
await recordSyncSuccess(userId, "polar", { leg: POLAR_LEG_VITALS });
- return imported;
+ return { imported, failed: false };
}
/**
diff --git a/src/lib/withings/__tests__/sync-measure-watermark.test.ts b/src/lib/withings/__tests__/sync-measure-watermark.test.ts
index c597f80b4..e8f37cd3e 100644
--- a/src/lib/withings/__tests__/sync-measure-watermark.test.ts
+++ b/src/lib/withings/__tests__/sync-measure-watermark.test.ts
@@ -171,10 +171,10 @@ describe("syncUserMeasurements — watermark hold on hard row failure (F4)", ()
.mockRejectedValueOnce(new Error("connection reset by peer"))
.mockResolvedValue({} as never);
- const imported = await syncUserMeasurements("user-1");
+ const result = await syncUserMeasurements("user-1");
- // Only the second row persisted.
- expect(imported).toBe(1);
+ // Only the second row persisted, and the caller is told the run lost one.
+ expect(result).toEqual({ imported: 1, failed: true });
// The watermark is HELD — no stamp — so the next tick refetches the window.
expect(prisma.withingsConnection.update).not.toHaveBeenCalled();
expect(recordSyncSuccess).not.toHaveBeenCalled();
@@ -195,7 +195,7 @@ describe("syncUserMeasurements — watermark hold on hard row failure (F4)", ()
] as never);
vi.mocked(prisma.measurement.create).mockRejectedValueOnce(P2002);
- const imported = await syncUserMeasurements("user-1");
+ const { imported } = await syncUserMeasurements("user-1");
// P2002 means the row is already present — nothing lost — so the cycle is
// still healthy: stamp the watermark + success, record no failure.
@@ -216,7 +216,7 @@ describe("syncUserMeasurements — watermark hold on hard row failure (F4)", ()
},
] as never);
- const imported = await syncUserMeasurements("user-1");
+ const { imported } = await syncUserMeasurements("user-1");
expect(imported).toBe(1);
expect(prisma.withingsConnection.update).toHaveBeenCalledTimes(1);
diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts
index 3da585814..fcee1cc7c 100644
--- a/src/lib/withings/sync.ts
+++ b/src/lib/withings/sync.ts
@@ -32,6 +32,7 @@ import {
} from "@/lib/rollups/measurement-rollups";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
+import type { SyncWriteResult } from "@/lib/outcome/written-outcome";
import {
acquireProviderTokenRefreshLock,
PROVIDER_REFRESH_TRANSACTION_OPTIONS,
@@ -180,6 +181,9 @@ export const WITHINGS_INCREMENTAL_OVERLAP_MS = 10 * 60 * 1000;
* Sync measurements from Withings for a given user.
* Fetches data since last sync (or last 30 days if first sync).
*
+ * Returns what was written AND whether any part of the run did not land, so
+ * the caller can tell a quiet week apart from a run that refused every row.
+ *
* Status-tracking contract:
* - On any failure (refresh failure, fetch failure, downstream
* measurement-upsert that fails ALL items) we call
@@ -193,26 +197,29 @@ export const WITHINGS_INCREMENTAL_OVERLAP_MS = 10 * 60 * 1000;
export async function syncUserMeasurements(
userId: string,
opts: { fullSync?: boolean } = {},
-): Promise {
+): Promise {
// Park: if the last burst said reauth_required, do nothing until
- // the OAuth callback flips state back to "connected". Returning 0
- // matches the existing contract for "no-op sync".
+ // the OAuth callback flips state back to "connected". The run wrote
+ // nothing AND did not complete, so it reports as a failure — a bare 0
+ // read as "nothing new", which is how a parked connection could keep
+ // answering with a green tick.
if (await isReauthRequired(userId, "withings")) {
getEvent()?.addWarning(
`withings sync skipped for ${userId}: parked at error_reauth`,
);
- return 0;
+ return { imported: 0, failed: true };
}
const tokenInfo = await getValidToken(userId);
// Note: getValidToken already calls recordSyncFailure on the refresh
// path, so we don't double-record here.
- if (!tokenInfo) return 0;
+ if (!tokenInfo) return { imported: 0, failed: true };
const connection = await prisma.withingsConnection.findUnique({
where: { userId },
});
- if (!connection) return 0;
+ // No connection row is not a failure — there is nothing to sync.
+ if (!connection) return { imported: 0, failed: false };
const startDate = opts.fullSync
? undefined
@@ -390,7 +397,7 @@ export async function syncUserMeasurements(
firstRowError ?? new Error("Withings measure row write failed"),
WITHINGS_LEG_MEASURES,
);
- return imported;
+ return { imported, failed: true };
}
// Update last synced timestamp
@@ -407,7 +414,7 @@ export async function syncUserMeasurements(
leg: WITHINGS_LEG_MEASURES,
});
- return imported;
+ return { imported, failed: false };
}
/**
From 6911f115bc9a3b5241f755b35167d8073897806c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 27 Jul 2026 23:06:44 +0200
Subject: [PATCH 3/3] fix(integrations,labs): let the write decide how a sync
result reads
Six integration cards and the lab-OCR dialog keyed their tone off `res.ok`.
A sync that imported nothing because every reading was refused rendered a
green tick and a count, and a re-scan in which every confirmed row turned out
to be a duplicate raised a green toast saying zero readings were saved.
Each surface now renders the outcome the route resolved: a tick only when
everything landed, a warning when some of it did not, an error when none of
it did, and a neutral note when there was nothing new. The lab-OCR toast
picks its variant the same way.
Replaces the directory-scoped import guard with one that covers the whole
component tree and pins per-file occurrence counts, so a listed file cannot
quietly add a second tick. Its docstring states what it cannot catch.
---
messages/de.json | 8 +
messages/en.json | 8 +
messages/es.json | 8 +
messages/fr.json | 8 +
messages/it.json | 8 +
messages/pl.json | 8 +
.../success-affordance-guard.test.ts | 558 ++++++++++++++++++
src/components/labs/ocr-review-dialog.tsx | 22 +-
.../__tests__/written-outcome-line.test.tsx | 76 +++
.../__tests__/import-result-guard.test.ts | 138 -----
.../__tests__/sync-outcome.test.ts | 44 ++
.../settings/integrations/fitbit-card.tsx | 55 +-
.../integrations/google-health-card.tsx | 55 +-
.../settings/integrations/nightscout-card.tsx | 56 +-
.../integrations/oauth-provider-card.tsx | 54 +-
.../settings/integrations/sync-outcome.ts | 77 +++
.../settings/integrations/whoop-card.tsx | 55 +-
.../settings/integrations/withings-card.tsx | 61 +-
src/lib/oura/__tests__/sync.test.ts | 1 -
src/lib/validations/labs-ocr.ts | 9 +
20 files changed, 1032 insertions(+), 277 deletions(-)
create mode 100644 src/__tests__/success-affordance-guard.test.ts
create mode 100644 src/components/outcome/__tests__/written-outcome-line.test.tsx
delete mode 100644 src/components/settings/import-panel/__tests__/import-result-guard.test.ts
create mode 100644 src/components/settings/integrations/__tests__/sync-outcome.test.ts
create mode 100644 src/components/settings/integrations/sync-outcome.ts
diff --git a/messages/de.json b/messages/de.json
index e633cfc99..6e7ec78e3 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -5148,6 +5148,11 @@
"withingsFullSyncTitle": "Vollsynchronisierung?",
"withingsFullSyncDescription": "Alle verfügbaren Withings-Daten werden vollständig synchronisiert. Das kann je nach Historie etwas dauern.",
"withingsSyncResult": "{count} Messwerte synchronisiert",
+ "syncOutcome": {
+ "empty": "Nichts Neues zu importieren. Alles ist aktuell.",
+ "failed": "Es wurde nichts gespeichert. Der nächste Lauf versucht es erneut.",
+ "partial": "{count} Messwerte synchronisiert. Ein Teil der Daten konnte nicht gespeichert werden; der nächste Lauf versucht es erneut."
+ },
"withingsFullSyncResult": "{count} Messwerte vollständig synchronisiert",
"withingsSyncFailed": "Sync fehlgeschlagen",
"withingsOauthConnected": "Withings verbunden — der erste Sync startet im Hintergrund.",
@@ -7873,6 +7878,9 @@
"saveSelected": "{count} ausgewählte speichern",
"discardAll": "Alle verwerfen",
"savedToast": "{count} Laborwerte gespeichert",
+ "savedPartialToast": "{count} Laborwerte gespeichert, {skipped} als Dubletten übersprungen",
+ "savedNothingToast": "Nichts gespeichert: {skipped} Werte waren bereits erfasst",
+ "savedEmptyToast": "Nichts zu speichern",
"noRows": "In diesem Befund konnten keine Werte gefunden werden.",
"nothingToSave": "Wähle mindestens einen Wert zum Speichern aus.",
"saveFailed": "Werte konnten nicht gespeichert werden. Bitte erneut versuchen.",
diff --git a/messages/en.json b/messages/en.json
index d7183b5c9..2cb2ed52b 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5148,6 +5148,11 @@
"withingsFullSyncTitle": "Full synchronization?",
"withingsFullSyncDescription": "All available Withings data will be fully synchronized. This may take some time depending on your history.",
"withingsSyncResult": "{count} measurements synchronized",
+ "syncOutcome": {
+ "empty": "Nothing new to import. Everything is up to date.",
+ "failed": "Nothing was saved. The next run retries it.",
+ "partial": "{count} measurements synchronized. Some data could not be saved; the next run retries it."
+ },
"withingsFullSyncResult": "{count} measurements fully synchronized",
"withingsSyncFailed": "Sync failed",
"withingsOauthConnected": "Withings connected — the first sync starts in the background.",
@@ -7873,6 +7878,9 @@
"saveSelected": "Save {count} selected",
"discardAll": "Discard all",
"savedToast": "{count} lab results saved",
+ "savedPartialToast": "{count} lab results saved, {skipped} skipped as duplicates",
+ "savedNothingToast": "Nothing saved: {skipped} readings were already recorded",
+ "savedEmptyToast": "Nothing to save",
"noRows": "No readings could be found in this report.",
"nothingToSave": "Select at least one reading to save.",
"saveFailed": "Couldn't save the readings. Please try again.",
diff --git a/messages/es.json b/messages/es.json
index 642747c38..0fdf76133 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -5148,6 +5148,11 @@
"withingsFullSyncTitle": "¿Sincronización completa?",
"withingsFullSyncDescription": "Se sincronizarán por completo todos los datos disponibles de Withings. Esto puede tardar un poco según tu historial.",
"withingsSyncResult": "{count} mediciones sincronizadas",
+ "syncOutcome": {
+ "empty": "No hay nada nuevo que importar. Todo está al día.",
+ "failed": "No se guardó nada. El próximo intento lo repetirá.",
+ "partial": "{count} mediciones sincronizadas. Parte de los datos no se pudo guardar; el próximo intento lo repetirá."
+ },
"withingsFullSyncResult": "{count} mediciones sincronizadas por completo",
"withingsSyncFailed": "Falló la sincronización",
"withingsOauthConnected": "Withings conectado — la primera sincronización comienza en segundo plano.",
@@ -7873,6 +7878,9 @@
"saveSelected": "Guardar {count} seleccionados",
"discardAll": "Descartar todo",
"savedToast": "{count} resultados de laboratorio guardados",
+ "savedPartialToast": "{count} resultados de laboratorio guardados, {skipped} omitidos por duplicados",
+ "savedNothingToast": "No se guardó nada: {skipped} valores ya estaban registrados",
+ "savedEmptyToast": "No hay nada que guardar",
"noRows": "No se encontraron valores en este informe.",
"nothingToSave": "Selecciona al menos un valor para guardar.",
"saveFailed": "No se pudieron guardar los valores. Inténtalo de nuevo.",
diff --git a/messages/fr.json b/messages/fr.json
index 604b61659..dab63fc75 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -5148,6 +5148,11 @@
"withingsFullSyncTitle": "Synchronisation complète ?",
"withingsFullSyncDescription": "Toutes les données Withings disponibles seront entièrement synchronisées. Cela peut prendre un certain temps selon votre historique.",
"withingsSyncResult": "{count} mesures synchronisées",
+ "syncOutcome": {
+ "empty": "Rien de nouveau à importer. Tout est à jour.",
+ "failed": "Rien n'a été enregistré. La prochaine synchronisation réessaiera.",
+ "partial": "{count} mesures synchronisées. Une partie des données n'a pas pu être enregistrée ; la prochaine synchronisation réessaiera."
+ },
"withingsFullSyncResult": "{count} mesures entièrement synchronisées",
"withingsSyncFailed": "Échec de la synchronisation",
"withingsOauthConnected": "Withings connecté — la première synchronisation démarre en arrière-plan.",
@@ -7873,6 +7878,9 @@
"saveSelected": "Enregistrer {count} sélectionnées",
"discardAll": "Tout abandonner",
"savedToast": "{count} résultats de laboratoire enregistrés",
+ "savedPartialToast": "{count} résultats de laboratoire enregistrés, {skipped} ignorés car en double",
+ "savedNothingToast": "Rien enregistré : {skipped} valeurs étaient déjà présentes",
+ "savedEmptyToast": "Rien à enregistrer",
"noRows": "Aucune valeur n'a pu être trouvée dans ce compte rendu.",
"nothingToSave": "Sélectionnez au moins une valeur à enregistrer.",
"saveFailed": "Impossible d'enregistrer les valeurs. Veuillez réessayer.",
diff --git a/messages/it.json b/messages/it.json
index 715a74ce6..4967cf325 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -5148,6 +5148,11 @@
"withingsFullSyncTitle": "Sincronizzazione completa?",
"withingsFullSyncDescription": "Tutti i dati Withings disponibili verranno sincronizzati completamente. Potrebbe richiedere del tempo a seconda del tuo storico.",
"withingsSyncResult": "{count} misurazioni sincronizzate",
+ "syncOutcome": {
+ "empty": "Non c'è nulla di nuovo da importare. Tutto è aggiornato.",
+ "failed": "Non è stato salvato nulla. La prossima esecuzione riprova.",
+ "partial": "{count} misurazioni sincronizzate. Una parte dei dati non è stata salvata; la prossima esecuzione riprova."
+ },
"withingsFullSyncResult": "{count} misurazioni sincronizzate completamente",
"withingsSyncFailed": "Sincronizzazione non riuscita",
"withingsOauthConnected": "Withings collegato — la prima sincronizzazione parte in background.",
@@ -7873,6 +7878,9 @@
"saveSelected": "Salva {count} selezionati",
"discardAll": "Scarta tutto",
"savedToast": "{count} risultati di laboratorio salvati",
+ "savedPartialToast": "{count} risultati di laboratorio salvati, {skipped} ignorati perché duplicati",
+ "savedNothingToast": "Nulla salvato: {skipped} valori erano già registrati",
+ "savedEmptyToast": "Nulla da salvare",
"noRows": "Nessun valore trovato in questo referto.",
"nothingToSave": "Seleziona almeno un valore da salvare.",
"saveFailed": "Impossibile salvare i valori. Riprova.",
diff --git a/messages/pl.json b/messages/pl.json
index 927e9b7e5..273d0af37 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -5148,6 +5148,11 @@
"withingsFullSyncTitle": "Pełna synchronizacja?",
"withingsFullSyncDescription": "Wszystkie dostępne dane Withings zostaną w pełni zsynchronizowane. Może to chwilę potrwać w zależności od historii.",
"withingsSyncResult": "{count} pomiarów zsynchronizowano",
+ "syncOutcome": {
+ "empty": "Nie ma nic nowego do zaimportowania. Wszystko jest aktualne.",
+ "failed": "Nic nie zostało zapisane. Następne uruchomienie spróbuje ponownie.",
+ "partial": "Zsynchronizowano {count} pomiarów. Części danych nie udało się zapisać; następne uruchomienie spróbuje ponownie."
+ },
"withingsFullSyncResult": "{count} pomiarów w pełni zsynchronizowano",
"withingsSyncFailed": "Synchronizacja nie powiodła się",
"withingsOauthConnected": "Withings połączony — pierwsza synchronizacja rozpocznie się w tle.",
@@ -7873,6 +7878,9 @@
"saveSelected": "Zapisz {count} wybranych",
"discardAll": "Odrzuć wszystko",
"savedToast": "Zapisano {count} wyników laboratoryjnych",
+ "savedPartialToast": "Zapisano {count} wyników laboratoryjnych, {skipped} pominięto jako duplikaty",
+ "savedNothingToast": "Nic nie zapisano: {skipped} wyników było już zarejestrowanych",
+ "savedEmptyToast": "Nie ma nic do zapisania",
"noRows": "W tym wyniku nie znaleziono żadnych wartości.",
"nothingToSave": "Wybierz co najmniej jeden wynik do zapisania.",
"saveFailed": "Nie udało się zapisać wyników. Spróbuj ponownie.",
diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts
new file mode 100644
index 000000000..2bec7a7b9
--- /dev/null
+++ b/src/__tests__/success-affordance-guard.test.ts
@@ -0,0 +1,558 @@
+/**
+ * G3 — the success affordance is owned by the outcome module, everywhere.
+ *
+ * #640 established the rule inside the import panel: a write that wrote
+ * nothing must not read as a success. #650 widened it to the medication
+ * intake import. This widens it to the whole component tree, because the same
+ * defect had been sitting in seven more surfaces the whole time — six
+ * integration cards and the lab-OCR dialog reported "success" off `res.ok`,
+ * so a sync that imported nothing because every reading was refused rendered
+ * a green tick and a count.
+ *
+ * What this asserts:
+ * 1. Every `CheckCircle2` / `text-success` / `toast.success` occurrence
+ * under `src/components` is either inside the shared presentation module
+ * (`src/components/outcome/`) or pinned in `PINNED_AFFORDANCES` with an
+ * exact per-file count. A name-only allowlist would let a listed file add
+ * a SECOND, dishonest tick without anyone noticing, which is why the
+ * counts are pinned rather than the filenames.
+ * 2. No pinned entry has gone stale — a file that no longer carries the
+ * marker, or no longer exists, fails here rather than rotting silently.
+ * This is the guard-of-the-guard: if the scanner ever stopped matching,
+ * every entry would go stale at once and this is what would say so.
+ * 3. Every surface that reports a write routes its result line through the
+ * shared outcome module.
+ *
+ * HOW WEAK THIS GUARD IS, stated plainly: this is the weakest guard in the
+ * release and it is not complete. It matches three literal tokens. It is
+ * evadable by synonym — `text-emerald-500`, a raw checkmark glyph, an
+ * `
- {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). */