fix(sleep): decide nap versus main sleep in one place

The sleep score treated a short daytime session as a nap while the
nap convention always took the longest session as the night, so a day
with only an afternoon nap showed that nap as the night in the list,
the night view and the dashboard, and as no night in the score.

The rule and its constants now live in sleep-night.ts. Each session
carries `daytimeNap`, the main night is the longest main sleep, and a
day of naps alone has no main night. reconstructSleepNights picks its
main session by the same rule, and the score reads that session.

Sessions are filed under the day their last row ends on over every
writer, the rule the SQL page cut of the sleep list can also apply.

The sleep pillar's regularity floor now counts the midpoints it uses,
not the nights.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 01:26:16 +02:00
1 parent acdea22e2b
commit 435099dc4f
8 files changed
+257 -122

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,65 @@ 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("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,
+145 -41
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
@@ -258,6 +273,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;
@@ -309,9 +388,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 +398,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 +723,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 +747,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 +807,10 @@ export function reconstructSleepNights(
}
if (
sessionAsleep > 0 &&
!isDaytimeNap(
sessionAsleep,
asleepMidpointMinutes(new Date(sessionEnd), sessionAsleep, tz),
) &&
(sessionAsleep > mainSessionAsleep ||
(sessionAsleep === mainSessionAsleep && sessionEnd > mainSessionEnd))
) {
@@ -844,6 +924,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 +1165,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 +1187,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 +1216,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 +1240,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 +1258,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 +1273,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;
@@ -113,10 +113,12 @@ function seedSymptomDrivesSleep() {
functionalImpact: burden,
symptomLinks: [],
});
// A night of five to eight hours, waking at 07:00 UTC: a main sleep, not
// a midday nap, so the nap rule leaves it the night.
measurements.push({
type: "SLEEP_DURATION",
value: 8 - burden,
measuredAt: new Date(BASE + (i + 1) * 86_400_000),
value: (8 - burden) * 60,
measuredAt: new Date(BASE + (i + 1) * 86_400_000 - 5 * 3_600_000),
});
}
measurementFindMany.mockResolvedValue(measurements);
@@ -24,7 +24,6 @@ import { mapSleep, type OuraSleep } from "@/lib/oura/client";
import {
computeSleepScore,
reconstructNights,
isDaytimeNap,
rhythmGap,
scoreConsistency,
} from "../sleep-score";
@@ -174,18 +173,6 @@ describe("Sleep Score rhythm on an Oura month", () => {
expect(sub("timing").gap).toBeUndefined();
});
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("scores the consistency of a steady sleeper, not 0", async () => {
const result = await computeSleepScore("u1", PROFILE, { now: NOW });
expect(result.status).toBe("ok");
+9 -65
View File
@@ -38,11 +38,11 @@ import type {
SleepStage,
} from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { shiftDateKey } from "@/lib/tz/format";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
import {
asleepMidpointMinutes,
reconstructSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
@@ -434,34 +434,16 @@ export function reconstructNights(
: n.asleepMinutes > 0 && awakeMinutes > 0
? n.asleepMinutes + awakeMinutes
: null;
// Midpoint = centre of the MAIN session's asleep span: its wake instant
// minus half its own asleep minutes. Not the whole night's: a nap on the
// same wake day moves the night's latest instant to the end of the nap and
// 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, not a daytime nap. 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. A short session at night is still
// the night (insomnia, a newborn, split sleep), so it keeps its place.
// Midpoint = centre of the MAIN session's asleep span. Not the whole
// night's: a nap on the same wake day would drag it into the afternoon.
// The main session is the nap convention's (`pickMainNightAndNaps`), so
// a day whose only sleep is a daytime nap has none: it still counts its
// minutes, it just has no place on the clock, exactly as the night view
// and the list show it without a main night.
const main = n.mainSession;
const mainMidpoint = main
? minutesOfDay(
new Date(main.end.getTime() - (main.asleepMinutes / 2) * 60_000),
tz,
)
const midpoint = main
? asleepMidpointMinutes(main.end, main.asleepMinutes, tz)
: null;
const midpoint =
main &&
mainMidpoint != null &&
!isDaytimeNap(main.asleepMinutes, mainMidpoint)
? mainMidpoint
: null;
return {
night: n.night,
asleepMinutes: n.asleepMinutes,
@@ -475,44 +457,6 @@ export function reconstructNights(
});
}
/**
* 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).
*/
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.
*/
const NIGHT_WINDOW_START = 20 * 60;
const NIGHT_WINDOW_END = 10 * 60;
/**
* True for a session that is a nap rather than a night: short (under
* `MAIN_SLEEP_MIN_MINUTES` asleep) AND centred in the daytime. A short night
* is still the night; a long daytime sleep (after a night shift) is still a
* main sleep. Pure.
*/
export function isDaytimeNap(
asleepMinutes: number,
midpointMinutes: number,
): boolean {
if (asleepMinutes >= MAIN_SLEEP_MIN_MINUTES) return false;
const atNight =
midpointMinutes >= NIGHT_WINDOW_START || midpointMinutes < NIGHT_WINDOW_END;
return !atNight;
}
function minutesOfDay(d: Date, tz: string): number {
if (tz === "UTC") return d.getUTCHours() * 60 + d.getUTCMinutes();
const { hour, minute } = wallClockInTz(d, tz);
return hour * 60 + minute;
}
// ── compute ─────────────────────────────────────────────────────────────
export interface SleepScoreOpts {