feat(oura): feed Cycle Insights phases into the cycle tracker

Pull Oura's daily_cycle_phases collection (undocumented in the published
OpenAPI spec, but confirmed live and auth-gated identically to every other
daily_* collection) on the existing hourly Oura poll. A luteal→follicular
transition, or a literal "menstrual" phase day, writes a single-day
CycleDayLog(flow=LIGHT, source=OURA) — the same conservative "logged but
unspecified intensity" marker the HealthKit unspecified-flow mapping uses.

Manual entries always win: the write only ever creates a day that has no
existing CycleDayLog row at all, regardless of source, and never updates a
row it did not create itself. No MenstrualCycle span is synthesised, mirroring
the Apple Health importer precedent. A 403/404 on the endpoint (the expected
outcome for most connections — Oura's reproductive-health resources are all
absent from its public spec) is swallowed without touching the rest of the
Oura connection's sync status.

No migration: reuses the existing CycleDayLog.source column and the OURA
value already present on MeasurementSource.
This commit is contained in:
Marc-André Bombeck committed 2026-07-17 14:29:07 +02:00
1 parent b3d586066a
commit 3aa11b0531
6 files changed
+577

No files matched your search

+100
View File
@@ -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"]);
});
});
+166
View File
@@ -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<typeof import("../client")>();
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" }),
}),
);
});
});
+51
View File
@@ -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");
});
});
+111
View File
@@ -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<OuraCyclePhase[]> {
return fetchCollection<OuraCyclePhase>(
"/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<string>();
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);
}
+126
View File
@@ -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<number> {
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;
}
+23
View File
@@ -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.