mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge pull request #801 from MBombeck/release/v1.37.24
v1.37.24 — stop waiting for sleep that never comes
This commit is contained in:
7 files changed
+96
-10
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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/*`
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user