+ toggleNote(entry.id)}
+ />
)}
@@ -998,6 +994,47 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
);
}
+/**
+ * v1.16.8 — expandable free-text note. Collapsed: a two-line clamped
+ * preview. Expanded: the COMPLETE text, line breaks preserved, rendered as
+ * plain text children. The whole block is one toggle button so the full
+ * note is readable in place — previously the only full-text surface was
+ * the edit sheet (plus a hover tooltip on desktop, unreachable on touch).
+ */
+function MoodNoteText({
+ note,
+ expanded,
+ onToggle,
+}: {
+ note: string;
+ expanded: boolean;
+ onToggle: () => void;
+}) {
+ const { t } = useTranslations();
+ return (
+
+ );
+}
+
/**
* v1.15.13 — render a `MoodEntry.source` value with its localized label.
* MANUAL is shown via the source filter only (a manual row carries no
diff --git a/src/lib/analytics/__tests__/sleep-night.test.ts b/src/lib/analytics/__tests__/sleep-night.test.ts
index af5875de2..203128283 100644
--- a/src/lib/analytics/__tests__/sleep-night.test.ts
+++ b/src/lib/analytics/__tests__/sleep-night.test.ts
@@ -669,3 +669,156 @@ describe("reconstructSleepSessions total-safety (A5)", () => {
expect(() => reconstructSleepSessions(rows, "UTC")).not.toThrow();
});
});
+
+/** Build a stage row tagged with a source AND a writer device-type. */
+function writerRow(
+ iso: string,
+ stage: SleepStage | null,
+ minutes: number,
+ source: MeasurementSource,
+ deviceType: string | null,
+): SleepStageRow {
+ return {
+ measuredAt: new Date(iso),
+ sleepStage: stage,
+ value: minutes,
+ source,
+ deviceType,
+ };
+}
+
+describe("per-night writer richness pick", () => {
+ it("a coarse awake-heavy source never masks the source that knows the stages", () => {
+ // The reported night: one source contributes a huge AWAKE block plus a
+ // sliver of bare ASLEEP (a phone/in-bed detection mis-scoring the night),
+ // the other carries the full hypnogram. The stage-bearing source must
+ // win the night — the headline is NOT 400 awake minutes with no phases.
+ const rows: SleepStageRow[] = [
+ // Coarse export: 22:50 → 06:35 local, awake-dominant.
+ srcRow("2026-06-04T01:00:00.000Z", "IN_BED", 250, "APPLE_HEALTH"),
+ srcRow("2026-06-04T04:30:00.000Z", "AWAKE", 400, "APPLE_HEALTH"),
+ srcRow("2026-06-04T04:35:00.000Z", "ASLEEP", 30, "APPLE_HEALTH"),
+ // Detailed stages for the SAME night.
+ srcRow("2026-06-04T01:30:00.000Z", "CORE", 240, "WHOOP"),
+ srcRow("2026-06-04T03:00:00.000Z", "DEEP", 80, "WHOOP"),
+ srcRow("2026-06-04T04:30:00.000Z", "REM", 90, "WHOOP"),
+ srcRow("2026-06-04T04:30:00.000Z", "AWAKE", 35, "WHOOP"),
+ ];
+ const nights = reconstructSleepNights(rows, "Europe/Berlin");
+ expect(nights).toHaveLength(1);
+ expect(nights[0].stages.CORE).toBe(240);
+ expect(nights[0].stages.DEEP).toBe(80);
+ expect(nights[0].stages.REM).toBe(90);
+ // The winner's own awake minutes survive; the coarse 400 must not.
+ expect(nights[0].awakeMinutes).toBe(35);
+ expect(nights[0].asleepMinutes).toBe(410);
+
+ const sessions = reconstructSleepSessions(rows, "Europe/Berlin");
+ expect(sessions).toHaveLength(1);
+ expect(sessions[0].source).toBe("WHOOP");
+ expect(sessions[0].stages.AWAKE).toBe(35);
+ });
+
+ it("separates writer apps behind ONE source: phone in-bed must not blend into watch stages", () => {
+ // Several HealthKit apps write under source = APPLE_HEALTH. The watch
+ // carries the granular partition; the phone's bedtime detection writes a
+ // parallel IN_BED + a long AWAKE block. Collapsing per source would blend
+ // both writers and inflate the night's awake total — the device-type
+ // refinement must keep only the stage-bearing writer.
+ const rows: SleepStageRow[] = [
+ writerRow("2026-06-04T00:30:00.000Z", "CORE", 120, "APPLE_HEALTH", "watch"),
+ writerRow("2026-06-04T02:30:00.000Z", "DEEP", 60, "APPLE_HEALTH", "watch"),
+ writerRow("2026-06-04T04:00:00.000Z", "REM", 80, "APPLE_HEALTH", "watch"),
+ writerRow("2026-06-04T04:30:00.000Z", "AWAKE", 15, "APPLE_HEALTH", "watch"),
+ writerRow("2026-06-04T04:30:00.000Z", "IN_BED", 460, "APPLE_HEALTH", "phone"),
+ writerRow("2026-06-04T00:35:00.000Z", "ASLEEP", 200, "APPLE_HEALTH", "phone"),
+ writerRow("2026-06-04T03:00:00.000Z", "AWAKE", 385, "APPLE_HEALTH", "phone"),
+ ];
+ const nights = reconstructSleepNights(rows, "Europe/Berlin");
+ expect(nights).toHaveLength(1);
+ expect(nights[0].stages.CORE).toBe(120);
+ expect(nights[0].stages.DEEP).toBe(60);
+ expect(nights[0].stages.REM).toBe(80);
+ // The watch writer's awake sliver, not the phone's 385-minute block.
+ expect(nights[0].awakeMinutes).toBe(15);
+ // The phone's bare ASLEEP must not blend into the asleep total either.
+ expect(nights[0].asleepMinutes).toBe(260);
+ });
+
+ it("rows without a device-type collapse per source exactly as before", () => {
+ // Legacy rows (pre device-type column) and single-writer sources: one
+ // bucket per source — granular + in-bed from the same source stay one
+ // night with both facets.
+ const rows: SleepStageRow[] = [
+ srcRow("2026-06-04T00:30:00.000Z", "CORE", 120, "APPLE_HEALTH"),
+ srcRow("2026-06-04T04:30:00.000Z", "AWAKE", 15, "APPLE_HEALTH"),
+ srcRow("2026-06-04T04:30:00.000Z", "IN_BED", 460, "APPLE_HEALTH"),
+ ];
+ const nights = reconstructSleepNights(rows, "Europe/Berlin");
+ expect(nights).toHaveLength(1);
+ expect(nights[0].stages.CORE).toBe(120);
+ expect(nights[0].inBedMinutes).toBe(460);
+ expect(nights[0].awakeMinutes).toBe(15);
+ });
+
+ it("the RICHEST stage set wins: three granular stages beat one, regardless of ladder", () => {
+ // WHOOP sits first on the default ladder but only knows CORE for this
+ // night; the watch carries the full three-stage hypnogram. Per-night
+ // richness must pick the watch.
+ const rows: SleepStageRow[] = [
+ srcRow("2026-06-04T04:30:00.000Z", "CORE", 300, "WHOOP"),
+ writerRow("2026-06-04T00:30:00.000Z", "CORE", 120, "APPLE_HEALTH", "watch"),
+ writerRow("2026-06-04T02:30:00.000Z", "DEEP", 60, "APPLE_HEALTH", "watch"),
+ writerRow("2026-06-04T04:00:00.000Z", "REM", 80, "APPLE_HEALTH", "watch"),
+ ];
+ const nights = reconstructSleepNights(rows, "Europe/Berlin");
+ expect(nights).toHaveLength(1);
+ expect(nights[0].stages.REM).toBe(80);
+ expect(nights[0].stages.DEEP).toBe(60);
+ expect(nights[0].stages.CORE).toBe(120);
+ expect(nights[0].asleepMinutes).toBe(260);
+ });
+
+ it("equally rich writers fall back to the ladder (WHOOP beats Apple Health)", () => {
+ const rows: SleepStageRow[] = [
+ srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "WHOOP"),
+ srcRow("2026-06-04T03:00:00.000Z", "DEEP", 80, "WHOOP"),
+ srcRow("2026-06-04T04:30:00.000Z", "REM", 90, "WHOOP"),
+ writerRow("2026-06-04T00:30:00.000Z", "CORE", 120, "APPLE_HEALTH", "watch"),
+ writerRow("2026-06-04T02:30:00.000Z", "DEEP", 60, "APPLE_HEALTH", "watch"),
+ writerRow("2026-06-04T04:00:00.000Z", "REM", 80, "APPLE_HEALTH", "watch"),
+ ];
+ const sessions = reconstructSleepSessions(rows, "Europe/Berlin");
+ expect(sessions).toHaveLength(1);
+ expect(sessions[0].source).toBe("WHOOP");
+ expect(sessions[0].asleepMinutes).toBe(410);
+ });
+
+ it("a coarse-only night heals on the next read once the stage rows land", () => {
+ // The reconstruction is read-time: a night first assembled from coarse
+ // rows (detailed sync lagging) must flip to the stage partition as soon
+ // as the detailed rows exist — no manual repair, no cache to clear.
+ const coarseOnly: SleepStageRow[] = [
+ srcRow("2026-06-04T01:00:00.000Z", "IN_BED", 250, "APPLE_HEALTH"),
+ srcRow("2026-06-04T04:30:00.000Z", "AWAKE", 400, "APPLE_HEALTH"),
+ srcRow("2026-06-04T04:35:00.000Z", "ASLEEP", 30, "APPLE_HEALTH"),
+ ];
+ const before = reconstructSleepNights(coarseOnly, "Europe/Berlin");
+ expect(before[0].stages.REM).toBeUndefined();
+ expect(before[0].awakeMinutes).toBe(400);
+
+ const afterRows: SleepStageRow[] = [
+ ...coarseOnly,
+ srcRow("2026-06-04T01:30:00.000Z", "CORE", 240, "WHOOP"),
+ srcRow("2026-06-04T03:00:00.000Z", "DEEP", 80, "WHOOP"),
+ srcRow("2026-06-04T04:30:00.000Z", "REM", 90, "WHOOP"),
+ ];
+ const after = reconstructSleepNights(afterRows, "Europe/Berlin");
+ expect(after).toHaveLength(1);
+ expect(after[0].stages.REM).toBe(90);
+ expect(after[0].stages.DEEP).toBe(80);
+ expect(after[0].stages.CORE).toBe(240);
+ expect(after[0].awakeMinutes).toBeNull();
+ expect(after[0].asleepMinutes).toBe(410);
+ });
+});
diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts
index 15af56f71..08c652648 100644
--- a/src/lib/analytics/sleep-night.ts
+++ b/src/lib/analytics/sleep-night.ts
@@ -62,18 +62,29 @@
* and every other session on the same wake day is a NAP, surfaced separately
* and never folded into the main night's headline.
*
- * Source de-dup
+ * 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 source using the user's `sleep` source-priority ladder
- * (`DEFAULT_SOURCE_PRIORITY.sleep` = WHOOP > APPLE_HEALTH > WITHINGS): pick the
- * highest-ranked source present in the session, falling back to the source
- * with the most asleep minutes (then source name) when none of the session's
- * sources sits on the ladder. Only that source's segments are summed — sources
- * are never blended within a night. This mirrors the per-(type, day) collapse
- * the rest of the v1.11.x numeric surfaces apply.
+ * 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.
*
* Unit
* ----
@@ -184,6 +195,15 @@ export interface SleepStageRow {
* 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. */
@@ -206,63 +226,111 @@ export interface SleepNight {
const NO_SOURCE = "__none__";
/**
- * Pick the one canonical source for a session. STAGE GRANULARITY wins first:
- * a source carrying at least one granular stage (CORE / DEEP / REM) always
- * beats a coarse ASLEEP/AWAKE-only source, so a wearable's full hypnogram is
- * never masked by a parallel coarse export of the same night (e.g. Apple
- * Health's 6–9 AWAKE/ASLEEP blocks alongside WHOOP's per-stage rows). Among
- * equally granular (or equally coarse) sources the user's `sleep`
- * source-priority ladder decides (lowest ladder index = highest priority);
- * sources absent from the ladder fall back to "most asleep minutes", then a
- * stable source-name tiebreak. Single-source (or source-less) sessions
- * resolve to that one bucket with no work.
+ * Separator between the source and device-type parts of a writer key.
+ * ` ` cannot appear in a `MeasurementSource` enum literal or a
+ * device-type tag, so the split-back is unambiguous.
*/
-function pickSessionSource(
+const WRITER_KEY_SEP = " ";
+
+/**
+ * Writer-bucket key of a stage row: the source, refined by the device-type
+ * tag when one is stored. Two HealthKit writer apps behind the same
+ * `APPLE_HEALTH` source (watch vs phone) land in DIFFERENT buckets so the
+ * coarse writer's awake/in-bed blocks never blend into the granular
+ * writer's night. Rows without a device-type keep the bare source as their
+ * key — single-writer sources and legacy rows collapse exactly as before.
+ */
+function writerKeyOf(r: SleepStageRow): string {
+ const src = r.source ?? NO_SOURCE;
+ const dev = r.deviceType;
+ return dev ? `${src}${WRITER_KEY_SEP}${dev}` : src;
+}
+
+/** The source part of a writer key (for ladder ranking + display). */
+function sourceOfWriterKey(key: string): string {
+ const i = key.indexOf(WRITER_KEY_SEP);
+ return i === -1 ? key : key.slice(0, i);
+}
+
+/**
+ * Count of DISTINCT granular stages (CORE / DEEP / REM) in a row set — the
+ * stage-richness score of a writer's night. 3 = full hypnogram, 0 = coarse
+ * (bare ASLEEP / AWAKE / IN_BED only).
+ */
+function granularStageCount(rows: readonly SleepStageRow[]): number {
+ const seen = new Set();
+ for (const r of rows) {
+ const stage = r.sleepStage;
+ if (stage != null && GRANULAR_ASLEEP_STAGES.has(stage)) seen.add(stage);
+ }
+ return seen.size;
+}
+
+/**
+ * Pick the one canonical WRITER for a session. STAGE RICHNESS wins first:
+ * the writer carrying the most distinct granular stages (CORE / DEEP / REM)
+ * always beats a coarser one, so neither a parallel coarse export of the
+ * same night (e.g. Apple Health's AWAKE/ASLEEP blocks alongside WHOOP's
+ * per-stage rows) nor a second writer app behind the same source (the
+ * phone's in-bed detection next to the watch's stages) can mask the
+ * fullest hypnogram available for the night. Among equally rich writers
+ * the user's `sleep` source-priority ladder decides (lowest ladder index =
+ * highest priority); writers whose source is absent from the ladder fall
+ * back to "most asleep minutes", then a stable key tiebreak. Single-writer
+ * (or source-less) sessions resolve to that one bucket with no work.
+ */
+function pickSessionWriter(
rows: SleepStageRow[],
sleepLadder: readonly MeasurementSource[],
): string {
- // Group rows by source, then sum each source's asleep minutes with the
- // granular-over-bare rule so a source that writes BOTH a bare ASLEEP
+ // Group rows by writer, then sum each writer's asleep minutes with the
+ // granular-over-bare rule so a writer that stores BOTH a bare ASLEEP
// aggregate and the granular breakdown is not over-weighted in the tiebreak.
- const rowsBySource = new Map();
+ const rowsByWriter = new Map();
for (const r of rows) {
- const src = r.source ?? NO_SOURCE;
- const list = rowsBySource.get(src) ?? [];
+ const key = writerKeyOf(r);
+ const list = rowsByWriter.get(key) ?? [];
list.push(r);
- rowsBySource.set(src, list);
+ rowsByWriter.set(key, list);
}
- const asleepBySource = new Map();
- for (const [src, srcRows] of rowsBySource) {
- const minutes = asleepMinutesOf(srcRows);
- if (minutes > 0) asleepBySource.set(src, minutes);
+ const asleepByWriter = new Map();
+ for (const [key, writerRows] of rowsByWriter) {
+ const minutes = asleepMinutesOf(writerRows);
+ if (minutes > 0) asleepByWriter.set(key, minutes);
}
// No asleep minutes anywhere (IN_BED / AWAKE only) — fall back to the set
- // of all present sources so the session still resolves to a single bucket.
- let sources =
- asleepBySource.size > 0
- ? [...asleepBySource.keys()]
- : [...new Set(rows.map((r) => r.source ?? NO_SOURCE))];
- // Granularity gate: a source with a CORE/DEEP/REM partition always beats a
- // coarse ASLEEP-only source — otherwise a parallel coarse export of the
- // same night masks the wearable's hypnogram. Coarse sources stay only as
- // the fallback when NO granular source covers the session.
- const granularSources = sources.filter((src) =>
- sawGranularStage(rowsBySource.get(src) ?? []),
- );
- if (granularSources.length > 0) sources = granularSources;
- if (sources.length <= 1) return sources[0] ?? NO_SOURCE;
+ // of all present writers so the session still resolves to a single bucket.
+ let writers =
+ asleepByWriter.size > 0
+ ? [...asleepByWriter.keys()]
+ : [...rowsByWriter.keys()];
+ // Richness gate: keep only the writers tied for the MOST distinct granular
+ // stages. A 3-stage hypnogram beats a 1-stage partial beats a coarse
+ // ASLEEP-only export; coarse writers survive only when NO writer carries a
+ // granular stage for the session.
+ const richnessOf = new Map();
+ let maxRichness = 0;
+ for (const key of writers) {
+ const score = granularStageCount(rowsByWriter.get(key) ?? []);
+ richnessOf.set(key, score);
+ if (score > maxRichness) maxRichness = score;
+ }
+ if (maxRichness > 0) {
+ writers = writers.filter((key) => richnessOf.get(key) === maxRichness);
+ }
+ if (writers.length <= 1) return writers[0] ?? NO_SOURCE;
- const rankOf = (src: string): number => {
- const i = sleepLadder.indexOf(src as MeasurementSource);
+ const rankOf = (key: string): number => {
+ const i = sleepLadder.indexOf(sourceOfWriterKey(key) as MeasurementSource);
return i === -1 ? Number.MAX_SAFE_INTEGER : i;
};
- return sources.sort((a, b) => {
+ return writers.sort((a, b) => {
const ra = rankOf(a);
const rb = rankOf(b);
if (ra !== rb) return ra - rb;
- // Neither (or both) on the ladder — most asleep minutes wins, then name.
- const ma = asleepBySource.get(a) ?? 0;
- const mb = asleepBySource.get(b) ?? 0;
+ // Neither (or both) on the ladder — most asleep minutes wins, then key.
+ const ma = asleepByWriter.get(a) ?? 0;
+ const mb = asleepByWriter.get(b) ?? 0;
if (ma !== mb) return mb - ma;
return a < b ? -1 : 1;
})[0];
@@ -316,15 +384,15 @@ export function reconstructSleepNights(
if (current.length > 0) sessions.push(current);
// Two sessions can land on the same wake day (e.g. a genuine overnight plus
- // a same-day nap); collect each session's canonical-source rows under one
+ // a same-day nap); collect each session's canonical-writer rows under one
// wake-day key. The rows are grouped PER SESSION (not flattened) so the
// granular-over-bare gate and the asleep total are decided per session — a
// bare-only nap keeps its minutes even when the overnight session is
// granular (the v1.11.5 merged-night nap under-count fix).
const byNight = new Map();
for (const session of sessions) {
- const canonical = pickSessionSource(session, sleepLadder);
- const kept = session.filter((r) => (r.source ?? NO_SOURCE) === canonical);
+ const canonical = pickSessionWriter(session, sleepLadder);
+ 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).
@@ -526,8 +594,8 @@ export function reconstructSleepSessions(
const sessions: SleepSession[] = [];
for (const session of rawSessions) {
- const canonical = pickSessionSource(session, sleepLadder);
- const kept = session.filter((r) => (r.source ?? NO_SOURCE) === canonical);
+ const canonical = pickSessionWriter(session, sleepLadder);
+ const kept = session.filter((r) => writerKeyOf(r) === canonical);
const pool = kept.length > 0 ? kept : session;
// v1.11.5 — drop the redundant bare ASLEEP aggregate (and stage-less
// rows) from the hypnogram segments AND the per-stage breakdown when the
@@ -571,10 +639,13 @@ export function reconstructSleepSessions(
}
}
const asleep = asleepMinutesOf(pool);
+ const canonicalSource = sourceOfWriterKey(canonical);
sessions.push({
night: userDayKey(latest, tz),
source:
- canonical === NO_SOURCE ? null : (canonical as MeasurementSource),
+ canonicalSource === NO_SOURCE
+ ? null
+ : (canonicalSource as MeasurementSource),
start: earliest,
end: latest,
asleepMinutes: asleep,
diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts
index 1af2e97f8..db65866f7 100644
--- a/src/lib/analytics/summaries-slice.ts
+++ b/src/lib/analytics/summaries-slice.ts
@@ -316,7 +316,15 @@ async function withSleepNightTotals(
measuredAt: { gte: since },
},
orderBy: { measuredAt: "asc" },
- select: { value: true, measuredAt: true, sleepStage: true, source: true },
+ // `deviceType` feeds the writer-level collapse: two HealthKit apps
+ // behind one source (watch stages vs phone in-bed) must not blend.
+ select: {
+ value: true,
+ measuredAt: true,
+ sleepStage: true,
+ source: true,
+ deviceType: true,
+ },
})) as SleepStageRow[];
if (rows.length === 0) return slice;
diff --git a/src/lib/export.ts b/src/lib/export.ts
index da69e3fd6..26e7d85aa 100644
--- a/src/lib/export.ts
+++ b/src/lib/export.ts
@@ -95,6 +95,8 @@ interface ExportMeasurement {
notes: string | null;
glucoseContext?: string | null;
sleepStage?: SleepStage | null;
+ /** Writer tag for the per-night sleep collapse (watch vs phone). */
+ deviceType?: string | null;
}
/**
@@ -158,6 +160,7 @@ export function formatMeasurementsForExport(
measuredAt: m.measuredAt,
sleepStage: m.sleepStage ?? null,
source: m.source as SleepStageRow["source"],
+ deviceType: m.deviceType ?? null,
}));
const sessions = reconstructSleepSessions(
stageRows,
@@ -281,6 +284,8 @@ export function formatMoodEntriesForExport(
mood: string;
score: number;
tags: string | null;
+ /** Free-text note — exported in full so the texts are readable offline. */
+ note?: string | null;
source: string;
moodLoggedAt: Date;
}>,
@@ -291,6 +296,7 @@ export function formatMoodEntriesForExport(
mood: e.mood,
score: e.score,
tags: e.tags ?? "",
+ note: e.note ?? "",
source: e.source,
loggedAt: formatTimestamp(e.moodLoggedAt, userTz),
}));
diff --git a/src/lib/whoop/sync.ts b/src/lib/whoop/sync.ts
index 2223d247a..dd691f191 100644
--- a/src/lib/whoop/sync.ts
+++ b/src/lib/whoop/sync.ts
@@ -102,12 +102,17 @@ const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000;
/**
* Overlap window for the incremental sync, in ms. WHOOP re-scores recovery
- * and sleep after the fact (the final recovery/sleep score can land hours
- * after the night), so the recovery/sleep overlap is a full 24 h to make sure
- * the re-scored record is re-fetched on the next tick. Workout/cycle settle
- * fast — a smaller overlap suffices and keeps the page count down.
+ * and sleep after the fact, and a night can reach the WHOOP cloud DAYS late
+ * (phone offline, app unopened, score pending). The collection endpoints
+ * filter on the record's own time range — not on when it arrived — so any
+ * record surfacing after the cursor moved past it is missed FOREVER by a
+ * narrow overlap; the night's per-stage rows then never reach the DB and
+ * every sleep surface keeps showing the parallel coarse source for that
+ * night. Seven days re-fetches a handful of records per tick (the upserts
+ * are idempotent) and closes the gap for every realistic lag. Workout/cycle
+ * settle fast — a smaller overlap suffices and keeps the page count down.
*/
-export const WHOOP_RECOVERY_SLEEP_OVERLAP_MS = 24 * 60 * 60 * 1000;
+export const WHOOP_RECOVERY_SLEEP_OVERLAP_MS = 7 * 24 * 60 * 60 * 1000;
export const WHOOP_DEFAULT_OVERLAP_MS = 60 * 60 * 1000; // 1 h
export interface WhoopTokenInfo {