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 1/3] 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; From 23278318ef8a457c80ee0b5b50dbe69a04e6d43c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 16 Aug 2026 09:33:42 +0200 Subject: [PATCH 2/3] =?UTF-8?q?chore(release):=20v1.37.24=20=E2=80=94=20th?= =?UTF-8?q?e=20card=20stops=20waiting=20for=20sleep=20that=20never=20comes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27b4af685..0635f477a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.37.24] — 2026-08-16 + +### Fixed + +- The hero card on the start page no longer says last night's sleep is still missing when no sleep reaches the record at all. It used to check only whether last night had arrived, so anyone without a tracker read the note every morning about a delay that was never going to end. The note now needs a night within the last week: a flat battery or a few nights without the device keeps it, a week of silence retires it, and someone who has never recorded sleep never sees it. +- The same rule settles the day itself. Records with no sleep source used to sit on "provisional" indefinitely, which the daily push line reads too, so the notification carried the same untrue aside. + ## [1.37.23] — 2026-08-15 ### Changed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index db1c17547..e9ba1ed55 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.37.23 + version: 1.37.24 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 706483299..c1fac9b41 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.37.23", + "version": "1.37.24", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 76f3a95c1..e5c76e001 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.37.23"; + /* @sw-version-fallback */ "v1.37.24"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` From d252940381d7db4b0ccc9bf0884ff82ddbe4433b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 16 Aug 2026 09:56:12 +0200 Subject: [PATCH 3/3] Give the just-in spec a record that expects sleep The scenario purges every sleep row so last night can be the arrival that flips the day. That left an account with no sleep source at all, which now reads `final` on purpose, so the provisional assertion could not be reached. Seeding a night from three days ago restores what the scenario means: a record that does track sleep, waiting for last night's. Written through the same ingest route as the fresh night and swept by the same purge. --- e2e/today-just-in.spec.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/e2e/today-just-in.spec.ts b/e2e/today-just-in.spec.ts index caa9f2999..eaa6923a6 100644 --- a/e2e/today-just-in.spec.ts +++ b/e2e/today-just-in.spec.ts @@ -153,6 +153,35 @@ test("a fresh arrival flips the day in place", async ({ page }) => { ).toBe(true); } + // A night from earlier in the week, so this is a record that expects + // sleep at all. The digest waits for last night only when sleep actually + // reaches the account (a night inside the last seven days); without one + // the purge above leaves a record with no sleep source, which reads + // `final` by design and puts the provisional assertion below out of + // reach. Written through the same ingest route as the fresh night, and + // swept by the same `e2e-just-in-` purge on the next run. + const priorNight = new Date(Date.now() - 3 * 24 * 60 * 60_000); + const priorWoke = new Date(priorNight.getTime() + 7 * 60 * 60_000); + const priorBatch = await page.request.post("/api/measurements/batch", { + data: { + entries: [ + { + hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis", + value: 420, + unit: "min", + startDate: priorNight.toISOString(), + endDate: priorWoke.toISOString(), + externalId: `e2e-just-in-prior-${priorNight.getTime()}`, + sleepStage: 3, + }, + ], + }, + }); + expect( + priorBatch.ok(), + `prior-night sleep batch must be accepted (got ${priorBatch.status()})`, + ).toBe(true); + // The account needs a reason to paint a hero that is not the arrival // itself. A connection needing a reconnect is that reason: the digest // reads `integration_statuses` fresh on every request rather than