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/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 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/*` 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;