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/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 diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index 4f819d2e1..691199cee 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 [ @@ -973,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 c1c8d4b5d..d9d764f53 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"; @@ -245,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; } /** @@ -261,24 +264,35 @@ 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[], 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) => !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 +469,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)); @@ -476,7 +492,7 @@ async function fetchAll( readings: first.readings, failures: first.failures, sleepSweeps, - scopeDenied: [], + scopeDenied: null, }; const rotated = await refreshAccessToken(refreshToken, creds); @@ -494,7 +510,7 @@ async function fetchAll( readings: first.readings, failures: first.failures, sleepSweeps, - scopeDenied: [], + scopeDenied: null, }; const retry = await attempt(usableToken, first.failed); @@ -551,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 diff --git a/src/lib/withings/__tests__/sync-plan.test.ts b/src/lib/withings/__tests__/sync-plan.test.ts index 24ab77259..89834898e 100644 --- a/src/lib/withings/__tests__/sync-plan.test.ts +++ b/src/lib/withings/__tests__/sync-plan.test.ts @@ -105,10 +105,12 @@ import { import { releasePlanGatedWithingsParks, requestWithinPlan, + shouldPersistPlanLeg, WITHINGS_ADVANCED_MEASTYPES, 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 +317,102 @@ 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("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"); @@ -326,19 +424,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); }); }); @@ -423,7 +630,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 +640,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..72b99f9a5 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. @@ -57,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 @@ -91,8 +104,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 +129,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 +146,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 +166,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 +178,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 +193,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 +211,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[][] { @@ -274,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 @@ -312,15 +353,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..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, @@ -566,10 +567,16 @@ 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(), + checkedAt: planCheckedAtFor(sleepPlan, reprobed, now), }); } } catch (err) { diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts index 67392e7b6..10bdc24b3 100644 --- a/src/lib/withings/sync.ts +++ b/src/lib/withings/sync.ts @@ -18,10 +18,12 @@ import { isPlanReprobeDue, parseWithingsApiPlanState, persistWithingsPlanLeg, + planCheckedAtFor, planSkipFor, requestWithinPlan, shouldPersistPlanLeg, WITHINGS_ADVANCED_MEASTYPES, + WITHINGS_CORE_MEASTYPES, WITHINGS_PLAN_GATED_STATUS, type WithinPlanResult, } from "./api-plan"; @@ -273,11 +275,17 @@ 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), - checkedAt: now.toISOString(), + checkedAt: planCheckedAtFor(planState.measures, reprobed, now), }); } catch (err) { // Losing the memory only costs a refused round trip next run. @@ -290,21 +298,33 @@ export async function syncUserMeasurements( name: "withings.sync.plan_gated", details: { unavailable: plan.unavailable, + unverified: plan.unverified, requests: plan.requests, acceptedAny: plan.acceptedAny, }, }, }); } - 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; @@ -486,11 +506,27 @@ 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. + // 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, skippedCore }, + }, + }); + } 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 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();