Merge branch 'fix/v1436-oura' into release/v1.43.6

This commit is contained in:
Marc-André Bombeck committed 2026-10-19 17:04:29 +02:00
commit 09c93cd547
16 files changed
+707 -6

No files matched your search

@@ -75,6 +75,7 @@ const sleepScore: SleepScoreValue = {
{ key: "composition", value: 88, weight: 0.15 },
],
windowNights: 20,
rhythmNights: 20,
series: [78, 82],
};
@@ -0,0 +1,202 @@
/**
* The Sleep Score's rhythm sub-scores (consistency, timing) for a ring wearer
* whose recent nights come from Oura while the phone's Apple Health history is
* still being read from years back. The Oura month carries the periods Oura
* really returns: a `long_sleep` most nights, short `sleep` naps, a `late_nap`
* in the evening, a `rest` period the person rejected, and two days on which
* the ring recorded no night at all and only a daytime nap. Rows go through
* the real Oura mapper, so the fixture has the shape the sync writes.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
user: { findUnique: vi.fn().mockResolvedValue(null) },
},
}));
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: vi.fn().mockResolvedValue("Europe/Berlin"),
}));
import { prisma } from "@/lib/db";
import { mapSleep, type OuraSleep } from "@/lib/oura/client";
import {
computeSleepScore,
reconstructNights,
rhythmGap,
scoreConsistency,
} from "../sleep-score";
const TZ = "Europe/Berlin";
const NOW = new Date("2026-10-01T09:00:00Z");
const PROFILE = { ageYears: 40, sex: "MALE" as const };
const findMany = prisma.measurement.findMany as ReturnType<typeof vi.fn>;
interface Row {
value: number;
measuredAt: Date;
sleepStage: string | null;
source: string;
deviceType: string | null;
}
/** A 5-minute hypnogram: awake at both ends, light / deep / REM between. */
function hypnogram(minutes: number): string {
const n = Math.round(minutes / 5);
const body = "2221133322224222113332";
let s = "444";
for (let i = 0; i < n - 5; i += 1) s += body[i % body.length];
return s + "44";
}
function period(
id: string,
type: string,
start: Date,
minutes: number,
): OuraSleep {
const end = new Date(start.getTime() + minutes * 60_000);
return {
id,
type,
day: end.toISOString().slice(0, 10),
bedtime_start: start.toISOString(),
bedtime_end: end.toISOString(),
sleep_phase_5_min: hypnogram(minutes),
efficiency: 90,
};
}
/** Thirty Oura days ending the morning of NOW, plus Apple Health from 2024. */
function fixture(): Row[] {
const periods: OuraSleep[] = [];
for (let d = 1; d <= 30; d += 1) {
// Bedtime 23:15 to 00:05 Berlin (21:15 to 22:05 UTC), eight hours.
const bed = new Date(Date.UTC(2026, 8, d, 21, 15 + ((d * 17) % 50)));
const noon = (h: number, m = 0) => new Date(Date.UTC(2026, 8, d + 1, h, m));
if (d === 9 || d === 21) {
// The ring spent the night on the charger: the day has only a nap.
periods.push(period(`only-${d}`, "sleep", noon(11), 60));
} else {
periods.push(period(`night-${d}`, "long_sleep", bed, 480));
}
if (d % 5 === 0) periods.push(period(`nap-${d}`, "sleep", noon(10), 40));
if (d % 7 === 0)
periods.push(period(`late-${d}`, "late_nap", noon(15, 30), 45));
if (d % 6 === 0) periods.push(period(`rest-${d}`, "rest", noon(11), 90));
}
const oura = periods.flatMap((p) =>
mapSleep(p, TZ)
.filter((m) => m.type === "SLEEP_DURATION")
.map((m) => ({
value: m.value,
measuredAt: m.measuredAt,
sleepStage: m.sleepStage ?? null,
source: "OURA",
deviceType: null,
})),
);
// The Watch history the phone is still working through, two years back.
const apple: Row[] = [];
for (let d = 1; d <= 20; d += 1) {
apple.push({
value: 420,
measuredAt: new Date(Date.UTC(2024, 8, d, 5, 0)),
sleepStage: "CORE",
source: "APPLE_HEALTH",
deviceType: "watch",
});
}
return [...apple, ...oura];
}
beforeEach(() => {
vi.clearAllMocks();
const rows = fixture();
// Honour the window the way the database would.
findMany.mockImplementation(
async (args: { where: { measuredAt: { gte: Date } } }) =>
rows
.filter((r) => r.measuredAt >= args.where.measuredAt.gte)
.sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime()),
);
});
describe("Sleep Score rhythm on an Oura month", () => {
it("keeps a nap-only day off the clock", () => {
const nights = reconstructNights(fixture() as never, TZ).filter(
(n) => n.night >= "2026-09-02",
);
const napOnly = nights.find((n) => n.night === "2026-09-10")!;
// The day still counts its nap...
expect(napOnly.asleepMinutes).toBeGreaterThan(0);
// ...but a nap is no point on the sleep rhythm.
expect(napOnly.midpoint).toBeNull();
const regular = nights.find((n) => n.night === "2026-09-11")!;
expect(regular.midpoint).not.toBeNull();
});
it("scores the consistency of a steady sleeper, not 0", async () => {
const result = await computeSleepScore("u1", PROFILE, { now: NOW });
expect(result.status).toBe("ok");
if (result.status !== "ok") return;
const sub = (k: string) =>
result.value.subScores.find((s) => s.key === k)!.value;
// Bedtimes wander within 50 minutes: a regular rhythm.
expect(sub("consistency")).toBeGreaterThanOrEqual(70);
expect(sub("timing")).toBeGreaterThanOrEqual(60);
// The Apple Health rows from 2024 never enter the window.
for (const call of findMany.mock.calls) {
expect(call[0].where.measuredAt.gte.getTime()).toBeGreaterThan(
Date.UTC(2026, 7, 31),
);
}
});
it("names too few nights as a gap, never as 0", async () => {
// Two nights on the ring so far: no rhythm yet.
const two = fixture().filter(
(r) =>
r.source === "OURA" &&
r.measuredAt >= new Date("2026-09-29T12:00:00Z") &&
r.measuredAt < new Date("2026-10-01T06:00:00Z"),
);
findMany.mockResolvedValue(two);
const result = await computeSleepScore("u1", PROFILE, { now: NOW });
expect(result.status).toBe("ok");
if (result.status !== "ok") return;
expect(result.value.rhythmNights).toBe(2);
for (const key of ["consistency", "timing"]) {
const s = result.value.subScores.find((x) => x.key === key)!;
expect(s.value).toBeNull();
expect(s.weight).toBe(0);
expect(s.gap).toBe("few_nights");
}
// A sub-score with a value carries no gap.
const sufficiency = result.value.subScores.find(
(x) => x.key === "sufficiency",
)!;
expect(sufficiency.value).not.toBeNull();
expect(sufficiency.gap).toBeUndefined();
});
it("names a latest night without a main sleep for timing only", () => {
expect(rhythmGap({ key: "timing", value: null }, 20, null)).toBe(
"no_main_sleep",
);
expect(
rhythmGap({ key: "consistency", value: null }, 20, null),
).toBeUndefined();
expect(rhythmGap({ key: "timing", value: 80 }, 20, 180)).toBeUndefined();
expect(
rhythmGap({ key: "efficiency", value: null }, 1, null),
).toBeUndefined();
});
it("still reads a truly irregular rhythm as irregular", () => {
// Midpoints spread across the night: 01:00, 04:00, 07:00, repeated.
const spread = Array.from({ length: 12 }, (_, i) => 60 + (i % 3) * 180);
expect(scoreConsistency(spread)).toBe(0);
});
});
+69 -4
View File
@@ -78,8 +78,22 @@ export interface SleepSubScore {
value: number | null;
/** Effective weight after null-redistribution, 0..1. */
weight: number;
/**
* Why a rhythm sub-score (consistency, timing) has no value, set only when
* `value` is null. `few_nights`: fewer than `RHYTHM_MIN_NIGHTS` nights in
* the window have a main sleep to place on the clock. `no_main_sleep`: the
* window has enough, but the latest night has none (a nap-only day). A
* client names the gap instead of reading it as "no data", and never as 0.
*/
gap?: SleepSubScoreGap;
}
/** The two reasons a rhythm sub-score is withheld. See `SleepSubScore.gap`. */
export type SleepSubScoreGap = "few_nights" | "no_main_sleep";
/** Nights with a main sleep the rhythm sub-scores need before they score. */
export const RHYTHM_MIN_NIGHTS = 3;
export interface SleepScoreValue {
/** The composite 0..100. */
score: number;
@@ -95,6 +109,12 @@ export interface SleepScoreValue {
subScores: SleepSubScore[];
/** Nights in the window that backed the consistency/timing baseline. */
windowNights: number;
/**
* Nights in the window whose main sleep places them on the clock — the
* nights consistency and timing are computed from. Below
* `RHYTHM_MIN_NIGHTS` both are withheld with `gap: "few_nights"`.
*/
rhythmNights: number;
/**
* v1.42 — the score of every scorable night in the window (oldest → newest,
* capped to `SPARKLINE_MAX_POINTS`), so a surface can show the score's
@@ -232,7 +252,7 @@ export function circularMeanMinutes(minutes: number[]): number | null {
* not penalised for a spurious ~24-hour spread.
*/
export function scoreConsistency(midpointMinutes: number[]): number | null {
if (midpointMinutes.length < 3) return null;
if (midpointMinutes.length < RHYTHM_MIN_NIGHTS) return null;
const mean = circularMeanMinutes(midpointMinutes);
if (mean == null) return null;
const variance =
@@ -256,7 +276,11 @@ export function scoreTiming(
habitualMidpoint: number | null,
windowNights: number,
): number | null {
if (nightMidpoint == null || habitualMidpoint == null || windowNights < 3) {
if (
nightMidpoint == null ||
habitualMidpoint == null ||
windowNights < RHYTHM_MIN_NIGHTS
) {
return null;
}
const FULL_SCALE = 90;
@@ -304,6 +328,24 @@ export function blendSleepSubScores(
return { score: clamp100(composite), subScores };
}
/**
* The reason a rhythm sub-score came out null, or undefined when it has a
* value or is not a rhythm sub-score. Consistency needs `RHYTHM_MIN_NIGHTS`
* nights on the clock; timing needs those and a main sleep on the latest
* night. Pure.
*/
export function rhythmGap(
sub: Pick<SleepSubScore, "key" | "value">,
rhythmNights: number,
latestMidpoint: number | null,
): SleepSubScoreGap | undefined {
if (sub.value !== null) return undefined;
if (sub.key !== "consistency" && sub.key !== "timing") return undefined;
if (rhythmNights < RHYTHM_MIN_NIGHTS) return "few_nights";
if (sub.key === "timing" && latestMidpoint == null) return "no_main_sleep";
return undefined;
}
// ── night reconstruction ───────────────────────────────────────────────
interface SleepRow {
@@ -398,9 +440,17 @@ export function reconstructNights(
// adds its minutes, which put the midpoint of a 23:00 to 07:00 night with a
// 14:00 nap at about 11:00 and read every such day as a ~7-hour shift in
// consistency and timing.
//
// And only when that main session is a main sleep. On a day the ring or
// watch recorded no night (left on the charger, taken off), the day's
// largest session is an afternoon nap, and its 14:00 midpoint sat ten
// hours from every other night: a single such day in a month pushed the
// midpoint spread past the 90-minute full scale and read the whole
// rhythm as Consistency 0. Such a day still counts its minutes; it just
// has no place on the clock.
const main = n.mainSession;
const midpoint =
main && main.asleepMinutes > 0
main && main.asleepMinutes >= MAIN_SLEEP_MIN_MINUTES
? minutesOfDay(
new Date(main.end.getTime() - (main.asleepMinutes / 2) * 60_000),
tz,
@@ -419,6 +469,15 @@ export function reconstructNights(
});
}
/**
* The least asleep time a day's main session needs before its midpoint is a
* point on the sleep rhythm (consistency, timing, chronotype, regularity).
* Three hours is where wearables draw the line between a nap and a main
* sleep (Oura types a period `long_sleep` from three hours on), and well
* above any nap, while a short night still clears it.
*/
const MAIN_SLEEP_MIN_MINUTES = 180;
function minutesOfDay(d: Date, tz: string): number {
if (tz === "UTC") return d.getUTCHours() * 60 + d.getUTCMinutes();
const { hour, minute } = wallClockInTz(d, tz);
@@ -554,7 +613,12 @@ export async function computeSleepScore(
),
};
const { score, subScores } = blendSleepSubScores(raw);
const blended = blendSleepSubScores(raw);
const score = blended.score;
const subScores = blended.subScores.map((s) => {
const gap = rhythmGap(s, midpoints.length, latest.midpoint);
return gap ? { ...s, gap } : s;
});
// The same blend over every scorable night in the window. Consistency is a
// window-level figure and timing reads against the window's habitual
@@ -602,6 +666,7 @@ export async function computeSleepScore(
latest.inBedMinutes === null ? null : Math.round(latest.inBedMinutes),
subScores,
windowNights: scorableNights.length,
rhythmNights: midpoints.length,
series,
},
coverage,