From 6c4f27794b0a4e1d56f33d48af50ee8c2aa92462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 16 Aug 2026 09:32:01 +0200 Subject: [PATCH] Stop waiting for sleep that is never coming The hero card on the start page told anyone without a sleep source, every morning, that last night's sleep was not in yet. The freshness rule only asked whether last night's sleep had landed, so a record that has never had a night in it, or whose owner has stopped wearing the tracker, read as permanently waiting. Not everyone owns one, and people put them away. Waiting now requires that sleep is actually reaching the record: a night within the last seven days. A flat battery or a few nights without the tracker keeps the note, since tonight's sleep really is still expected. A week of silence retires it. The same rule settles the day itself. It used to park on `provisional` forever for those records, which the daily push line reads too, so the notification carried the same untrue "last night's sleep is still missing" aside. Two existing tests pinned the old behaviour by name ("is provisional when sleep has never been recorded", "stays provisional when sleep never arrives"). They described the defect rather than a contract worth keeping, so they now assert the opposite, and a third covers the boundary between an ordinary gap and a source that has gone quiet. --- src/lib/daily/__tests__/digest.test.ts | 30 +++++++++++++++++++---- src/lib/daily/digest.ts | 34 ++++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts index 68709685d..e6dde7696 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -161,9 +161,27 @@ describe("buildDailyDigest — freshness (provisional/final)", () => { expect(d.sleepPending).toBe(true); }); - it("is provisional when sleep has never been recorded", () => { + it("is final (not pending) when sleep has never been recorded", () => { + // Someone with no tracker never has a night in the record. Waiting for one + // means the card would tell them every single morning that last night's + // sleep "is not in yet", about a delay that never ends. const d = buildDailyDigest(input({ sleepLastSeenDaysAgo: null }), t); - expect(d.sleepPending).toBe(true); + expect(d.phase).toBe("final"); + expect(d.sleepPending).toBe(false); + }); + + it("keeps waiting through an ordinary gap, stops once the source has gone quiet", () => { + // A flat battery or a few nights without the tracker is not a reason to + // stop expecting tonight's sleep. + const shortGap = buildDailyDigest(input({ sleepLastSeenDaysAgo: 7 }), t); + expect(shortGap.phase).toBe("provisional"); + expect(shortGap.sleepPending).toBe(true); + + // A full week with nothing arriving is. Someone who has put the tracker + // away should not keep reading about last night's missing sleep. + const abandoned = buildDailyDigest(input({ sleepLastSeenDaysAgo: 8 }), t); + expect(abandoned.phase).toBe("final"); + expect(abandoned.sleepPending).toBe(false); }); it("is final (not pending) when the sleep module is off", () => { @@ -194,13 +212,15 @@ describe("buildDailyDigest — freshness (provisional/final)", () => { expect(finalised.sleepPending).toBe(false); }); - it("stays provisional when sleep never arrives (no marker, no reading)", () => { + it("settles the day even when sleep never arrives (no marker, no reading)", () => { + // Without this the day parks on `provisional` forever for every record + // that has no sleep source, which also leaks into the push line. const d = buildDailyDigest( input({ sleepLastSeenDaysAgo: null, morningRefreshedToday: false }), t, ); - expect(d.phase).toBe("provisional"); - expect(d.sleepPending).toBe(true); + expect(d.phase).toBe("final"); + expect(d.sleepPending).toBe(false); }); }); diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index b98471e6c..9d14cc933 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -866,12 +866,38 @@ function composeLine( return t("daily.line.allClear"); } +/** + * How stale the freshest night may be before the record stops expecting one. + * + * A gap of a few days is ordinary for someone who does wear a tracker: a flat + * battery, a night without it, a holiday. A full week with nothing arriving + * says the source is not feeding this record at the moment, and "last night's + * sleep is not in yet" stops being a true statement about today. + */ +const SLEEP_EXPECTED_WITHIN_DAYS = 7; + +/** + * Whether this record should be waiting for last night's sleep at all. + * + * Waiting only makes sense when sleep actually reaches the record. Someone who + * owns no tracker has never had a night in it (`null`), and someone who has + * stopped wearing one stops having them; telling either of them every morning + * that last night's sleep "is not in yet" describes a delay that is never + * going to end. Both cases read as no sleep expected, which also settles the + * day as `final` instead of parking it on `provisional` forever. + */ +export function isSleepExpected(lastSeenDaysAgo: number | null): boolean { + if (lastSeenDaysAgo === null) return false; + return lastSeenDaysAgo <= SLEEP_EXPECTED_WITHIN_DAYS; +} + export function buildDailyDigest( input: DailyDigestInput, t: Translate, ): DailyDigest { // §E freshness lifecycle. The day is `final` once ANY of: // - sleep is disabled (nothing to wait for); + // - no sleep is expected in the first place (see `sleepExpected` below); // - the event-driven morning refresh has run for today (authoritative, // fast — flips the instant the sleep-arrival job stamps the marker); // - last night's sleep is already visibly in the record (`daysAgo === 0`) — @@ -880,10 +906,14 @@ export function buildDailyDigest( // Otherwise it stays `provisional`, and `sleepPending` drives the honest // "last night's sleep not yet in" note. const sleepEnabled = moduleEnabled(input.modules, "sleep"); + const sleepExpected = isSleepExpected(input.sleepLastSeenDaysAgo); const lastNightSleepIn = input.sleepLastSeenDaysAgo === 0; const isFinal = - !sleepEnabled || input.morningRefreshedToday || lastNightSleepIn; - const sleepPending = sleepEnabled && !isFinal; + !sleepEnabled || + !sleepExpected || + input.morningRefreshedToday || + lastNightSleepIn; + const sleepPending = sleepEnabled && sleepExpected && !isFinal; const phase: DailyDigest["phase"] = isFinal ? "final" : "provisional"; const topSignal = input.briefing?.signalsOfDay?.[0] ?? null;