From 8ce6a83da53eb1b866bf5230a4653d6aee578d8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 14 Oct 2026 19:44:05 +0200 Subject: [PATCH 1/7] fix(withings): never remember a type the plan probe could not resolve When the request budget ran out mid-split, every subset not yet tried was stored as refused, so free types (temperature, SpO2, body composition) could be skipped for a week and named as withheld by the plan. Those types are now unverified: not remembered, asked for again on the next run, and the measure watermark holds until every type was read or proven refused. --- src/lib/withings/__tests__/sync-plan.test.ts | 75 +++++++++++++++++++- src/lib/withings/api-plan.ts | 37 ++++++---- src/lib/withings/sync.ts | 24 +++++-- 3 files changed, 116 insertions(+), 20 deletions(-) diff --git a/src/lib/withings/__tests__/sync-plan.test.ts b/src/lib/withings/__tests__/sync-plan.test.ts index 24ab77259..b94b29b54 100644 --- a/src/lib/withings/__tests__/sync-plan.test.ts +++ b/src/lib/withings/__tests__/sync-plan.test.ts @@ -109,6 +109,7 @@ import { WITHINGS_PLAN_MAX_REQUESTS, } from "../api-plan"; import { withingsPlanUnavailableFeatures } from "../plan-features"; +import { WITHINGS_MEASTYPES } from "../client"; const LAST_GOOD_SYNC = new Date("2026-10-12T21:12:00Z"); @@ -315,6 +316,73 @@ describe("syncUserMeasurements inside the Withings API plan", () => { expect(recordSyncFailure).not.toHaveBeenCalled(); }); + it("never remembers a type the budget could not resolve, and converges over runs", async () => { + // Two undocumented refusals on top of the documented set. Over the 19 + // types the first run runs out of budget before it reaches 12, 54, 71, + // 76, 77 and 88, all free. + refused = new Set([...WITHINGS_ADVANCED_MEASTYPES, 6, 35]); + let stored: unknown = null; + let lastSyncedAt = LAST_GOOD_SYNC; + vi.mocked(prisma.withingsConnection.findUnique).mockImplementation( + (async () => connection(stored, lastSyncedAt)) as never, + ); + vi.mocked(prisma.withingsConnection.update).mockImplementation( + (async (args: { data: { lastSyncedAt: Date } }) => { + lastSyncedAt = args.data.lastSyncedAt; + return {}; + }) as never, + ); + const runs: Array<{ unavailable: number[]; startdates: string[] }> = []; + const read = new Set(); + + for (let run = 0; run < 4; run++) { + vi.mocked(prisma.$executeRaw).mockClear(); + vi.mocked(prisma.measurement.create).mockClear(); + requestedStartdates.length = 0; + await syncUserMeasurements("user-1"); + const [persisted] = persistedLegs(); + if (persisted) { + const value = persisted.value as { + unavailable: number[]; + checkedAt: string; + }; + stored = { version: 1, measures: value }; + } + for (const types of requestedTypes.splice(0)) { + if (!types.some((t) => refused.has(t))) + types.forEach((t) => read.add(t)); + } + const current = stored as { + measures?: { unavailable: number[] }; + } | null; + runs.push({ + unavailable: current?.measures?.unavailable ?? [], + startdates: [...requestedStartdates], + }); + } + + const truly = [...refused].sort((a, b) => a - b); + // No run ever stored a type Withings did not refuse. + for (const r of runs) { + expect(r.unavailable.every((t) => refused.has(t))).toBe(true); + } + expect(runs.at(-1)!.unavailable).toEqual(truly); + // Every type the plan allows was read in an accepted request, the free + // ones the first run never reached included. + expect(read).toEqual( + new Set(WITHINGS_MEASTYPES.filter((t) => !refused.has(t))), + ); + expect([...read]).toEqual(expect.arrayContaining([12, 54, 71, 76, 77, 88])); + // The first run read nothing for the unresolved types, so the second + // still reads from the last good sync. + const fromLastGood = String( + Math.floor( + (LAST_GOOD_SYNC.getTime() - WITHINGS_INCREMENTAL_OVERLAP_MS) / 1000, + ), + ); + expect(runs[1]!.startdates.every((s) => s === fromLastGood)).toBe(true); + }); + it("reads the whole gap since the last good sync", async () => { await syncUserMeasurements("user-1"); @@ -423,7 +491,7 @@ describe("requestWithinPlan", () => { expect(second.unavailable.sort()).toEqual(["c", "f"]); }); - it("stops at the request budget and counts what is left as unavailable", async () => { + it("stops at the request budget and leaves what is left unverified, not unavailable", async () => { const result = await requestWithinPlan({ keys: ["a", "b", "c", "d", "e", "f", "g", "h"], knownGated: [], @@ -433,7 +501,10 @@ describe("requestWithinPlan", () => { }, }); expect(result.requests).toBe(3); - expect(result.unavailable.sort()).toEqual([ + // Only the requests actually made prove anything: [a..h], [a..d] and [a,b] + // were refused, which isolates no single key. + expect(result.unavailable).toEqual([]); + expect(result.unverified.sort()).toEqual([ "a", "b", "c", diff --git a/src/lib/withings/api-plan.ts b/src/lib/withings/api-plan.ts index 50704a36e..bbb3cd7ac 100644 --- a/src/lib/withings/api-plan.ts +++ b/src/lib/withings/api-plan.ts @@ -16,7 +16,8 @@ * documents as advanced, then, if it is still refused, splits what is left * in halves until every accepted subset has been read and every refused key * is isolated. Bounded by `WITHINGS_PLAN_MAX_REQUESTS`; anything the budget - * could not resolve counts as unavailable until the next probe. + * could not resolve is unverified, never remembered as refused, and asked + * for again on the next run, which starts from what this one proved. * - `WithingsApiPlanState` remembers per request family what the plan * refused, so later runs skip those keys without paying a refused round * trip, and re-probes them weekly in case the account's plan changes. @@ -91,8 +92,16 @@ const PLAN_GATED_MESSAGE = /Withings\s+\w+\s+error:\s*277\b/; export interface WithinPlanResult { /** Everything the accepted requests returned. */ results: R[]; - /** The requested keys the plan refused (or the budget could not clear). */ + /** + * The requested keys the plan refused: isolated alone, or dropped as + * documented advanced after the whole request was refused. + */ unavailable: K[]; + /** + * The requested keys the budget ran out on. Neither read nor proven + * refused: not to be remembered as unavailable, and to be asked for again. + */ + unverified: K[]; /** True when at least one request was refused with 277. */ gated: boolean; /** True when at least one request was accepted. */ @@ -108,8 +117,9 @@ export interface WithinPlanResult { * are dropped and the rest asked for again; on a further 277 the remainder is * split in halves, depth first and in input order, so the same refusal always * spends the same requests. A single key that is still refused is unavailable. - * When the budget runs out, the subsets not yet tried count as unavailable for - * now; the weekly re-probe asks for them again. + * When the budget runs out, the subsets not yet resolved are unverified: the + * caller remembers only what was proven, so the next run skips that and spends + * its budget on the rest. * * Any error other than a 277 propagates unchanged: this only decides what the * plan allows, never whether the connection works. @@ -124,6 +134,7 @@ export async function requestWithinPlan(opts: { const keys = [...opts.keys]; const results: R[] = []; const unavailable: K[] = []; + const unverified: K[] = []; let requests = 0; let gated = false; let acceptedAny = false; @@ -143,10 +154,10 @@ export async function requestWithinPlan(opts: { }; if (keys.length === 0) { - return { results, unavailable, gated, acceptedAny, requests }; + return { results, unavailable, unverified, gated, acceptedAny, requests }; } if (await attempt(keys)) { - return { results, unavailable, gated, acceptedAny, requests }; + return { results, unavailable, unverified, gated, acceptedAny, requests }; } const knownGated = new Set(opts.knownGated); @@ -155,14 +166,14 @@ export async function requestWithinPlan(opts: { if (dropped.length > 0) { unavailable.push(...dropped); if (remaining.length === 0) { - return { results, unavailable, gated, acceptedAny, requests }; + return { results, unavailable, unverified, gated, acceptedAny, requests }; } if (requests >= budget) { - unavailable.push(...remaining); - return { results, unavailable, gated, acceptedAny, requests }; + unverified.push(...remaining); + return { results, unavailable, unverified, gated, acceptedAny, requests }; } if (await attempt(remaining)) { - return { results, unavailable, gated, acceptedAny, requests }; + return { results, unavailable, unverified, gated, acceptedAny, requests }; } } @@ -170,14 +181,14 @@ export async function requestWithinPlan(opts: { // part has been read and each refused key stands alone. if (remaining.length === 1) { unavailable.push(...remaining); - return { results, unavailable, gated, acceptedAny, requests }; + return { results, unavailable, unverified, gated, acceptedAny, requests }; } const stack: K[][] = halves(remaining).reverse(); remaining = []; while (stack.length > 0) { const subset = stack.pop()!; if (requests >= budget) { - unavailable.push(...subset); + unverified.push(...subset); continue; } if (await attempt(subset)) continue; @@ -188,7 +199,7 @@ export async function requestWithinPlan(opts: { stack.push(...halves(subset).reverse()); } - return { results, unavailable, gated, acceptedAny, requests }; + return { results, unavailable, unverified, gated, acceptedAny, requests }; } function halves(list: K[]): K[][] { diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts index 67392e7b6..cf9eadff4 100644 --- a/src/lib/withings/sync.ts +++ b/src/lib/withings/sync.ts @@ -290,6 +290,7 @@ export async function syncUserMeasurements( name: "withings.sync.plan_gated", details: { unavailable: plan.unavailable, + unverified: plan.unverified, requests: plan.requests, acceptedAny: plan.acceptedAny, }, @@ -486,11 +487,24 @@ export async function syncUserMeasurements( return { imported, failed: true }; } - // Update last synced timestamp - await prisma.withingsConnection.update({ - where: { userId }, - data: { lastSyncedAt: new Date() }, - }); + // Types the probe budget could not resolve were not read this run. Moving + // the watermark past them would drop their readings in this window for + // good, so it stays put until a run reads (or proves refused) every type. + // Re-reading the accepted types is idempotent. + const holdWatermark = plan.unverified.length > 0; + if (holdWatermark) { + annotate({ + action: { + name: "withings.sync.plan_watermark_held", + details: { unverified: plan.unverified }, + }, + }); + } else { + await prisma.withingsConnection.update({ + where: { userId }, + data: { lastSyncedAt: new Date() }, + }); + } // Record success — even when measures.length === 0, completing the // round-trip is the signal "the connection is healthy". A user who From 6feaf6849ac490319f97664c66b55d5ee86bd3e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 14 Oct 2026 19:45:38 +0200 Subject: [PATCH 2/7] fix(withings): keep weight and blood pressure when every request is refused A run where Withings refused every requested type stored all of them, core types included, as outside the plan for a week; later runs then requested nothing and moved the watermark on with no readings, losing the gap. A refusal is now remembered only from a run in which another type was accepted. A run that read nothing keeps the watermark and reports that it did not land, and the watermark also holds while plan memory skips a core type (weight, blood pressure, pulse, free body composition). --- src/lib/withings/__tests__/sync-plan.test.ts | 114 ++++++++++++++++++- src/lib/withings/api-plan.ts | 28 ++++- src/lib/withings/sync-sleep.ts | 8 +- src/lib/withings/sync.ts | 43 +++++-- 4 files changed, 175 insertions(+), 18 deletions(-) diff --git a/src/lib/withings/__tests__/sync-plan.test.ts b/src/lib/withings/__tests__/sync-plan.test.ts index b94b29b54..862fd06cf 100644 --- a/src/lib/withings/__tests__/sync-plan.test.ts +++ b/src/lib/withings/__tests__/sync-plan.test.ts @@ -105,6 +105,7 @@ import { import { releasePlanGatedWithingsParks, requestWithinPlan, + shouldPersistPlanLeg, WITHINGS_ADVANCED_MEASTYPES, WITHINGS_PLAN_MAX_REQUESTS, } from "../api-plan"; @@ -394,19 +395,128 @@ describe("syncUserMeasurements inside the Withings API plan", () => { expect(requestedStartdates.every((s) => s === expected)).toBe(true); }); - it("holds the watermark and records no failure when the plan refuses everything", async () => { + it("treats a run where every request was refused as transient: nothing remembered, watermark held", async () => { refused = new Set([ 1, 5, 6, 8, 9, 10, 11, 12, 35, 54, 71, 76, 77, 88, 91, 123, 155, 170, 73, ]); const result = await syncUserMeasurements("user-1"); - expect(result).toEqual({ imported: 0, failed: false }); + expect(result).toEqual({ imported: 0, failed: true }); expect(prisma.withingsConnection.update).not.toHaveBeenCalled(); + // A 277 stays off the ledger, and a run that read nothing is no success. expect(recordSyncFailure).not.toHaveBeenCalled(); + expect(recordSyncSuccess).not.toHaveBeenCalled(); expect(requestedTypes.length).toBeLessThanOrEqual( WITHINGS_PLAN_MAX_REQUESTS, ); + // Weight and blood pressure are not remembered as refused. + expect(persistedLegs()).toEqual([]); + + // The fault clears; the next run asks for everything again and reads the + // whole gap since the last good sync. + refused = new Set(WITHINGS_ADVANCED_MEASTYPES); + requestedTypes.length = 0; + requestedStartdates.length = 0; + const next = await syncUserMeasurements("user-1"); + expect(next.failed).toBe(false); + expect(requestedTypes[0]).toEqual(expect.arrayContaining([1, 9, 10, 11])); + expect(createdTypes()).toEqual( + expect.arrayContaining(["WEIGHT", "BLOOD_PRESSURE_SYS", "PULSE"]), + ); + const fromLastGood = String( + Math.floor( + (LAST_GOOD_SYNC.getTime() - WITHINGS_INCREMENTAL_OVERLAP_MS) / 1000, + ), + ); + expect(requestedStartdates.every((s) => s === fromLastGood)).toBe(true); + }); + + it("holds the watermark when plan memory leaves nothing to request", async () => { + vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue( + connection({ + version: 1, + measures: { + unavailable: [...WITHINGS_MEASTYPES], + checkedAt: new Date().toISOString(), + }, + }) as never, + ); + + const result = await syncUserMeasurements("user-1"); + + expect(requestedTypes).toHaveLength(0); + expect(result).toEqual({ imported: 0, failed: true }); + expect(prisma.withingsConnection.update).not.toHaveBeenCalled(); + expect(recordSyncSuccess).not.toHaveBeenCalled(); + }); + + it("holds the watermark when a core type was skipped from plan memory", async () => { + vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue( + connection({ + version: 1, + measures: { + unavailable: [1, ...WITHINGS_ADVANCED_MEASTYPES], + checkedAt: new Date().toISOString(), + }, + }) as never, + ); + + const result = await syncUserMeasurements("user-1"); + + expect(requestedTypes).toHaveLength(1); + expect(requestedTypes[0]).not.toContain(1); + expect(result.failed).toBe(false); + expect(createdTypes()).toContain("BLOOD_PRESSURE_SYS"); + // Weight was not read in this window, so the window stays open for it. + expect(prisma.withingsConnection.update).not.toHaveBeenCalled(); + }); + + it("still moves the watermark when only advanced types are remembered", async () => { + vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue( + connection({ + version: 1, + measures: { + unavailable: [...WITHINGS_ADVANCED_MEASTYPES], + checkedAt: new Date().toISOString(), + }, + }) as never, + ); + + await syncUserMeasurements("user-1"); + + expect(prisma.withingsConnection.update).toHaveBeenCalledWith({ + where: { userId: "user-1" }, + data: { lastSyncedAt: expect.any(Date) }, + }); + }); +}); + +describe("shouldPersistPlanLeg", () => { + it("remembers a refusal only from a run where something else was accepted", () => { + expect( + shouldPersistPlanLeg({ gated: true, reprobed: false, acceptedAny: true }), + ).toBe(true); + expect( + shouldPersistPlanLeg({ + gated: true, + reprobed: false, + acceptedAny: false, + }), + ).toBe(false); + expect( + shouldPersistPlanLeg({ gated: true, reprobed: true, acceptedAny: false }), + ).toBe(false); + expect( + shouldPersistPlanLeg({ gated: false, reprobed: true, acceptedAny: true }), + ).toBe(true); + expect( + shouldPersistPlanLeg({ + gated: false, + reprobed: false, + acceptedAny: true, + }), + ).toBe(false); }); }); diff --git a/src/lib/withings/api-plan.ts b/src/lib/withings/api-plan.ts index bbb3cd7ac..c056eb072 100644 --- a/src/lib/withings/api-plan.ts +++ b/src/lib/withings/api-plan.ts @@ -58,6 +58,18 @@ export const WITHINGS_ADVANCED_MEASTYPES: readonly number[] = [ 91, 155, 170, 123, 73, ]; +/** + * The meastypes Withings lists as free in every plan and that carry the core + * of the measure leg: weight (1), blood pressure (9, 10), pulse (11) and the + * body-composition readings the table lists as free (fat-free mass 5, fat + * mass 8, muscle mass 76, hydration 77, bone mass 88). A run that leaves one + * of them out on the strength of plan memory has not read the window for it, + * so the measure watermark must not move past it. + */ +export const WITHINGS_CORE_MEASTYPES: readonly number[] = [ + 1, 5, 8, 9, 10, 11, 76, 77, 88, +]; + /** * Sleep-summary fields HealthLog requests that the table marks advanced: the * sleep score, the overnight HRV and the respiration rate. The nightly average @@ -323,15 +335,23 @@ export async function persistWithingsPlanLeg( } /** - * Decide whether a run's outcome is worth writing down. A run that met a 277 - * always is; so is a re-probe (the full set was asked for), whatever it found; - * a run that skipped remembered keys and was accepted changes nothing. + * Decide whether a run's outcome is worth writing down. + * + * A run that met a 277 is, but only when some other request in the same run + * was accepted: a plan never refuses everything, so a run where every request + * was refused (a transient fault upstream, a misconfigured developer app) + * proves nothing about any single key, and remembering it would skip weight + * and blood pressure for a week. A re-probe that met no 277 is written down + * too, so a cleared refusal is forgotten. A run that skipped remembered keys + * and was accepted changes nothing. */ export function shouldPersistPlanLeg(opts: { gated: boolean; reprobed: boolean; + acceptedAny: boolean; }): boolean { - return opts.gated || opts.reprobed; + if (opts.gated) return opts.acceptedAny; + return opts.reprobed; } // ── lifting parks an earlier build left ────────────────────────────────────── diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts index 0f7609221..a7817cd86 100644 --- a/src/lib/withings/sync-sleep.ts +++ b/src/lib/withings/sync-sleep.ts @@ -566,7 +566,13 @@ export async function syncUserSleep( ), }); summaries = plan.results; - if (shouldPersistPlanLeg({ gated: plan.gated, reprobed })) { + if ( + shouldPersistPlanLeg({ + gated: plan.gated, + reprobed, + acceptedAny: plan.acceptedAny, + }) + ) { await persistWithingsPlanLeg(userId, "sleepSummary", { unavailable: [...skip, ...plan.unavailable], checkedAt: now.toISOString(), diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts index cf9eadff4..e15d8d83e 100644 --- a/src/lib/withings/sync.ts +++ b/src/lib/withings/sync.ts @@ -22,6 +22,7 @@ import { requestWithinPlan, shouldPersistPlanLeg, WITHINGS_ADVANCED_MEASTYPES, + WITHINGS_CORE_MEASTYPES, WITHINGS_PLAN_GATED_STATUS, type WithinPlanResult, } from "./api-plan"; @@ -273,7 +274,13 @@ export async function syncUserMeasurements( await recordWithingsSyncFailure(userId, err, WITHINGS_LEG_MEASURES); throw err; } - if (shouldPersistPlanLeg({ gated: plan.gated, reprobed })) { + if ( + shouldPersistPlanLeg({ + gated: plan.gated, + reprobed, + acceptedAny: plan.acceptedAny, + }) + ) { try { await persistWithingsPlanLeg(userId, "measures", { unavailable: [...skip, ...plan.unavailable].sort((a, b) => a - b), @@ -297,15 +304,26 @@ export async function syncUserMeasurements( }, }); } - if (plan.gated && !plan.acceptedAny) { - // The plan refused every type asked for. Nothing was read, so the - // watermark stays where it is (a later plan change re-reads the gap), and - // nothing failed: the connection is healthy, the plan is the limit. The - // settings card names what the plan withholds. - await recordSyncSuccess(userId, "withings", { - leg: WITHINGS_LEG_MEASURES, + if (!plan.acceptedAny) { + // Nothing was read: either plan memory left nothing to ask for, or every + // request was refused. Every plan carries weight and blood pressure, so a + // run where all of it was refused is a fault upstream, not a plan answer. + // Nothing is remembered (above), the watermark stays where it is so the + // next run reads the whole gap, and the run reports that it did not land. + // It stays off the failure ledger like every other 277: a plan answer must + // never climb the strike ladder or park the connection. + getEvent()?.addWarning( + requested.length === 0 + ? "withings: plan memory left no measure type to request" + : "withings: every measure request was refused with 277", + ); + annotate({ + action: { + name: "withings.sync.plan_nothing_read", + details: { requested: requested.length, requests: plan.requests }, + }, }); - return { imported: 0, failed: false }; + return { imported: 0, failed: true }; } const fetched = plan.results; @@ -491,12 +509,15 @@ export async function syncUserMeasurements( // the watermark past them would drop their readings in this window for // good, so it stays put until a run reads (or proves refused) every type. // Re-reading the accepted types is idempotent. - const holdWatermark = plan.unverified.length > 0; + // The same holds for a core type left out on the strength of plan memory: + // that memory may be wrong, and the re-probe must find the window intact. + const skippedCore = skip.filter((t) => WITHINGS_CORE_MEASTYPES.includes(t)); + const holdWatermark = plan.unverified.length > 0 || skippedCore.length > 0; if (holdWatermark) { annotate({ action: { name: "withings.sync.plan_watermark_held", - details: { unverified: plan.unverified }, + details: { unverified: plan.unverified, skippedCore }, }, }); } else { From c7164be0d33ced11d2937f7470f1064b33a40803 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 14 Oct 2026 19:46:03 +0200 Subject: [PATCH 3/7] fix(withings): keep the plan re-probe date on a run that only narrowed A run that skipped remembered types and met one more refusal restamped the leg's checkedAt, pushing the weekly re-probe of the remembered types out on every such run. Only a re-probe, or a leg with nothing remembered yet, now starts the week again. --- src/lib/withings/__tests__/sync-plan.test.ts | 29 ++++++++++++++++++++ src/lib/withings/api-plan.ts | 18 ++++++++++++ src/lib/withings/sync-sleep.ts | 3 +- src/lib/withings/sync.ts | 3 +- 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/lib/withings/__tests__/sync-plan.test.ts b/src/lib/withings/__tests__/sync-plan.test.ts index 862fd06cf..89834898e 100644 --- a/src/lib/withings/__tests__/sync-plan.test.ts +++ b/src/lib/withings/__tests__/sync-plan.test.ts @@ -384,6 +384,35 @@ describe("syncUserMeasurements inside the Withings API plan", () => { expect(runs[1]!.startdates.every((s) => s === fromLastGood)).toBe(true); }); + it("keeps the remembered date when a run that is not the re-probe finds one more refusal", async () => { + const remembered = new Date(Date.now() - 3 * 86_400_000).toISOString(); + vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue( + connection({ + version: 1, + measures: { + unavailable: [...WITHINGS_ADVANCED_MEASTYPES], + checkedAt: remembered, + }, + }) as never, + ); + // The plan starts refusing fat ratio too. + refused = new Set([...WITHINGS_ADVANCED_MEASTYPES, 6]); + + await syncUserMeasurements("user-1"); + + expect(persistedLegs()).toEqual([ + { + leg: "measures", + value: { + unavailable: [6, ...WITHINGS_ADVANCED_MEASTYPES].sort( + (a, b) => a - b, + ), + checkedAt: remembered, + }, + }, + ]); + }); + it("reads the whole gap since the last good sync", async () => { await syncUserMeasurements("user-1"); diff --git a/src/lib/withings/api-plan.ts b/src/lib/withings/api-plan.ts index c056eb072..72b99f9a5 100644 --- a/src/lib/withings/api-plan.ts +++ b/src/lib/withings/api-plan.ts @@ -297,6 +297,24 @@ export function planSkipFor( return leg.unavailable; } +/** + * The `checkedAt` to store with a run's outcome. Only a re-probe asked for the + * full set, so only a re-probe (or a leg with nothing remembered yet) starts + * the week again. A run that skipped remembered keys and found one more + * refusal keeps the old date: moving it would push the re-probe of the keys it + * never asked for further out on every such run. + */ +export function planCheckedAtFor( + leg: WithingsPlanLegState | undefined, + reprobed: boolean, + now: Date, +): string { + if (reprobed || !leg || leg.unavailable.length === 0) { + return now.toISOString(); + } + return leg.checkedAt; +} + /** * Write one leg of the plan state. A single statement merges the leg into the * stored object, so the measures, sleep and ECG legs (separate crons, separate diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts index a7817cd86..1f8719950 100644 --- a/src/lib/withings/sync-sleep.ts +++ b/src/lib/withings/sync-sleep.ts @@ -65,6 +65,7 @@ import { isPlanReprobeDue, parseWithingsApiPlanState, persistWithingsPlanLeg, + planCheckedAtFor, planSkipFor, requestWithinPlan, shouldPersistPlanLeg, @@ -575,7 +576,7 @@ export async function syncUserSleep( ) { await persistWithingsPlanLeg(userId, "sleepSummary", { unavailable: [...skip, ...plan.unavailable], - checkedAt: now.toISOString(), + checkedAt: planCheckedAtFor(sleepPlan, reprobed, now), }); } } catch (err) { diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts index e15d8d83e..10bdc24b3 100644 --- a/src/lib/withings/sync.ts +++ b/src/lib/withings/sync.ts @@ -18,6 +18,7 @@ import { isPlanReprobeDue, parseWithingsApiPlanState, persistWithingsPlanLeg, + planCheckedAtFor, planSkipFor, requestWithinPlan, shouldPersistPlanLeg, @@ -284,7 +285,7 @@ export async function syncUserMeasurements( try { await persistWithingsPlanLeg(userId, "measures", { unavailable: [...skip, ...plan.unavailable].sort((a, b) => a - b), - checkedAt: now.toISOString(), + checkedAt: planCheckedAtFor(planState.measures, reprobed, now), }); } catch (err) { // Losing the memory only costs a refused round trip next run. From 55ddad3d6aa84d66547c69b4e032ad9de246720a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 14 Oct 2026 19:47:05 +0200 Subject: [PATCH 4/7] fix(oura): read a 401 as a scope gap only on the scope-gated collections A 401 on any collection next to a success was recorded as a scope gap, so a real auth failure on sleep, readiness or activity was hidden from the failure ledger and shown as a reconnect hint. Only spo2, vo2max, cardio_age and resilience sit behind a scope beyond daily; a 401 anywhere else keeps its normal failure handling, and a stored note naming another collection no longer skips the token refresh. --- src/lib/oura/__tests__/sync.test.ts | 44 +++++++++++++++++++++++++++++ src/lib/oura/sync.ts | 36 +++++++++++++++-------- 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index 4f819d2e1..00d4c4c0f 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -950,6 +950,50 @@ describe("syncUserOura — a grant without the spo2 / heart_health scopes", () = expect(recordSuccessMock).toHaveBeenCalledWith("u1", "oura"); }); + it("keeps a 401 on a core collection a failure even when siblings succeed", async () => { + getConnMock.mockResolvedValue(CONN); + fetchReadinessMock.mockResolvedValue([ + { id: "x", day: "2026-06-10", score: 70 }, + ]); + fetchSleepMock.mockRejectedValue(err401()); + refreshMock.mockResolvedValue({ + access_token: "newAcc", + refresh_token: "newRef", + expires_in: 86400, + }); + + const result = await syncUserOura("u1"); + + expect(refreshMock).toHaveBeenCalledTimes(1); + expect(result.failed).toBe(true); + expect(scopeDeniedMock).not.toHaveBeenCalledWith( + "u1", + expect.arrayContaining(["sleep"]), + ); + expect(recordFailureMock).toHaveBeenCalledWith( + expect.objectContaining({ integration: "oura" }), + ); + expect(recordSuccessMock).not.toHaveBeenCalled(); + }); + + it("does not let a stored core name skip the refresh", async () => { + getConnMock.mockResolvedValue({ ...CONN, scopeDenied: ["sleep"] }); + fetchReadinessMock.mockResolvedValue([ + { id: "x", day: "2026-06-10", score: 70 }, + ]); + fetchSleepMock.mockRejectedValueOnce(err401()).mockResolvedValue([]); + refreshMock.mockResolvedValue({ + access_token: "newAcc", + refresh_token: "newRef", + expires_in: 86400, + }); + + const result = await syncUserOura("u1"); + + expect(refreshMock).toHaveBeenCalledTimes(1); + expect(result.failed).toBe(false); + }); + it("still treats a 401 on every collection as a dead token", async () => { getConnMock.mockResolvedValue(CONN); for (const m of [ diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index c1c8d4b5d..040759195 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -91,6 +91,7 @@ import { } from "./credentials"; import { OuraApiError, classifyOuraError } from "./response-classifier"; import { syncUserOuraCyclePhases } from "./cycle-sync"; +import { OURA_SCOPE_GATED_COLLECTIONS } from "./scope"; import { shiftDateKey, userDayKey } from "@/lib/tz/format"; import { resolveUserTimezone } from "@/lib/tz/resolver"; @@ -261,13 +262,26 @@ function isAuthRefusal(err: unknown): boolean { return err instanceof OuraApiError && err.httpStatus === 401; } +const SCOPE_GATED: ReadonlySet = new Set(OURA_SCOPE_GATED_COLLECTIONS); + /** - * Split the auth refusals out of a run's failures once a sibling collection has - * proven the token. Oura's authorization server enforces scopes per collection, - * so a grant issued without `spo2` / `heart_health` answers 401 on exactly the - * collections behind them and 200 everywhere else. Counting those toward the - * reconnect ladder painted a working connection as broken on every sync, and - * refreshing the token over them changed nothing. + * A 401 that can be the grant's scope rather than the token: only the + * collections behind `spo2` / `heart_health` sit outside the `daily` scope + * every grant carries. A 401 on sleep, readiness or activity is a real auth + * failure whatever its siblings did. + */ +function isScopeRefusal(f: OuraCollectionFailure): boolean { + return SCOPE_GATED.has(f.name) && isAuthRefusal(f.err); +} + +/** + * Split the scope refusals out of a run's failures once a sibling collection + * has proven the token. Oura's authorization server enforces scopes per + * collection, so a grant issued without `spo2` / `heart_health` answers 401 on + * exactly the collections behind them and 200 everywhere else. Counting those + * toward the reconnect ladder painted a working connection as broken on every + * sync, and refreshing the token over them changed nothing. Any other failure, + * a 401 on a core collection included, stays a failure. */ function splitScopeDenied( failures: OuraCollectionFailure[], @@ -275,10 +289,8 @@ function splitScopeDenied( ): { failures: OuraCollectionFailure[]; scopeDenied: string[] } { if (!tokenProven) return { failures, scopeDenied: [] }; return { - failures: failures.filter((f) => !isAuthRefusal(f.err)), - scopeDenied: failures - .filter((f) => isAuthRefusal(f.err)) - .map((f) => f.name), + failures: failures.filter((f) => !isScopeRefusal(f)), + scopeDenied: failures.filter(isScopeRefusal).map((f) => f.name), }; } @@ -455,7 +467,9 @@ async function fetchAll( // to collections that succeeded, is the grant's scope again: a refresh cannot // widen it, so none is spent. Any other 401 still takes the one refresh, and // whatever 401s after it beside a success is recorded as scope. - const knownDenied = new Set(knownScopeDenied); + const knownDenied = new Set( + knownScopeDenied.filter((name) => SCOPE_GATED.has(name)), + ); const onlyKnownScope401 = first.failures .filter((f) => isAuthRefusal(f.err)) .every((f) => knownDenied.has(f.name)); From 530d1a6be2c527555c5d19327caef619270c7abc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 14 Oct 2026 19:47:37 +0200 Subject: [PATCH 5/7] fix(oura): keep the scope note when a run never proved the token The refresh-abort paths (no client credentials, refresh token lost to a peer) and a run where no collection succeeded wrote an empty scope list, clearing the reconnect hint until the next good run. Such a run learned nothing about the grant, so the stored note now stands. --- src/lib/oura/__tests__/sync.test.ts | 33 ++++++++++++++++++++++++++++- src/lib/oura/sync.ts | 33 +++++++++++++++++------------ 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index 00d4c4c0f..691199cee 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -1017,9 +1017,40 @@ describe("syncUserOura — a grant without the spo2 / heart_health scopes", () = const result = await syncUserOura("u1"); expect(result.failed).toBe(true); - expect(scopeDeniedMock).toHaveBeenCalledWith("u1", []); + // Nothing proved the token, so nothing was learned about the grant. + expect(scopeDeniedMock).not.toHaveBeenCalled(); expect(recordFailureMock).toHaveBeenCalledWith( expect.objectContaining({ integration: "oura", kind: "reauth_required" }), ); }); + + it("keeps the scope note when the refresh is abandoned for want of credentials", async () => { + getConnMock.mockResolvedValue({ ...CONN, scopeDenied: SCOPED }); + // A new 401 the note does not explain sends the run to the refresh. + fetchSleepMock.mockRejectedValue(err401()); + denyScopedCollections(); + getCredsMock.mockResolvedValue(null); + + await syncUserOura("u1"); + + expect(refreshMock).not.toHaveBeenCalled(); + expect(scopeDeniedMock).not.toHaveBeenCalled(); + }); + + it("keeps the scope note when the refresh loses its token to a peer", async () => { + getConnMock.mockResolvedValue({ ...CONN, scopeDenied: SCOPED }); + fetchSleepMock.mockRejectedValue(err401()); + denyScopedCollections(); + refreshMock.mockResolvedValue({ + access_token: "newAcc", + refresh_token: "newRef", + expires_in: 86400, + }); + storeTokensMock.mockResolvedValue(null); + + await syncUserOura("u1"); + + expect(refreshMock).toHaveBeenCalledTimes(1); + expect(scopeDeniedMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index 040759195..d9d764f53 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -246,11 +246,13 @@ export interface OuraFetchResult { */ sleepSweeps: SleepSegmentSweep[]; /** - * Collections refused with 401/403 while a sibling succeeded with the same + * Collections refused with 401 while a sibling succeeded with the same * token: the token is fine, the grant does not cover them. Not failures; the - * card asks for one reconnect instead. + * card asks for one reconnect instead. `null` when the run never proved the + * token (no collection succeeded, or the refresh was abandoned): it learned + * nothing about the grant, so the note from the last run that did stands. */ - scopeDenied: string[]; + scopeDenied: string[] | null; } /** @@ -286,8 +288,8 @@ function isScopeRefusal(f: OuraCollectionFailure): boolean { function splitScopeDenied( failures: OuraCollectionFailure[], tokenProven: boolean, -): { failures: OuraCollectionFailure[]; scopeDenied: string[] } { - if (!tokenProven) return { failures, scopeDenied: [] }; +): { failures: OuraCollectionFailure[]; scopeDenied: string[] | null } { + if (!tokenProven) return { failures, scopeDenied: null }; return { failures: failures.filter((f) => !isScopeRefusal(f)), scopeDenied: failures.filter(isScopeRefusal).map((f) => f.name), @@ -490,7 +492,7 @@ async function fetchAll( readings: first.readings, failures: first.failures, sleepSweeps, - scopeDenied: [], + scopeDenied: null, }; const rotated = await refreshAccessToken(refreshToken, creds); @@ -508,7 +510,7 @@ async function fetchAll( readings: first.readings, failures: first.failures, sleepSweeps, - scopeDenied: [], + scopeDenied: null, }; const retry = await attempt(usableToken, first.failed); @@ -565,15 +567,20 @@ export async function syncUserOura( // Remember which collections the grant does not cover, so the settings card // can ask for one reconnect. Best-effort: the note is a convenience, the // sync outcome does not depend on it. - if (result.scopeDenied.length > 0) { + // A run that never proved the token says nothing about the grant and leaves + // the note as it was. + const scopeDenied = result.scopeDenied; + if (scopeDenied && scopeDenied.length > 0) { annotate({ - meta: { oura_scope_denied: result.scopeDenied }, + meta: { oura_scope_denied: scopeDenied }, }); } - try { - await syncOuraScopeDenied(userId, result.scopeDenied); - } catch (err) { - getEvent()?.addWarning(`oura: scope note write failed: ${err}`); + if (scopeDenied) { + try { + await syncOuraScopeDenied(userId, scopeDenied); + } catch (err) { + getEvent()?.addWarning(`oura: scope note write failed: ${err}`); + } } // Clear whatever an earlier scoring left under the re-fetched sleep records From 03e80c1a94ea655977d25e31f452a5a9ba9efa00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 14 Oct 2026 19:49:33 +0200 Subject: [PATCH 6/7] fix(labs): drop the printed range text when an edit retires the conversion Changing the value of a converted reading cleared sourceValue and sourceUnit but kept the printed range text, which is written in the report's unit. With the unit gone every reader presented it as the marker unit's window, beside numbers in another unit. The text now goes with the conversion record (a whole-form echo of it does not bring it back); the bounds, already converted into the marker's unit, stay. --- src/app/api/labs/[id]/route.ts | 25 ++++++- .../lab-source-reference-range.test.ts | 75 +++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts index e8363b6e5..c828bdf1f 100644 --- a/src/app/api/labs/[id]/route.ts +++ b/src/app/api/labs/[id]/route.ts @@ -192,6 +192,9 @@ export const PUT = apiHandler( const data: Record = {}; if (d.panel !== undefined) data.panel = d.panel; if (d.analyte !== undefined) data.analyte = d.analyte; + // Set when a changed value retires a conversion whose printed range text is + // in the report's unit (see below). + let dropPrintedText = false; if (d.value !== undefined) { data.value = d.value; // A number the person typed is no longer the conversion of what the @@ -205,6 +208,18 @@ export const PUT = apiHandler( ) { data.sourceValue = null; data.sourceUnit = null; + // The printed range text belonged to the printed value and is written + // in the report's unit. With `sourceUnit` gone nothing would say so, + // and every reader would present it as the marker unit's window, so + // it goes with the record. The bounds stay: they were converted with + // the value and are already in the marker's unit. + if ( + existing.sourceUnit !== null && + existing.sourceReferenceText !== null + ) { + data.sourceReferenceText = null; + dropPrintedText = true; + } } } // v1.18.9 — qualitative edit. A row stays single-typed: editing `value` on a @@ -220,7 +235,15 @@ export const PUT = apiHandler( if (d.sourceReferenceHigh !== undefined) { data.sourceReferenceHigh = d.sourceReferenceHigh; } - if (d.sourceReferenceText !== undefined) { + // A whole-form echo of the old printed text does not bring it back once the + // value change retired it; a text that differs is a new statement and wins. + if ( + d.sourceReferenceText !== undefined && + !( + dropPrintedText && + d.sourceReferenceText === existing.sourceReferenceText + ) + ) { data.sourceReferenceText = d.sourceReferenceText; } if (d.takenAt !== undefined) data.takenAt = d.takenAt; diff --git a/tests/integration/lab-source-reference-range.test.ts b/tests/integration/lab-source-reference-range.test.ts index 57e50c028..923e72588 100644 --- a/tests/integration/lab-source-reference-range.test.ts +++ b/tests/integration/lab-source-reference-range.test.ts @@ -297,6 +297,81 @@ describe("a printed reference range, end to end", () => { expect(observations[0].valueQuantity?.unit).toBe("mg/dL"); }); + it("drops the printed text with the conversion record when the value is edited", async () => { + const prisma = getPrismaClient(); + const marker = await prisma.biomarker.create({ + data: { + userId: USER_ID, + name: "Glucose", + unit: "mg/dL", + lowerBound: 70, + upperBound: 100, + }, + }); + const PRINTED_MMOL = "3,9 - 5,5 mmol/l"; + const { POST } = await import("@/app/api/labs/route"); + const created = await POST( + postReq({ + biomarkerId: marker.id, + value: 5.6, + unit: "mmol/L", + sourceReferenceLow: 3.9, + sourceReferenceHigh: 5.5, + sourceReferenceText: PRINTED_MMOL, + takenAt: "2026-07-03T08:00:00.000Z", + }), + ); + expect(created.status).toBe(201); + const id = (await created.json()).data.id as string; + + // The person types a new number in mg/dL, and the form echoes the old + // printed text back with it. + const { PUT } = await import("@/app/api/labs/[id]/route"); + const edited = await PUT( + new NextRequest(`http://localhost/api/labs/${id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ value: 104, sourceReferenceText: PRINTED_MMOL }), + }), + { params: Promise.resolve({ id }) }, + ); + expect(edited.status).toBe(200); + + const row = await prisma.labResult.findUniqueOrThrow({ where: { id } }); + expect(row.sourceUnit).toBeNull(); + expect(row.sourceValue).toBeNull(); + expect(row.sourceReferenceText).toBeNull(); + // The bounds were converted with the value and stay, in mg/dL. + expect(row.sourceReferenceLow).toBeCloseTo(3.9 * 18.0182, 6); + expect(row.sourceReferenceHigh).toBeCloseTo(5.5 * 18.0182, 6); + + // No surface shows mmol/L text beside the mg/dL numbers. + const dto = await readDto("Glucose"); + expect(dto.converted).toBe(false); + expect(dto.sourceReferenceText).toBeNull(); + expect(dto.printedReferenceText).toBeNull(); + expect(dto.referenceOrigin).toBe("source"); + expect(dto.referenceHigh).toBeCloseTo(5.5 * 18.0182, 6); + + const { loadLabResults } = + await import("@/lib/doctor-report/clinical-records"); + const labs = await loadLabResults( + USER_ID, + new Date("2026-01-01T00:00:00.000Z"), + new Date("2026-12-31T23:59:59.000Z"), + ); + expect(labs?.[0].sourceReferenceText).toBeNull(); + + const { labObservations } = await import("@/lib/fhir/resources/labs"); + let seq = 0; + const observations = labObservations( + { labResults: labs } as Parameters[0], + () => `obs-${++seq}`, + ); + expect(observations[0].referenceRange?.[0]?.text).toBeUndefined(); + expect(observations[0].referenceRange?.[0]?.high?.unit).toBe("mg/dL"); + }); + it("keeps a window it cannot read as text and leaves the catalog in force", async () => { const prisma = getPrismaClient(); await seedMarker(); From fcced06fa732c589bc76de081f0575e5d062ee9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 14 Oct 2026 19:50:26 +0200 Subject: [PATCH 7/7] fix(labs): never open a small lab value as zero in the edit sheet The edit seed rounded every value to two decimals, so a TSH of 0.004 opened as 0. Values at or above 1 keep the two-decimal display precision; below 1 the seed keeps three significant figures, written without exponent notation. --- src/lib/labs/__tests__/value-update.test.ts | 12 +++++++++ src/lib/labs/value-update.ts | 27 ++++++++++++++++----- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/lib/labs/__tests__/value-update.test.ts b/src/lib/labs/__tests__/value-update.test.ts index dcc98d4c8..c952d06ad 100644 --- a/src/lib/labs/__tests__/value-update.test.ts +++ b/src/lib/labs/__tests__/value-update.test.ts @@ -12,6 +12,18 @@ describe("valueEditSeed", () => { expect(valueEditSeed(110)).toBe("110"); expect(valueEditSeed(4.567)).toBe("4.57"); }); + + it("never opens a small nonzero value as zero", () => { + expect(valueEditSeed(0.004)).toBe("0.004"); + expect(valueEditSeed(0.0123456)).toBe("0.0123"); + expect(valueEditSeed(0.87)).toBe("0.87"); + expect(valueEditSeed(0.123456)).toBe("0.123"); + expect(valueEditSeed(-0.004)).toBe("-0.004"); + expect(valueEditSeed(0.0000004)).toBe("0.0000004"); + for (const v of [0.004, 0.0004, 0.00004, 1e-9]) { + expect(Number(valueEditSeed(v))).not.toBe(0); + } + }); }); describe("resolveValueForUpdate", () => { diff --git a/src/lib/labs/value-update.ts b/src/lib/labs/value-update.ts index 5cbbad305..7dd945dc5 100644 --- a/src/lib/labs/value-update.ts +++ b/src/lib/labs/value-update.ts @@ -1,18 +1,33 @@ -import { formatLabValue } from "./format-value"; - /** * The numeric value of a lab reading in the edit sheet. * * A converted reading is stored at full precision (5.6 mmol/L of glucose is * 100.90191999999999 mg/dL), and the sheet must not open with that tail. The - * field starts from the value as every lab surface shows it: whole numbers - * bare, at most two decimals. Written with a dot, which the sheet's parser - * reads, rather than in the reader's locale, which an input cannot round-trip. + * field starts from the value at the precision every lab surface shows it: + * whole numbers bare, two decimals. Two decimals carry too little of a small + * value, though (a TSH of 0.004 would open as "0"), so below 1 the field keeps + * three significant figures instead. Written with a dot, which the sheet's + * parser reads, rather than in the reader's locale, which an input cannot + * round-trip, and never in exponent notation. */ export function valueEditSeed(value: number): string { - return formatLabValue(value, String); + if (Number.isInteger(value)) return String(value); + const magnitude = Math.floor(Math.log10(Math.abs(value))); + const decimals = Math.min( + MAX_SEED_DECIMALS, + Math.max(DISPLAY_DECIMALS, SEED_SIGNIFICANT_FIGURES - 1 - magnitude), + ); + return value + .toFixed(decimals) + .replace(/(\.\d*?)0+$/, "$1") + .replace(/\.$/, ""); } +const DISPLAY_DECIMALS = 2; +const SEED_SIGNIFICANT_FIGURES = 3; +/** `toFixed` accepts up to 100; no lab value needs more than this. */ +const MAX_SEED_DECIMALS = 20; + /** * What the edit PUT sends for the value. A field the person did not touch is * omitted (`undefined`), so the stored full-precision number stays as it is and