mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
b3d586066a
commit
3aa11b0531
6 files changed
+577
No files matched your search
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user