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] 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.