From ab31c9d89c0c1453b1a3ef22de807abe15c1be3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 27 Sep 2026 01:01:29 +0200 Subject: [PATCH] fix(oura): ask for cycle phases over the user's own days, like the other collections --- src/lib/oura/__tests__/cycle-sync.test.ts | 22 ++++++++++++---------- src/lib/oura/__tests__/sync.test.ts | 18 +++++++++++++++++- src/lib/oura/cycle-sync.ts | 13 ++++++------- src/lib/oura/sync.ts | 11 +++++++---- 4 files changed, 42 insertions(+), 22 deletions(-) diff --git a/src/lib/oura/__tests__/cycle-sync.test.ts b/src/lib/oura/__tests__/cycle-sync.test.ts index ef732bbf4..4319cbfed 100644 --- a/src/lib/oura/__tests__/cycle-sync.test.ts +++ b/src/lib/oura/__tests__/cycle-sync.test.ts @@ -35,6 +35,8 @@ vi.mock("../client", async (importOriginal) => { import { syncUserOuraCyclePhases } from "../cycle-sync"; +const WINDOW = { startDate: "2026-09-20", endDate: "2026-09-27" }; + function uniqueViolation() { const err = new Error("Unique constraint failed") as Error & { code: string; @@ -52,7 +54,7 @@ beforeEach(() => { describe("syncUserOuraCyclePhases", () => { it("no-ops without touching the DB when the collection is empty", async () => { - const created = await syncUserOuraCyclePhases("u1", "tok", 7); + const created = await syncUserOuraCyclePhases("u1", "tok", WINDOW); expect(created).toBe(0); expect(isCycleAvailableMock).not.toHaveBeenCalled(); expect(createMock).not.toHaveBeenCalled(); @@ -63,7 +65,7 @@ describe("syncUserOuraCyclePhases", () => { { day: "2026-06-08", phase: "luteal" }, { day: "2026-06-09", phase: "luteal" }, ]); - const created = await syncUserOuraCyclePhases("u1", "tok", 7); + const created = await syncUserOuraCyclePhases("u1", "tok", WINDOW); expect(created).toBe(0); expect(isCycleAvailableMock).not.toHaveBeenCalled(); }); @@ -73,7 +75,7 @@ describe("syncUserOuraCyclePhases", () => { { day: "2026-06-05", phase: "menstrual" }, ]); isCycleAvailableMock.mockResolvedValue(false); - const created = await syncUserOuraCyclePhases("u1", "tok", 7); + const created = await syncUserOuraCyclePhases("u1", "tok", WINDOW); expect(created).toBe(0); expect(createMock).not.toHaveBeenCalled(); }); @@ -82,7 +84,7 @@ describe("syncUserOuraCyclePhases", () => { fetchCyclePhasesMock.mockResolvedValue([ { day: "2026-06-05", phase: "menstrual" }, ]); - const created = await syncUserOuraCyclePhases("u1", "tok", 7); + const created = await syncUserOuraCyclePhases("u1", "tok", WINDOW); expect(created).toBe(1); expect(createMock).toHaveBeenCalledWith({ data: { @@ -100,7 +102,7 @@ describe("syncUserOuraCyclePhases", () => { { day: "2026-06-05", phase: "menstrual" }, ]); createMock.mockRejectedValue(uniqueViolation()); - const created = await syncUserOuraCyclePhases("u1", "tok", 7); + const created = await syncUserOuraCyclePhases("u1", "tok", WINDOW); expect(created).toBe(0); }); @@ -112,7 +114,7 @@ describe("syncUserOuraCyclePhases", () => { createMock .mockRejectedValueOnce(uniqueViolation()) // 06-05 already logged (manual) .mockResolvedValueOnce({ id: "cdl-2" }); // 06-06 genuinely empty - const created = await syncUserOuraCyclePhases("u1", "tok", 7); + const created = await syncUserOuraCyclePhases("u1", "tok", WINDOW); expect(created).toBe(1); expect(createMock).toHaveBeenCalledTimes(2); }); @@ -125,7 +127,7 @@ describe("syncUserOuraCyclePhases", () => { createMock .mockRejectedValueOnce(new Error("connection reset")) .mockResolvedValueOnce({ id: "cdl-2" }); - const created = await syncUserOuraCyclePhases("u1", "tok", 7); + const created = await syncUserOuraCyclePhases("u1", "tok", WINDOW); // The failing day is skipped (warned, not thrown); the healthy sibling // still writes. expect(created).toBe(1); @@ -133,7 +135,7 @@ describe("syncUserOuraCyclePhases", () => { it("propagates the Oura fetch error to the caller (the sync layer decides how to swallow it)", async () => { fetchCyclePhasesMock.mockRejectedValue(new Error("403 forbidden")); - await expect(syncUserOuraCyclePhases("u1", "tok", 7)).rejects.toThrow( + await expect(syncUserOuraCyclePhases("u1", "tok", WINDOW)).rejects.toThrow( "403 forbidden", ); expect(createMock).not.toHaveBeenCalled(); @@ -144,7 +146,7 @@ describe("syncUserOuraCyclePhases", () => { { day: "2026-06-05", phase: "menstrual" }, ]); findUserMock.mockResolvedValue(null); - await syncUserOuraCyclePhases("u1", "tok", 7); + await syncUserOuraCyclePhases("u1", "tok", WINDOW); expect(createMock).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ tz: null }) }), ); @@ -155,7 +157,7 @@ describe("syncUserOuraCyclePhases", () => { { day: "2026-06-08", phase: "luteal" }, { day: "2026-06-09", phase: "follicular" }, ]); - const created = await syncUserOuraCyclePhases("u1", "tok", 7); + const created = await syncUserOuraCyclePhases("u1", "tok", WINDOW); expect(created).toBe(1); expect(createMock).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index 2c51946a0..0cddccc1b 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -1,4 +1,12 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + onTestFinished, + vi, +} from "vitest"; const { getConnMock, @@ -842,6 +850,14 @@ describe("syncUserOura — window resolution", () => { }); it("passes the resolved window to the cycle-phases leg too", async () => { + // Pinned where the user's date and the UTC date differ (01:30 in Berlin, + // 23:30 UTC the day before), so a leg that cuts its window on UTC days + // asks Oura for a different range and the assertion below catches it. + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-09-26T23:30:00Z")); + onTestFinished(() => { + vi.useRealTimers(); + }); getConnMock.mockResolvedValue(CONN); findFirstMock.mockResolvedValue({ measuredAt: new Date(Date.now() - 10 * DAY_MS), diff --git a/src/lib/oura/cycle-sync.ts b/src/lib/oura/cycle-sync.ts index a750f3c0a..bb6d0a5dd 100644 --- a/src/lib/oura/cycle-sync.ts +++ b/src/lib/oura/cycle-sync.ts @@ -75,14 +75,13 @@ function isUniqueViolation(err: unknown): boolean { export async function syncUserOuraCyclePhases( userId: string, accessToken: string, - lookbackDays: number, + window: { startDate: string; endDate: string }, ): Promise { - const now = new Date(); - const start = new Date(now.getTime() - lookbackDays * 24 * 60 * 60 * 1000); - const records = await fetchDailyCyclePhases(accessToken, { - startDate: start.toISOString().slice(0, 10), - endDate: now.toISOString().slice(0, 10), - }); + // The window is the measurement sync's own, cut on the user's calendar days. + // Deriving it here from UTC dates made the two legs ask Oura for different + // days whenever the user's date and the UTC date differ (every evening west + // of UTC, every early morning east of it). + const records = await fetchDailyCyclePhases(accessToken, window); if (records.length === 0) return 0; const periodDays = derivePeriodDaysFromCyclePhases(records); diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index b545206b0..cc087bbc2 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -509,11 +509,14 @@ export async function syncUserOura( try { const freshConn = await getOuraConnection(userId); if (freshConn) { - await syncUserOuraCyclePhases( - userId, - freshConn.accessToken, - lookbackDays, + const cycleToday = userDayKey( + new Date(), + await resolveUserTimezone(userId), ); + await syncUserOuraCyclePhases(userId, freshConn.accessToken, { + startDate: shiftDateKey(cycleToday, -lookbackDays), + endDate: cycleToday, + }); } } catch (err) { getEvent()?.addWarning(