Merge branch 'fix/v1437-review' into release/v1.43.7

# Conflicts:
#	src/app/api/analytics/route.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 06:20:42 +02:00
commit 45c027aee2
217 files changed
+3636 -2127

No files matched your search

@@ -4,6 +4,8 @@ import {
reconstructSleepNights,
reconstructSleepSessions,
pickMainNightAndNaps,
isDaytimeNap,
separateNightsAndNaps,
summarizeSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
@@ -594,6 +596,80 @@ describe("pickMainNightAndNaps", () => {
});
});
describe("the one nap rule", () => {
it("tells a nap from a night by length and time of day together", () => {
// Short and in the afternoon: a nap.
expect(isDaytimeNap(60, 14 * 60)).toBe(true);
// Short but at night: the night.
expect(isDaytimeNap(150, 2 * 60)).toBe(false);
expect(isDaytimeNap(150, 22 * 60)).toBe(false);
// A late sleeper's short night, centred at 09:00.
expect(isDaytimeNap(150, 9 * 60)).toBe(false);
// Long in the daytime (after a night shift): a main sleep.
expect(isDaytimeNap(420, 13 * 60)).toBe(false);
});
it("gives a day of naps alone no main night", () => {
// 14:00 to 15:00 Berlin, nothing overnight (the watch was charging).
const rows: SleepStageRow[] = [row("2026-06-04T13:00:00.000Z", "CORE", 60)];
const sessions = reconstructSleepSessions(rows, "Europe/Berlin");
const { main, naps } = pickMainNightAndNaps(sessions);
expect(main).toBeNull();
expect(naps).toHaveLength(1);
expect(naps[0].daytimeNap).toBe(true);
const nights = reconstructSleepNights(rows, "Europe/Berlin");
expect(nights[0].asleepMinutes).toBe(60);
expect(nights[0].mainSession).toBeNull();
expect(separateNightsAndNaps(sessions)).toEqual([
{ night: "2026-06-04", main: null, naps },
]);
});
it("keeps a short night as the main sleep over a longer afternoon nap", () => {
const rows: SleepStageRow[] = [
// 02:00 to 03:30 Berlin: a broken night, 90 minutes.
row("2026-06-04T01:30:00.000Z", "CORE", 90),
// 13:00 to 15:00 Berlin: a two-hour nap.
row("2026-06-04T13:00:00.000Z", "CORE", 120),
];
const sessions = reconstructSleepSessions(rows, "Europe/Berlin");
const { main, naps } = pickMainNightAndNaps(sessions);
expect(main?.asleepMinutes).toBe(90);
expect(naps.map((n) => n.asleepMinutes)).toEqual([120]);
// The merged night places the same session on the clock.
const [night] = reconstructSleepNights(rows, "Europe/Berlin");
expect(night.mainSession?.asleepMinutes).toBe(90);
expect(night.mainSession?.end.getTime()).toBe(main?.end.getTime());
});
it("files a session under the day its last row ends on, over every writer", () => {
// The watch stops at 23:50 UTC; the phone's in-bed span runs to 00:10.
const rows: SleepStageRow[] = [
srcRow("2026-06-03T23:50:00.000Z", "CORE", 170, "WHOOP"),
srcRow("2026-06-04T00:10:00.000Z", "IN_BED", 200, "APPLE_HEALTH"),
];
const sessions = reconstructSleepSessions(rows, "UTC");
const nights = reconstructSleepNights(rows, "UTC");
expect(sessions.map((s) => s.night)).toEqual(["2026-06-04"]);
expect(nights.map((n) => n.night)).toEqual(["2026-06-04"]);
});
});
describe("a zero-minute granular row", () => {
it("does not strip the bare aggregate that carries the night", () => {
// A writer's bare 6-hour night beside a REM row of no length: the REM
// row partitions nothing, so the night is the aggregate's six hours.
const rows: SleepStageRow[] = [
row("2026-06-04T06:00:00.000Z", "ASLEEP", 360),
row("2026-06-04T06:00:00.000Z", "REM", 0),
];
const [session] = reconstructSleepSessions(rows, "UTC");
expect(session.asleepMinutes).toBe(360);
expect(session.stages.ASLEEP).toBe(360);
expect(reconstructSleepNights(rows, "UTC")[0].asleepMinutes).toBe(360);
});
});
describe("summarizeSleepNights", () => {
it("summarises per-night totals, not per-stage rows", () => {
const rows: SleepStageRow[] = [
@@ -373,6 +373,38 @@ describe("reference-score pillars", () => {
}
});
it("holds regularity back until 21 nights carry a midpoint, not 21 nights", () => {
const nights = days(24, 480).map((point, index) => ({
night: point.day,
asleepMinutes: point.value,
// Four nap-only days count toward the level but have no place on the
// clock, which leaves 20 midpoints.
midpoint: index % 6 === 0 ? null : 180,
}));
const result = computeSleepPillar({
...live,
asOf: NOW,
ageYears: 40,
nights,
sources: ["APPLE_HEALTH"],
});
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.value.observed.label).not.toContain("regularity");
}
const enough = computeSleepPillar({
...live,
asOf: NOW,
ageYears: 40,
nights: nights.map((n) => ({ ...n, midpoint: 180 })),
sources: ["APPLE_HEALTH"],
});
if (enough.status === "ok") {
expect(enough.value.observed.label).toContain("regularity");
}
expect(enough.status).toBe("ok");
});
it("scores exactly 28 sleep calendar keys", () => {
const nights = days(29, 600).map((point) => ({
night: point.day,
+4 -1
View File
@@ -62,8 +62,11 @@ export function computeSleepPillar(
const midpoints = nights
.map((night) => night.midpoint)
.filter((midpoint): midpoint is number => midpoint != null);
// The floor is on the nights that have a place on the clock: a day whose
// only sleep was a nap counts toward the level but carries no midpoint, so
// 21 nights could hold far fewer midpoints than the floor asks for.
const regularity =
nights.length >= SLEEP_REGULARITY_MIN_NIGHTS
midpoints.length >= SLEEP_REGULARITY_MIN_NIGHTS
? scoreConsistency(midpoints)
: null;
// These are the existing Sleep Score's own declared sub-score weights,
+153 -46
View File
@@ -48,7 +48,7 @@
* measured on the absolute UTC instant, so it is immune to DST shifts.
*
* Each session is keyed by the LOCAL WAKE DAY — `userDayKey` of the session's
* LAST segment (its end instant) in the user's timezone. This matches the
* LAST row end over every writer, in the user's timezone. This matches the
* Apple Health convention (a sleep session is attributed to the date you wake
* up) and keeps the keying tz/DST-correct because it runs on the real instant.
*
@@ -58,9 +58,12 @@
* day under one key so the headline "time asleep" for a day is the day's
* total. `reconstructSleepSessions` keeps each session SEPARATE, and
* `pickMainNightAndNaps` applies the convention the UI needs: the MAIN night
* is the session with the most asleep minutes (normally the overnight block),
* and every other session on the same wake day is a NAP, surfaced separately
* and never folded into the main night's headline.
* is the main sleep with the most asleep minutes (normally the overnight
* block), and every other session on the same wake day is a NAP, surfaced
* separately and never folded into the main night's headline. A session is
* a nap rather than a main sleep when it is short and centred in the daytime
* (`isDaytimeNap`), so a day with an afternoon nap and no recorded night has
* no main night at all, rather than the nap standing in for one.
*
* Writer de-dup
* -------------
@@ -127,6 +130,7 @@
*/
import type { MeasurementSource, SleepStage } from "@/generated/prisma/client";
import { userDayKey } from "@/lib/tz/format";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { summarize, type DataSummary } from "@/lib/analytics/trends";
import {
getDeviceTypeLadder,
@@ -151,6 +155,17 @@ const GRANULAR_ASLEEP_STAGES: ReadonlySet<SleepStage> = new Set<SleepStage>([
"DEEP",
]);
/**
* Every stage that counts as asleep: the granular partition plus the bare
* `ASLEEP` aggregate. A stage-less row counts as asleep too, but it has no
* stage to list. The sleep list's SQL session cut splices this list, so the
* two decide "does this session hold sleep?" from one definition.
*/
export const ASLEEP_STAGES: readonly SleepStage[] = [
"ASLEEP",
...GRANULAR_ASLEEP_STAGES,
];
/**
* True when any row in the set carries a GRANULAR asleep stage
* (CORE / DEEP / REM). When this holds, the bare `ASLEEP` aggregate (and any
@@ -162,7 +177,10 @@ const GRANULAR_ASLEEP_STAGES: ReadonlySet<SleepStage> = new Set<SleepStage>([
function sawGranularStage(rows: readonly SleepStageRow[]): boolean {
for (const r of rows) {
const stage = r.sleepStage;
if (stage != null && GRANULAR_ASLEEP_STAGES.has(stage)) return true;
// A zero-minute granular row partitions nothing: it must not strip the
// bare aggregate that does carry the night.
if (stage != null && GRANULAR_ASLEEP_STAGES.has(stage) && r.value > 0)
return true;
}
return false;
}
@@ -199,7 +217,7 @@ function asleepMinutesOf(rows: readonly SleepStageRow[]): number {
const stage = seg.stage;
if (stage != null && GRANULAR_ASLEEP_STAGES.has(stage)) {
granular += seg.minutes;
sawGranular = true;
if (seg.minutes > 0) sawGranular = true;
} else if (stage === "ASLEEP" || stage == null) {
// Bare unspecified aggregate (`ASLEEP`) or a stage-less legacy/manual
// row — only used when no granular stage carries the night.
@@ -258,6 +276,70 @@ function inBedEnvelopeMinutes(rows: readonly SleepStageRow[]): number | null {
*/
export const SESSION_GAP_MS = 3 * 60 * 60 * 1000;
/**
* Below this much asleep time a session can be a nap. 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).
*/
export const MAIN_SLEEP_MIN_MINUTES = 180;
/**
* The night window, in local minutes-of-day: a session whose midpoint falls
* from 20:00 to 10:00 is night sleep however short it is. Wide enough for a
* late chronotype (asleep 04:00 to 12:00 has its midpoint at 08:00) and for
* an early one, and clear of the afternoon where naps sit.
*/
export const NIGHT_WINDOW_START_MINUTES = 20 * 60;
export const NIGHT_WINDOW_END_MINUTES = 10 * 60;
/**
* True for a session that is a nap rather than a main sleep: short (under
* `MAIN_SLEEP_MIN_MINUTES` asleep) AND centred in the daytime. A short night
* is still the night (insomnia, a newborn, split sleep); a long daytime sleep
* (after a night shift) is still a main sleep. The one nap rule: the nap
* convention, the sleep score's midpoint and every list read it. Pure.
*/
export function isDaytimeNap(
asleepMinutes: number,
midpointMinutes: number,
): boolean {
if (asleepMinutes >= MAIN_SLEEP_MIN_MINUTES) return false;
const atNight =
midpointMinutes >= NIGHT_WINDOW_START_MINUTES ||
midpointMinutes < NIGHT_WINDOW_END_MINUTES;
return !atNight;
}
/**
* The centre of a session's asleep span as local minutes-of-day: its wake
* instant minus half its asleep minutes, read on the sleeper's wall clock.
*/
export function asleepMidpointMinutes(
end: Date,
asleepMinutes: number,
tz: string,
): number {
const mid = new Date(end.getTime() - (asleepMinutes / 2) * 60_000);
if (tz === "UTC") return mid.getUTCHours() * 60 + mid.getUTCMinutes();
const { hour, minute } = wallClockInTz(mid, tz);
return hour * 60 + minute;
}
/**
* The wake day a session is filed under: the local day its last row ends on,
* over every writer in the session. One rule for every reader, the SQL page
* cut of the sleep list included, which sees every writer and cannot know the
* winning one. Filing by the winner alone named a different day whenever a
* losing writer's in-bed span ran past midnight after the winner stopped, and
* the list and the night view then disagreed about which day the night was.
*/
function wakeDayOf(rows: readonly SleepStageRow[], tz: string): string {
let end = rows[0].measuredAt;
for (const r of rows)
if (r.measuredAt.getTime() > end.getTime()) end = r.measuredAt;
return userDayKey(end, tz);
}
export interface SleepStageRow {
value: number;
measuredAt: Date;
@@ -271,12 +353,12 @@ export interface SleepStageRow {
*/
source?: MeasurementSource | null;
/**
* Device-type tag of the stage row (`Measurement.deviceType`, v1.4.25
* W8c). Distinguishes WRITERS behind one source: several HealthKit apps
* Device-type tag of the stage row (`Measurement.deviceType`, v1.4.25).
* Distinguishes WRITERS behind one source: several HealthKit apps
* write sleep under `source = APPLE_HEALTH` (watch granular stages, phone
* coarse in-bed, vendor-app re-exports), and collapsing on source alone
* blends them. Optional — callers that don't select the column (and every
* row stored before W8c) collapse per source exactly as before.
* row stored before v1.4.25) collapse per source exactly as before.
*/
deviceType?: string | null;
}
@@ -309,9 +391,9 @@ export interface SleepNight {
stages: Partial<Record<SleepStage, number>>;
/**
* Non-null when two writer buckets reported clearly different asleep
* totals for the night's MAIN session — the same-wake-day session with
* totals for the night's MAIN session — the same-wake-day main sleep with
* the most asleep minutes, normally the overnight block (see
* `sessionSourceDiscrepancy`). Sessions never cross-compare: a nap's
* `pickMainNightAndNaps` and `sessionSourceDiscrepancy`). Sessions never cross-compare: a nap's
* writers are not measured against the overnight's. Observational only —
* the served `asleepMinutes` stays the winning writer's total; the
* annotation lets the UI mark the number with a discreet
@@ -319,12 +401,13 @@ export interface SleepNight {
*/
sourceDiscrepancy: SleepSourceDiscrepancy | null;
/**
* The night's MAIN session alone: the same-wake-day session with the most
* asleep minutes (the one `sourceDiscrepancy` reads), its wake instant and
* its own asleep minutes. A nap on the same wake day is in the night's
* totals but not here, so a reader that places the night on the clock (the
* sleep midpoint) is not dragged into the afternoon by it. Null when no
* session carries asleep minutes.
* The night's MAIN session alone, chosen by the nap convention
* (`pickMainNightAndNaps`: the main sleep with the most asleep minutes, the
* one `sourceDiscrepancy` reads), its wake instant and its own asleep
* minutes. A nap on the same wake day is in the night's totals but not
* here, so a reader that places the night on the clock (the sleep midpoint)
* is not dragged into the afternoon by it. Null when no session carries
* asleep minutes, and on a day whose only sleep is daytime naps.
*/
mainSession: { end: Date; asleepMinutes: number } | null;
}
@@ -643,13 +726,9 @@ export function reconstructSleepNights(
const canonical = pickSessionWriter(session, sleepLadder, deviceLadder);
const kept = session.filter((r) => writerKeyOf(r) === canonical);
const pool = kept.length > 0 ? kept : session;
// The wake day is the LATEST segment END in the session (Apple Health
// attributes a session to the morning you wake up).
const wakeInstant = pool.reduce(
(max, r) => (r.measuredAt.getTime() > max.getTime() ? r.measuredAt : max),
pool[0].measuredAt,
);
const key = userDayKey(wakeInstant, tz);
// The wake day is the session's latest row end (Apple Health attributes
// a session to the morning you wake up), over every writer.
const key = wakeDayOf(session, tz);
const list = byNight.get(key) ?? [];
list.push({ pool, all: session });
byNight.set(key, list);
@@ -671,9 +750,9 @@ export function reconstructSleepNights(
// edits.
let latest = nightSessions[0].pool[0].measuredAt;
const stages: Partial<Record<SleepStage, number>> = {};
// MAIN session of the night = most asleep minutes (same per-session
// asleep total the headline sums; tie → later wake instant, matching
// `pickMainNightAndNaps`). Its FULL raw row set (every writer,
// MAIN session of the night = the main sleep (not a daytime nap) with
// the most asleep minutes (same per-session asleep total the headline
// sums; tie → later wake instant), exactly as `pickMainNightAndNaps`. Its FULL raw row set (every writer,
// pre-collapse) feeds the night's source-discrepancy annotation —
// per session, so a nap's writers never cross-compare against the
// overnight block's.
@@ -731,6 +810,10 @@ export function reconstructSleepNights(
}
if (
sessionAsleep > 0 &&
!isDaytimeNap(
sessionAsleep,
asleepMidpointMinutes(new Date(sessionEnd), sessionAsleep, tz),
) &&
(sessionAsleep > mainSessionAsleep ||
(sessionAsleep === mainSessionAsleep && sessionEnd > mainSessionEnd))
) {
@@ -844,6 +927,12 @@ export interface SleepSession {
* "sources disagree" hint.
*/
sourceDiscrepancy: SleepSourceDiscrepancy | null;
/**
* `true` when the session is a daytime nap rather than a main sleep
* (`isDaytimeNap`). Decided here, once, so the nap convention, the sleep
* score and every list read the same answer.
*/
daytimeNap: boolean;
}
/** Resolve a stage row to its absolute span (start = end − duration). */
@@ -1079,7 +1168,7 @@ export function reconstructSleepSessions(
const asleep = asleepMinutesOf(pool);
const canonicalSource = sourceOfWriterKey(canonical);
sessions.push({
night: userDayKey(latest, tz),
night: wakeDayOf(session, tz),
source:
canonicalSource === NO_SOURCE
? null
@@ -1101,21 +1190,28 @@ export function reconstructSleepSessions(
// losing bucket's disagreement is visible; the served totals above
// stay winner-only.
sourceDiscrepancy: sessionSourceDiscrepancy(session),
daytimeNap: isDaytimeNap(
asleep,
asleepMidpointMinutes(latest, asleep, tz),
),
});
}
return sessions.sort((a, b) => a.start.getTime() - b.start.getTime());
}
/**
* Apply the NAP convention to a wake-day's sessions: the MAIN night is the
* session with the most asleep minutes (normally the overnight block); every
* other session on the same wake day is a nap, surfaced separately and never
* folded into the main night's headline.
* Apply the NAP convention to a wake-day's sessions. The MAIN night is the
* main-sleep session (see `isDaytimeNap`) with the most asleep minutes,
* normally the overnight block; every other scorable session on the same wake
* day is a nap, surfaced separately and never folded into the main night's
* headline. A day whose only sleep is daytime naps has no main night: `main`
* is null and the naps are all there is.
*
* Returns the main session plus the naps (sorted by start). When the input
* holds sessions across multiple wake days, only the sessions matching the
* main session's wake day are considered naps — call once per wake day, or
* pass a single wake day's sessions.
* main session's wake day (or, without one, the latest session's) are
* considered naps — call once per wake day, or pass a single wake day's
* sessions.
*/
export function pickMainNightAndNaps(sessions: readonly SleepSession[]): {
main: SleepSession | null;
@@ -1123,14 +1219,22 @@ export function pickMainNightAndNaps(sessions: readonly SleepSession[]): {
} {
const scorable = sessions.filter((s) => s.asleepMinutes > 0);
if (scorable.length === 0) return { main: null, naps: [] };
// Main = most asleep minutes; tie-break on the later end (overnight wins).
const main = [...scorable].sort((a, b) => {
if (b.asleepMinutes !== a.asleepMinutes)
return b.asleepMinutes - a.asleepMinutes;
return b.end.getTime() - a.end.getTime();
})[0];
// Main = most asleep minutes among the main sleeps; tie-break on the later
// end (overnight wins).
const main =
scorable
.filter((s) => !s.daytimeNap)
.sort((a, b) => {
if (b.asleepMinutes !== a.asleepMinutes)
return b.asleepMinutes - a.asleepMinutes;
return b.end.getTime() - a.end.getTime();
})[0] ?? null;
const day =
main?.night ??
scorable.reduce((a, b) => (b.end.getTime() > a.end.getTime() ? b : a))
.night;
const naps = scorable
.filter((s) => s !== main && s.night === main.night)
.filter((s) => s !== main && s.night === day)
.sort((a, b) => a.start.getTime() - b.start.getTime());
return { main, naps };
}
@@ -1139,8 +1243,11 @@ export function pickMainNightAndNaps(sessions: readonly SleepSession[]): {
export interface SleepDaySplit {
/** Wake-day key (YYYY-MM-DD) in the user's timezone. */
night: string;
/** The overnight block — the session with the most asleep minutes. */
main: SleepSession;
/**
* The main sleep (see `pickMainNightAndNaps`), or null on a day whose only
* sleep is daytime naps.
*/
main: SleepSession | null;
/** Every other scorable session on the same wake day, sorted by start. */
naps: SleepSession[];
}
@@ -1154,8 +1261,8 @@ export interface SleepDaySplit {
* than growing its own idea of a night. There is exactly one nap classifier in
* this codebase and it is `pickMainNightAndNaps`; this is its multi-day form.
*
* Days whose sessions carry no asleep minutes are dropped (no main night to
* report). The result is sorted ascending by wake day.
* Days whose sessions carry no asleep minutes are dropped; a day of naps
* alone is kept with `main` null. The result is sorted ascending by wake day.
*/
export function separateNightsAndNaps(
sessions: readonly SleepSession[],
@@ -1169,7 +1276,7 @@ export function separateNightsAndNaps(
const days: SleepDaySplit[] = [];
for (const [night, daySessions] of byDay) {
const { main, naps } = pickMainNightAndNaps(daySessions);
if (!main) continue;
if (!main && naps.length === 0) continue;
days.push({ night, main, naps });
}
return days.sort((a, b) => a.night.localeCompare(b.night));
@@ -73,6 +73,8 @@ export function buildSleepStageComposition(
const perNight: SleepStageCompositionNight[] = [];
for (const { night, main, naps } of days) {
// A day of naps alone has no night to compose.
if (!main) continue;
const nightStages: Record<string, number> = {};
for (const [stage, mins] of Object.entries(main.stages)) {
if (mins == null) continue;