diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index 6a44695ae..1d51c364d 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -3,8 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { OURA_OAUTH_SCOPE, RESILIENCE_LEVELS, + derivePeriodDaysFromCyclePhases, exchangeCode, fetchCardiovascularAge, + fetchDailyCyclePhases, fetchReadiness, fetchResilience, fetchVo2Max, @@ -524,3 +526,101 @@ describe("OURA_OAUTH_SCOPE", () => { expect(OURA_OAUTH_SCOPE).toBe("daily personal"); }); }); + +describe("fetchDailyCyclePhases", () => { + it("reads the daily_cycle_phases collection path", async () => { + const fetchMock = installFetchMock([ + { + status: 200, + body: { + data: [{ id: "1", day: "2026-06-10", phase: "follicular" }], + next_token: null, + }, + }, + ]); + const r = await fetchDailyCyclePhases("tok", { + startDate: "2026-06-01", + endDate: "2026-06-10", + }); + expect(r).toHaveLength(1); + const [url] = fetchMock.mock.calls[0]!; + expect(url).toContain("/v2/usercollection/daily_cycle_phases"); + }); + + it("propagates a 403 (the undocumented / scope-gated case) as an OuraApiError", async () => { + installFetchMock([{ status: 403, body: { detail: "forbidden" } }]); + await expect( + fetchDailyCyclePhases("tok", { + startDate: "2026-06-01", + endDate: "2026-06-10", + }), + ).rejects.toBeInstanceOf(OuraApiError); + }); +}); + +describe("derivePeriodDaysFromCyclePhases", () => { + it("marks a literal 'menstrual' phase day directly", () => { + const days = derivePeriodDaysFromCyclePhases([ + { day: "2026-06-05", phase: "menstrual" }, + { day: "2026-06-10", phase: "follicular" }, + ]); + expect(days).toEqual(["2026-06-05"]); + }); + + it("marks the single day a luteal phase transitions into a follicular phase", () => { + const days = derivePeriodDaysFromCyclePhases([ + { day: "2026-06-08", phase: "luteal" }, + { day: "2026-06-09", phase: "luteal" }, + { day: "2026-06-10", phase: "follicular" }, + { day: "2026-06-11", phase: "follicular" }, + ]); + expect(days).toEqual(["2026-06-10"]); + }); + + it("does NOT mark a luteal→follicular transition across a gap in the series", () => { + const days = derivePeriodDaysFromCyclePhases([ + { day: "2026-06-08", phase: "luteal" }, + // 2026-06-09 missing — not calendar-adjacent. + { day: "2026-06-10", phase: "follicular" }, + ]); + expect(days).toEqual([]); + }); + + it("is order-independent — sorts records by day before scanning", () => { + const days = derivePeriodDaysFromCyclePhases([ + { day: "2026-06-10", phase: "follicular" }, + { day: "2026-06-09", phase: "luteal" }, + ]); + expect(days).toEqual(["2026-06-10"]); + }); + + it("ignores an unrecognised / missing phase string (never guesses)", () => { + const days = derivePeriodDaysFromCyclePhases([ + { day: "2026-06-08", phase: "luteal" }, + { day: "2026-06-09", phase: "unknown_future_phase" }, + { day: "2026-06-10", phase: "follicular" }, + ]); + // The unrecognised phase still updates the "previous phase" tracker (to + // a value that is neither luteal nor follicular), so the 06-09→06-10 + // step is no longer a luteal→follicular transition and nothing fires. + expect(days).toEqual([]); + }); + + it("returns [] for an empty or all-ovulatory/all-luteal series (no transition, no direct hit)", () => { + expect(derivePeriodDaysFromCyclePhases([])).toEqual([]); + expect( + derivePeriodDaysFromCyclePhases([ + { day: "2026-06-08", phase: "luteal" }, + { day: "2026-06-09", phase: "luteal" }, + ]), + ).toEqual([]); + }); + + it("skips records with a missing/empty day string", () => { + const days = derivePeriodDaysFromCyclePhases([ + { day: "", phase: "menstrual" }, + { day: "2026-06-10", phase: "menstrual" }, + ] as never); + expect(days).toEqual(["2026-06-10"]); + }); +}); diff --git a/src/lib/oura/__tests__/cycle-sync.test.ts b/src/lib/oura/__tests__/cycle-sync.test.ts new file mode 100644 index 000000000..ef732bbf4 --- /dev/null +++ b/src/lib/oura/__tests__/cycle-sync.test.ts @@ -0,0 +1,166 @@ +/** + * `syncUserOuraCyclePhases` — Oura Cycle Insights → CycleDayLog, subordinate + * to any existing row. The core invariant under test: a day that already + * carries ANY CycleDayLog row (regardless of source) is never touched — + * Oura only ever fills a genuinely empty day. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { fetchCyclePhasesMock, isCycleAvailableMock, findUserMock, createMock } = + vi.hoisted(() => ({ + fetchCyclePhasesMock: vi.fn(), + isCycleAvailableMock: vi.fn(), + findUserMock: vi.fn(), + createMock: vi.fn(), + })); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: findUserMock }, + cycleDayLog: { create: createMock }, + }, +})); + +vi.mock("@/lib/cycle/gate", () => ({ + isCycleAvailableForUser: isCycleAvailableMock, +})); + +vi.mock("../client", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchDailyCyclePhases: fetchCyclePhasesMock, + }; +}); + +import { syncUserOuraCyclePhases } from "../cycle-sync"; + +function uniqueViolation() { + const err = new Error("Unique constraint failed") as Error & { + code: string; + }; + err.code = "P2002"; + return err; +} + +beforeEach(() => { + fetchCyclePhasesMock.mockReset().mockResolvedValue([]); + isCycleAvailableMock.mockReset().mockResolvedValue(true); + findUserMock.mockReset().mockResolvedValue({ timezone: "Europe/Berlin" }); + createMock.mockReset().mockResolvedValue({ id: "cdl-1" }); +}); + +describe("syncUserOuraCyclePhases", () => { + it("no-ops without touching the DB when the collection is empty", async () => { + const created = await syncUserOuraCyclePhases("u1", "tok", 7); + expect(created).toBe(0); + expect(isCycleAvailableMock).not.toHaveBeenCalled(); + expect(createMock).not.toHaveBeenCalled(); + }); + + it("no-ops when the series carries no derivable period day", async () => { + fetchCyclePhasesMock.mockResolvedValue([ + { day: "2026-06-08", phase: "luteal" }, + { day: "2026-06-09", phase: "luteal" }, + ]); + const created = await syncUserOuraCyclePhases("u1", "tok", 7); + expect(created).toBe(0); + expect(isCycleAvailableMock).not.toHaveBeenCalled(); + }); + + it("respects the two-layer cycle gate — no write when disabled", async () => { + fetchCyclePhasesMock.mockResolvedValue([ + { day: "2026-06-05", phase: "menstrual" }, + ]); + isCycleAvailableMock.mockResolvedValue(false); + const created = await syncUserOuraCyclePhases("u1", "tok", 7); + expect(created).toBe(0); + expect(createMock).not.toHaveBeenCalled(); + }); + + it("creates a source=OURA flow=LIGHT row for a detected period day", async () => { + fetchCyclePhasesMock.mockResolvedValue([ + { day: "2026-06-05", phase: "menstrual" }, + ]); + const created = await syncUserOuraCyclePhases("u1", "tok", 7); + expect(created).toBe(1); + expect(createMock).toHaveBeenCalledWith({ + data: { + userId: "u1", + date: "2026-06-05", + tz: "Europe/Berlin", + source: "OURA", + flow: "LIGHT", + }, + }); + }); + + it("skips a day that already has ANY existing row (manual wins) — unique-violation swallowed silently", async () => { + fetchCyclePhasesMock.mockResolvedValue([ + { day: "2026-06-05", phase: "menstrual" }, + ]); + createMock.mockRejectedValue(uniqueViolation()); + const created = await syncUserOuraCyclePhases("u1", "tok", 7); + expect(created).toBe(0); + }); + + it("never calls .update — a pre-existing row (its own or another source's) is left exactly as-is", async () => { + fetchCyclePhasesMock.mockResolvedValue([ + { day: "2026-06-05", phase: "menstrual" }, + { day: "2026-06-06", phase: "menstrual" }, + ]); + createMock + .mockRejectedValueOnce(uniqueViolation()) // 06-05 already logged (manual) + .mockResolvedValueOnce({ id: "cdl-2" }); // 06-06 genuinely empty + const created = await syncUserOuraCyclePhases("u1", "tok", 7); + expect(created).toBe(1); + expect(createMock).toHaveBeenCalledTimes(2); + }); + + it("propagates a non-unique-violation write error without crashing the whole batch", async () => { + fetchCyclePhasesMock.mockResolvedValue([ + { day: "2026-06-05", phase: "menstrual" }, + { day: "2026-06-06", phase: "menstrual" }, + ]); + createMock + .mockRejectedValueOnce(new Error("connection reset")) + .mockResolvedValueOnce({ id: "cdl-2" }); + const created = await syncUserOuraCyclePhases("u1", "tok", 7); + // The failing day is skipped (warned, not thrown); the healthy sibling + // still writes. + expect(created).toBe(1); + }); + + 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( + "403 forbidden", + ); + expect(createMock).not.toHaveBeenCalled(); + }); + + it("falls back to a null tz when the user row / timezone is unresolvable", async () => { + fetchCyclePhasesMock.mockResolvedValue([ + { day: "2026-06-05", phase: "menstrual" }, + ]); + findUserMock.mockResolvedValue(null); + await syncUserOuraCyclePhases("u1", "tok", 7); + expect(createMock).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ tz: null }) }), + ); + }); + + it("creates one row per distinct detected day from a luteal→follicular transition", async () => { + fetchCyclePhasesMock.mockResolvedValue([ + { day: "2026-06-08", phase: "luteal" }, + { day: "2026-06-09", phase: "follicular" }, + ]); + const created = await syncUserOuraCyclePhases("u1", "tok", 7); + expect(created).toBe(1); + expect(createMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ date: "2026-06-09", flow: "LIGHT" }), + }), + ); + }); +}); diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index a10533017..7738bc43a 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -12,6 +12,7 @@ const { fetchVo2MaxMock, fetchCardioAgeMock, fetchResilienceMock, + fetchCyclePhasesMock, refreshMock, upsertMock, updateManyMock, @@ -31,6 +32,7 @@ const { fetchVo2MaxMock: vi.fn(), fetchCardioAgeMock: vi.fn(), fetchResilienceMock: vi.fn(), + fetchCyclePhasesMock: vi.fn(), refreshMock: vi.fn(), upsertMock: vi.fn(), updateManyMock: vi.fn(), @@ -78,6 +80,7 @@ vi.mock("../client", async (importOriginal) => { fetchVo2Max: fetchVo2MaxMock, fetchCardiovascularAge: fetchCardioAgeMock, fetchResilience: fetchResilienceMock, + fetchDailyCyclePhases: fetchCyclePhasesMock, refreshAccessToken: refreshMock, }; }); @@ -107,6 +110,7 @@ beforeEach(() => { fetchVo2MaxMock.mockReset().mockResolvedValue([]); fetchCardioAgeMock.mockReset().mockResolvedValue([]); fetchResilienceMock.mockReset().mockResolvedValue([]); + fetchCyclePhasesMock.mockReset().mockResolvedValue([]); refreshMock.mockReset(); upsertMock.mockReset().mockResolvedValue({}); updateManyMock.mockReset().mockResolvedValue({ count: 0 }); @@ -479,4 +483,51 @@ describe("syncUserOura", () => { expect(recordFailureMock).toHaveBeenCalled(); expect(recordSuccessMock).not.toHaveBeenCalled(); }); + + it("degrades gracefully when daily_cycle_phases 403s — the measurement sync still succeeds (F4/cycle)", async () => { + getConnMock.mockResolvedValue(CONN); + fetchReadinessMock.mockResolvedValue([ + { id: "1", day: "2026-06-10", score: 70 }, + ]); + // The endpoint is undocumented / gated for most connections — a 403 here + // must never surface as a whole-connection reauth prompt. + fetchCyclePhasesMock.mockRejectedValue( + new OuraApiError({ + verb: "fetchDailyCyclePhases", + classification: "reauth_required", + httpStatus: 403, + reason: "http_403", + }), + ); + const imported = await syncUserOura("u1"); + expect(imported).toBe(1); + expect(recordSuccessMock).toHaveBeenCalledWith("u1", "oura"); + expect(recordFailureMock).not.toHaveBeenCalled(); + }); + + it("re-reads the connection for the cycle-phases fetch (picks up a rotated token)", async () => { + getConnMock.mockResolvedValue(CONN); + await syncUserOura("u1"); + expect(fetchCyclePhasesMock).toHaveBeenCalledWith( + CONN.accessToken, + expect.objectContaining({ + startDate: expect.any(String), + endDate: expect.any(String), + }), + ); + // Once for the measurement-sync token, once more for the cycle-phases + // fetch's fresh read. + expect(getConnMock.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + it("a cycle-phases fetch error never blocks morning-refresh / rollup tail work", async () => { + getConnMock.mockResolvedValue(CONN); + fetchDailySleepMock.mockResolvedValue([ + { id: "s", day: "2026-06-10", score: 80 }, + ]); + fetchCyclePhasesMock.mockRejectedValue(new Error("boom")); + const imported = await syncUserOura("u1"); + expect(imported).toBe(1); + expect(recordSuccessMock).toHaveBeenCalledWith("u1", "oura"); + }); }); diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index 2081bfbc5..460fddfa4 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -15,6 +15,7 @@ */ import { getEvent } from "@/lib/logging/context"; import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; +import { dayDiff } from "@/lib/cycle/day-math"; import { safeFetch } from "@/lib/safe-fetch"; import { OuraApiError, classifyOuraResponse } from "./response-classifier"; @@ -256,6 +257,37 @@ export interface OuraResilience { level?: string | null; } +/** + * Oura `daily_cycle_phases` — the per-day Cycle Insights phase estimate + * (temperature-deviation-derived). UNDOCUMENTED in the published OpenAPI + * spec at `cloud.ouraring.com/v2/docs` as of this writing — the route was + * confirmed live against the production API (`GET + * /v2/usercollection/daily_cycle_phases` 400s "Token is missing" identically + * to every documented `daily_*` collection when called unauthenticated, vs a + * genuine 404 for an unknown path) but sits outside the spec dump alongside + * every other Oura reproductive-health resource (`period_start`, + * `pregnancy`, `fertile_window`, `ovulation_confirmed`, `blood_glucose` — all + * listed as valid webhook subscription data types, none with a documented + * read schema). These look gated behind an approval Oura does not extend to + * a self-registered OAuth app by default, so a 403/404 here is the EXPECTED + * outcome for most connections, not an anomaly — see `syncUserOuraCyclePhases` + * in `./cycle-sync` for how that is swallowed without touching the rest of + * the Oura connection's health status. Re-verify the `phase` field name + + * value set against `cloud.ouraring.com/v2/docs` once Oura documents it. + */ +export interface OuraCyclePhase { + id?: string; + day: string; + /** + * Oura's public Cycle Insights copy describes exactly two ring-observable + * phases (`follicular` / `luteal`, temperature-derived); some algorithm + * versions may additionally emit `menstrual` directly. Kept as a loose + * string — an unrecognised / missing value is skipped, never guessed (see + * `derivePeriodDaysFromCyclePhases`). + */ + phase?: string | null; +} + export interface OuraDailyActivity { id: string; day: string; @@ -429,6 +461,19 @@ export function fetchResilience( ); } +/** See the `OuraCyclePhase` docstring for the endpoint's documentation gap. */ +export function fetchDailyCyclePhases( + accessToken: string, + query: DateRangeQuery, +): Promise { + return fetchCollection( + "/v2/usercollection/daily_cycle_phases", + accessToken, + "fetchDailyCyclePhases", + query, + ); +} + // ─── Field → Measurement mapping ─────────────────────────────── const SEC_TO_MIN = 1 / 60; @@ -832,3 +877,69 @@ export function mapResilience(r: OuraResilience): MappedMeasurement[] { }, ]; } + +// ─── Cycle-phase → CycleDayLog mapping (v1.29.x) ─────────────────── +// +// `daily_cycle_phases` does NOT feed `Measurement` like every collection +// above — it feeds the cycle tracker's `CycleDayLog` via +// `syncUserOuraCyclePhases` in `./cycle-sync`, which owns the write-side +// "manual always wins" guard. This module only derives WHICH days look like +// a period day; it never touches the database. + +const OURA_MENSTRUAL_PHASE = "menstrual"; +const OURA_FOLLICULAR_PHASE = "follicular"; +const OURA_LUTEAL_PHASE = "luteal"; + +/** + * Derive the (sparse) set of `YYYY-MM-DD` days that look like a period day + * from a `daily_cycle_phases` series. The endpoint reports only the PHASE, + * not menstrual flow directly, so a period day is inferred two ways: + * + * 1. A record whose `phase` is literally `"menstrual"` — some algorithm + * versions / accounts may emit this directly. + * 2. The single day a `"luteal"` phase is immediately (calendar-adjacent) + * followed by a `"follicular"` phase. Oura's own definition of the + * follicular phase is "from the first day of menstruation to + * ovulation", so this transition day is the best period-start signal + * the two-phase (follicular/luteal) shape carries. + * + * Records are sorted by `day` internally (the collection's page order is not + * guaranteed to be chronological). Returns bare day strings — the caller + * writes each as a single-day conservative `flow: LIGHT` marker (the same + * "logged but unspecified intensity" boundary the HealthKit `unspecified` + * flow mapping uses), never a multi-day bleed span this endpoint gives no + * evidence for. + */ +export function derivePeriodDaysFromCyclePhases( + records: readonly OuraCyclePhase[], +): string[] { + const sorted = [...records] + .filter((r) => typeof r.day === "string" && r.day.length > 0) + .sort((a, b) => dayDiff(a.day, b.day)); + + const days = new Set(); + let prevPhase: string | null = null; + let prevDay: string | null = null; + + for (const r of sorted) { + const phase = typeof r.phase === "string" ? r.phase.toLowerCase() : null; + + if (phase === OURA_MENSTRUAL_PHASE) { + days.add(r.day); + } else if ( + phase === OURA_FOLLICULAR_PHASE && + prevPhase === OURA_LUTEAL_PHASE && + prevDay !== null && + dayDiff(r.day, prevDay) === 1 + ) { + days.add(r.day); + } + + if (phase) { + prevPhase = phase; + prevDay = r.day; + } + } + + return Array.from(days); +} diff --git a/src/lib/oura/cycle-sync.ts b/src/lib/oura/cycle-sync.ts new file mode 100644 index 000000000..a750f3c0a --- /dev/null +++ b/src/lib/oura/cycle-sync.ts @@ -0,0 +1,126 @@ +/** + * v1.29.x — Oura Cycle Insights (`daily_cycle_phases`) → HealthLog's cycle + * tracker, as an ADDITIONAL, SUBORDINATE source. + * + * Every other Oura collection (see `./sync`) writes `Measurement` rows, + * which support multiple source rows per `(type, day)` and resolve a + * canonical reading at READ time (`src/lib/analytics/source-priority.ts`). + * `CycleDayLog` has no such ladder — it is ONE canonical row per + * `(userId, date)` (`prisma/schema.prisma` — "the HealthKit sync unit + the + * manual log-day surface"), and the shared write helper + * (`src/lib/cycle/day-log-write.ts`) partial-merges a re-post's fields over + * whatever is already stored REGARDLESS of source. Calling it here would let + * an hourly Oura poll silently demote a user's own logged flow (e.g. a + * manual HEAVY re-tagged LIGHT on the next tick) — the exact opposite of + * "manual wins". + * + * So this module never touches the shared upsert helper. It only ever + * CREATEs a day that has NO existing `CycleDayLog` row at all (any source), + * and never updates a row it did not itself create. A day already owned by + * this module (source = OURA) is left alone on re-sync too — the marker is + * a fixed single-value hint (`flow: LIGHT`), not a corrigible reading, so + * there is nothing to refresh. The whole path degenerates to a cheap + * "create if absent, otherwise no-op". + * + * Gated behind the SAME two-layer cycle-module gate every other cycle + * writer respects (`isCycleAvailableForUser`) so an operator-disabled + * instance, or a user who turned cycle tracking off, never accumulates rows + * from a background Oura poll. No `MenstrualCycle` span is ever created or + * mutated here — mirrors the Apple Health importer precedent + * (`src/lib/cycle/import-accumulator.ts`), which attributes day-logs to an + * EXISTING cycle but never synthesises a new one from flow data either; + * cycle-span creation stays an explicit user action + * (`POST /api/cycle/period`). + */ +import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; +import { isCycleAvailableForUser } from "@/lib/cycle/gate"; +import { + fetchDailyCyclePhases, + derivePeriodDaysFromCyclePhases, +} from "./client"; + +/** The conservative "logged but unspecified intensity" flow level — mirrors + * the HealthKit `unspecified` → `LIGHT` boundary documented in + * `healthkit-mapping.ts`: a detected-but-unmeasured bleeding day is at least + * light flow, never invented as MEDIUM/HEAVY. */ +const OURA_PERIOD_FLOW = "LIGHT" as const; + +/** Prisma P2002 (unique constraint) detector, mirrored locally from + * `day-log-write.ts` rather than imported — a two-line duplicate is cheaper + * than coupling this module to that helper's internals. */ +function isUniqueViolation(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code?: unknown }).code === "P2002" + ); +} + +/** + * Sync one user's Oura `daily_cycle_phases` window into `CycleDayLog`. + * Returns the count of days newly created. + * + * Every error the Oura fetch throws propagates to the caller — this + * endpoint sits outside the scope most self-registered Oura apps are + * granted (see the `OuraCyclePhase` docstring in `./client`), so a 403/404 + * here is the COMMON case, not an anomaly. `./sync` swallows it without + * touching the rest of the Oura connection's health status. + * + * The Oura fetch runs BEFORE the cycle-module gate / any database access on + * purpose: the overwhelmingly common outcome (no access to this endpoint, or + * no transition in the window) resolves without a single query. + */ +export async function syncUserOuraCyclePhases( + userId: string, + accessToken: string, + lookbackDays: number, +): 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), + }); + if (records.length === 0) return 0; + + const periodDays = derivePeriodDaysFromCyclePhases(records); + if (periodDays.length === 0) return 0; + + const available = await isCycleAvailableForUser(userId); + if (!available) return 0; + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { timezone: true }, + }); + const tz = user?.timezone ?? null; + + let created = 0; + for (const day of periodDays) { + try { + await prisma.cycleDayLog.create({ + data: { + userId, + date: day, + tz, + source: "OURA", + flow: OURA_PERIOD_FLOW, + }, + }); + created += 1; + } catch (err) { + if (isUniqueViolation(err)) { + // A row already exists for this date — the user's own entry, an + // Apple Health import, or this module's own prior write. Never + // overwrite: Oura is a subordinate signal here. + continue; + } + getEvent()?.addWarning( + `oura: cycle day-log create failed for ${userId} ${day}: ${err}`, + ); + } + } + return created; +} diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index b91d09197..dbafc4a37 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -78,6 +78,7 @@ import { storeOuraTokens, } from "./credentials"; import { OuraApiError, classifyOuraError } from "./response-classifier"; +import { syncUserOuraCyclePhases } from "./cycle-sync"; /** Default lookback window (days) for an incremental sync. Oura finalises a * night's scores hours after wake; 7 days re-fetches a handful of records (the @@ -370,6 +371,28 @@ export async function syncUserOura( // sibling collection failed — one bad collection must not blank the source. const imported = await upsertOuraMeasurements(userId, result.readings); + // v1.29.x — best-effort Cycle Insights import. Fully isolated from the + // measurement sync's status ledger on purpose: `daily_cycle_phases` sits + // outside the scope most self-registered Oura apps are granted (see the + // `OuraCyclePhase` docstring in `./client`), so a 403/404 here is the + // COMMON case for a typical connection. Re-reads the connection so a + // reactive refresh `fetchAll` just performed is picked up rather than the + // (now possibly stale) token captured before it ran. + try { + const freshConn = await getOuraConnection(userId); + if (freshConn) { + await syncUserOuraCyclePhases( + userId, + freshConn.accessToken, + opts.lookbackDays ?? OURA_SYNC_LOOKBACK_DAYS, + ); + } + } catch (err) { + getEvent()?.addWarning( + `oura: cycle-phases import skipped for ${userId}: ${err}`, + ); + } + // S4 — trigger the debounced morning refresh on a last-night segment landing // (mirrors the Withings / WHOOP / Apple seams). Fires whether or not a // sibling collection failed, since the sleep readings were still imported.