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;