mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Per-status texts check AI consent before the unchanged-data refresh can re-stamp them, the comprehensive fingerprint folds in the comparison baseline and generation language so toggling either regenerates once, and profile, threshold and source-priority writes evict the analytics, targets and derived caches they feed. CSV export neutralises spreadsheet formula prefixes on free-text cells while numeric columns stay typed, the derived batch validates its type token against the measurement enum and carries a per-user rate limit, and a dose synced from the phone app counts as the interactive write it is. Background cache refresh failures log a structured event instead of vanishing, the warm queue runs visit-triggered jobs alongside the nightly walk, the formatter memo evicts one entry instead of flushing wholesale with its option objects frozen, the targets cold build bounds its fan-out, and time in bed unions every writer's envelope while stages stay with the richest recorder.
804 lines
34 KiB
TypeScript
804 lines
34 KiB
TypeScript
/**
|
||
* v1.11.4 — per-night sleep aggregation (iOS #1, HIGH).
|
||
*
|
||
* `SLEEP_DURATION` is stored in MINUTES, one row per sleep STAGE per
|
||
* night (IN_BED / AWAKE / ASLEEP / REM / CORE / DEEP) since v1.4.23. The
|
||
* value is NOT cumulative — a single stage row carries only that stage's
|
||
* minutes. Every "last night's sleep" surface therefore has to SUM the
|
||
* stage rows belonging to one night before it shows a headline number;
|
||
* reading the single most-recent row gives one stage, not the night.
|
||
*
|
||
* This helper is the one shared place that turns the raw per-stage rows
|
||
* into per-night totals. The headline "time asleep" is light(CORE) +
|
||
* deep + REM; IN_BED and AWAKE are excluded from the asleep total,
|
||
* matching the AASM convention.
|
||
*
|
||
* Granular-over-bare (v1.11.5)
|
||
* ----------------------------
|
||
* Apple Health writes BOTH an unspecified `ASLEEP` AGGREGATE row AND the
|
||
* granular `CORE` / `DEEP` / `REM` breakdown for the SAME sleep period. The
|
||
* granular rows partition the same time the aggregate covers, so counting
|
||
* bare-ASLEEP together with the granular rows ~doubles the night. We therefore
|
||
* prefer the granular partition: when ANY granular stage (CORE / DEEP / REM)
|
||
* is present for a session we drop the redundant bare `ASLEEP` aggregate (and
|
||
* any stage-less row) EVERYWHERE — the asleep total, the per-stage `stages`
|
||
* breakdown, and the hypnogram `segments` list — so none of those views holds
|
||
* the granular set plus its ~equal bare twin. The bare `ASLEEP` aggregate (or
|
||
* a stage-less legacy / manual row) is the session's only asleep signal when
|
||
* no granular stage exists. WHOOP never emits a bare `ASLEEP` aggregate (its
|
||
* stage map writes CORE / DEEP / REM / AWAKE / IN_BED only — see
|
||
* `whoop/client.ts`), so this gate is Apple-Health-only in practice.
|
||
*
|
||
* Night grouping
|
||
* --------------
|
||
* A "night" is one SLEEP SESSION, not a calendar day. `measuredAt` is the
|
||
* stage segment's END instant (`apple-health-mapping.ts` sets
|
||
* `takenAt = endDate`), and a device writes one row per stage segment, so a
|
||
* normal overnight sleep (asleep before local midnight) spreads its segments
|
||
* across BOTH sides of midnight. Keying each segment by its own calendar day
|
||
* would split one physical night into two partial nights and undercount the
|
||
* "last night" headline.
|
||
*
|
||
* Instead we cluster the segments into sessions by time gap: sort by
|
||
* `measuredAt`, and start a new session whenever the gap from the previous
|
||
* segment exceeds `SESSION_GAP_MS` (3 h). Stage segments inside one sleep
|
||
* session are contiguous (minutes to ~2 h apart), so a > 3 h gap reliably
|
||
* marks the boundary between a daytime nap and the overnight session, or
|
||
* between two genuine sessions in one day — they stay separable. The gap is
|
||
* 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
|
||
* 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.
|
||
*
|
||
* Naps vs the main night
|
||
* ----------------------
|
||
* `reconstructSleepNights` MERGES every session that lands on the same wake
|
||
* 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.
|
||
*
|
||
* Writer de-dup
|
||
* -------------
|
||
* A user paired to more than one sleep source (e.g. WHOOP + Apple Health)
|
||
* gets per-stage rows for the SAME night from each source. Summing them all
|
||
* would ~double the night total. Before summing a session we collapse it to
|
||
* ONE writer. A "writer" is `(source, deviceType)` — finer than `source`
|
||
* because several HealthKit writer apps land behind the SAME
|
||
* `source = APPLE_HEALTH` (the watch's granular stages, the phone's coarse
|
||
* in-bed detection, a wearable vendor app's re-export). Keying the collapse
|
||
* on source alone blends those writers into one bucket: the phone's
|
||
* awake/in-bed blocks inflate the night's awake total and sit on top of the
|
||
* watch's hypnogram. Rows without a `deviceType` collapse per source exactly
|
||
* as before, so legacy rows and single-writer sources are unaffected.
|
||
*
|
||
* The pick is per-night by STAGE RICHNESS first: the writer carrying the
|
||
* most distinct granular stages (CORE / DEEP / REM) wins, so a coarse
|
||
* awake/asleep-only export can never mask a writer that knows the night's
|
||
* phases. Among equally rich writers the user's `sleep` source-priority
|
||
* ladder decides (`DEFAULT_SOURCE_PRIORITY.sleep` = WHOOP > APPLE_HEALTH >
|
||
* WITHINGS), then most asleep minutes, then a stable key tiebreak. Only the
|
||
* winning writer's segments are summed — writers are never blended within a
|
||
* session, mirroring the per-(type, day) collapse the rest of the v1.11.x
|
||
* numeric surfaces apply.
|
||
*
|
||
* One exception: `inBedMinutes` ("Zeit im Bett") derives from the UNION
|
||
* of the IN_BED spans across ALL writers in the session, not from the
|
||
* winner alone. The common pairing is a watch that knows the stages but
|
||
* writes no IN_BED row and a phone whose bedtime detection writes only
|
||
* the long IN_BED window — the watch wins the night on richness, and a
|
||
* winner-only in-bed figure would shrink to nothing exactly when the
|
||
* stage data is best. Overlapping spans from two writers are merged
|
||
* interval-union style so a doubled export never double-counts. The
|
||
* asleep total, the `stages` map, and `awakeMinutes` stay winner-only —
|
||
* blending the phone's coarse AWAKE blocks into the watch's night was
|
||
* the original inflated-awake bug this collapse exists to prevent.
|
||
*
|
||
* Unit
|
||
* ----
|
||
* Totals are returned in MINUTES — the canonical `SLEEP_DURATION` unit
|
||
* (`getUnitForType` → "minutes") — so the existing web consumers that
|
||
* already treat the summary as minutes (e.g. the insights overview's
|
||
* `formatHoursMinutes`) keep working unchanged; they now average per-
|
||
* night totals instead of per-stage rows, which is strictly more
|
||
* correct. Callers that want hours convert at the edge (`/ 60`).
|
||
*/
|
||
import type { MeasurementSource, SleepStage } from "@/generated/prisma/client";
|
||
import { userDayKey } from "@/lib/tz/format";
|
||
import { summarize, type DataSummary } from "@/lib/analytics/trends";
|
||
import {
|
||
getSourceLadder,
|
||
parseSourcePriority,
|
||
} from "@/lib/validations/source-priority";
|
||
|
||
/**
|
||
* The GRANULAR asleep stages (iOS 16+ `asleepCore` / `asleepDeep` /
|
||
* `asleepREM`). A bare `ASLEEP` row is the iOS 15- `asleepUnspecified`
|
||
* AGGREGATE — Apple Health writes BOTH the unspecified aggregate AND the
|
||
* granular breakdown for the SAME period, so keeping bare-ASLEEP alongside the
|
||
* granular rows ~doubles the night. The granular rows partition the same time
|
||
* the aggregate covers, so when any granular stage is present for a session we
|
||
* keep ONLY the granular stages and drop the bare aggregate; the bare row is
|
||
* the asleep signal only when no granular stage exists. WHOOP never writes a
|
||
* bare `ASLEEP` aggregate (see `whoop/client.ts` `SLEEP_STAGE_MAP`).
|
||
*/
|
||
const GRANULAR_ASLEEP_STAGES: ReadonlySet<SleepStage> = new Set<SleepStage>([
|
||
"REM",
|
||
"CORE",
|
||
"DEEP",
|
||
]);
|
||
|
||
/**
|
||
* True when any row in the set carries a GRANULAR asleep stage
|
||
* (CORE / DEEP / REM). When this holds, the bare `ASLEEP` aggregate (and any
|
||
* stage-less row) is the redundant unspecified twin of the granular partition
|
||
* and must be dropped from EVERY downstream view — the asleep total, the
|
||
* per-stage `stages` map, and the hypnogram `segments` list — so none of them
|
||
* double-counts the same period.
|
||
*/
|
||
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;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* A stage row is a redundant bare-asleep aggregate (must be dropped) when the
|
||
* session/night ALSO carries a granular CORE/DEEP/REM partition. Only the bare
|
||
* `ASLEEP` aggregate and stage-less (`null`) rows are redundant — IN_BED /
|
||
* AWAKE and the granular stages themselves are always kept.
|
||
*/
|
||
function isRedundantBareAsleep(
|
||
stage: SleepStage | null,
|
||
sawGranular: boolean,
|
||
): boolean {
|
||
return sawGranular && (stage === "ASLEEP" || stage == null);
|
||
}
|
||
|
||
/**
|
||
* Sum a set of stage rows into time-asleep minutes WITHOUT double-counting a
|
||
* bare `ASLEEP` aggregate against the granular CORE/DEEP/REM rows it overlaps.
|
||
* IN_BED + AWAKE are never asleep; a stage-less (`null`) row is the night's
|
||
* total only when no granular stage is present.
|
||
*/
|
||
function asleepMinutesOf(rows: readonly SleepStageRow[]): number {
|
||
let granular = 0;
|
||
let fallback = 0;
|
||
let sawGranular = false;
|
||
for (const r of rows) {
|
||
const minutes = Number.isFinite(r.value) ? r.value : 0;
|
||
const stage = r.sleepStage;
|
||
if (stage != null && GRANULAR_ASLEEP_STAGES.has(stage)) {
|
||
granular += minutes;
|
||
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.
|
||
fallback += minutes;
|
||
}
|
||
// IN_BED / AWAKE never count toward asleep.
|
||
}
|
||
return sawGranular ? granular : fallback;
|
||
}
|
||
|
||
/**
|
||
* Total minutes covered by the UNION of the IN_BED spans across EVERY
|
||
* writer in a session ("Zeit im Bett"). Each IN_BED row resolves to the
|
||
* absolute span `[measuredAt − value·60_000, measuredAt]`; overlapping
|
||
* spans (the same window exported by two writers) merge before summing
|
||
* so a doubled export never double-counts the night. Returns `null`
|
||
* when no IN_BED row exists anywhere in the session — the caller keeps
|
||
* the "no in-bed signal" semantics. Deliberately NOT winner-scoped: the
|
||
* stage-rich watch usually writes no IN_BED row while the phone writes
|
||
* only the envelope, and the headline must keep both facets.
|
||
*/
|
||
function inBedEnvelopeMinutes(rows: readonly SleepStageRow[]): number | null {
|
||
const intervals: Array<{ start: number; end: number }> = [];
|
||
for (const r of rows) {
|
||
if (r.sleepStage !== "IN_BED") continue;
|
||
const minutes = Number.isFinite(r.value) ? r.value : 0;
|
||
const end = r.measuredAt.getTime();
|
||
intervals.push({ start: end - minutes * 60_000, end });
|
||
}
|
||
if (intervals.length === 0) return null;
|
||
intervals.sort((a, b) => a.start - b.start);
|
||
let total = 0;
|
||
let curStart = intervals[0].start;
|
||
let curEnd = intervals[0].end;
|
||
for (let i = 1; i < intervals.length; i++) {
|
||
const { start, end } = intervals[i];
|
||
if (start <= curEnd) {
|
||
if (end > curEnd) curEnd = end;
|
||
} else {
|
||
total += curEnd - curStart;
|
||
curStart = start;
|
||
curEnd = end;
|
||
}
|
||
}
|
||
total += curEnd - curStart;
|
||
return Math.round(total / 60_000);
|
||
}
|
||
|
||
/**
|
||
* Gap (ms) between consecutive stage segments that starts a new sleep
|
||
* session. Stage segments within one night are contiguous (minutes to a
|
||
* couple of hours apart); a > 3 h gap separates a nap from the overnight
|
||
* block, or two genuine sessions in one day.
|
||
*/
|
||
const SESSION_GAP_MS = 3 * 60 * 60 * 1000;
|
||
|
||
export interface SleepStageRow {
|
||
value: number;
|
||
measuredAt: Date;
|
||
sleepStage: SleepStage | null;
|
||
/**
|
||
* Ingest source of the stage row. Used to collapse a multi-source night
|
||
* (e.g. WHOOP + Apple Health) to one canonical source before summing.
|
||
* Optional so legacy callers and fixtures that don't carry a source still
|
||
* type-check (a missing source is treated as a single anonymous source —
|
||
* the de-dup is a no-op for single-source nights).
|
||
*/
|
||
source?: MeasurementSource | null;
|
||
/**
|
||
* Device-type tag of the stage row (`Measurement.deviceType`, v1.4.25
|
||
* W8c). 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.
|
||
*/
|
||
deviceType?: string | null;
|
||
}
|
||
|
||
/** One reconstructed night: the asleep total + the per-stage breakdown. */
|
||
export interface SleepNight {
|
||
/** Wake-day key (YYYY-MM-DD) in the user's timezone. */
|
||
night: string;
|
||
/** Latest stage instant in the night — the night's representative timestamp. */
|
||
measuredAt: Date;
|
||
/** Time asleep in minutes = CORE + DEEP + REM, or bare ASLEEP when granular stages are absent. */
|
||
asleepMinutes: number;
|
||
/**
|
||
* In-bed minutes when an IN_BED row exists ANYWHERE in the night's
|
||
* sessions, else null. Union envelope across ALL writers — not
|
||
* winner-scoped like `asleepMinutes` / `stages` / `awakeMinutes` —
|
||
* so a stage-rich watch winning the night never erases the phone's
|
||
* in-bed window. See the module doc's writer-de-dup exception.
|
||
*/
|
||
inBedMinutes: number | null;
|
||
/** Awake-in-bed minutes when an AWAKE row exists, else null. */
|
||
awakeMinutes: number | null;
|
||
/** Per-stage minutes for the night (only stages the winning writer reported). */
|
||
stages: Partial<Record<SleepStage, number>>;
|
||
}
|
||
|
||
/** Sentinel for rows that carry no source — collapses to one bucket. */
|
||
const NO_SOURCE = "__none__";
|
||
|
||
/**
|
||
* Separator between the source and device-type parts of a writer key.
|
||
* ` |