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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-16 09:32:01 +02:00
1 parent b7fdfc3a2e
commit 6c4f27794b
2 files changed
+57 -7

No files matched your search

+25 -5
View File
@@ -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);
});
});
+32 -2
View File
@@ -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;