merge: forward the v1.16.17 fixes into the v1.17.0 line

Carry the restored sleep timeline bar and the dashboard refresh-after-intake
fixes onto the release branch so the v1.17.0 image ships with them.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 20:38:23 +02:00
commit 4788c81689
8 files changed
+132 -13

No files matched your search

+9
View File
@@ -2,6 +2,15 @@
## [Unreleased]
## [1.16.17] — 2026-06-14 — the sleep timeline returns, and the dashboard clears after a dose
Two fixes to behaviour that surfaced in daily use. No breaking changes.
### Fixed
- **The "Last night" sleep timeline bar returns.** The staged hypnogram — which lays the night out across deep, core, REM and awake by clock time — had collapsed to just the numbers for nights whose stages share an end instant, leaving the breakdown without its bar. The bar now appears whenever the night carries timed stages; only a session recorded on a single instant falls back to the breakdown alone.
- **The dashboard clears a dose the moment it is recorded.** After recording an intake from the medication card or detail page, the dashboard kept prompting for that dose until a full page reload, because the home view was refreshed only while it was on screen. It now refreshes as soon as the dose is recorded, with no reload.
## [1.16.16] — 2026-06-14 — one engine for sleep, recovery and glucose
This release continues the work of making one number mean one thing everywhere. Sleep, recovery and blood glucose are now read from a single source on every surface — the dashboard, the coach, the doctor-report, the CSV and FHIR exports, and the companion app — so the figure you act on is the same wherever you look. Several displayed numbers become correct-but-different; each is called out below. No breaking changes.
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.16.16
version: 1.16.17
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.16.16",
"version": "1.16.17",
"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
View File
@@ -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.16.16";
/* @sw-version-fallback */ "v1.16.17";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
const MAX_STATIC_ENTRIES = 150;
@@ -92,3 +92,54 @@ describe("<SleepHypnogram> — lane derivation", () => {
expect(src).toContain("ticks={lanes.map((_, i) => lanes.length - 1 - i)}");
});
});
describe("<SleepHypnogram> — timeline bar visibility", () => {
const sharedEnd = "2026-06-09T06:00:00.000Z";
it("draws the timeline bar for a multi-stage night whose segments share a right edge", () => {
// Distinct STARTS but a shared END instant — the shape a summary-derived
// writer reconstructs to. The earlier end-instant guard hid the bar here;
// gating on the start instant restores it. (Regression: bar vanished,
// leaving only the breakdown numbers.)
const session: SleepHypnogramSession = {
...baseSession,
segments: [
{
stage: "CORE",
start: "2026-06-08T22:30:00.000Z",
end: sharedEnd,
minutes: 200,
},
{
stage: "DEEP",
start: "2026-06-09T01:00:00.000Z",
end: sharedEnd,
minutes: 100,
},
{
stage: "REM",
start: "2026-06-09T03:00:00.000Z",
end: sharedEnd,
minutes: 120,
},
],
};
expect(render(session)).toContain('role="img"');
});
it("collapses to the breakdown footer for a degenerate single-instant session", () => {
// Every segment stamped on ONE instant — no real timeline, so only the
// breakdown footer, no bar.
const instant = "2026-06-09T06:00:00.000Z";
const session: SleepHypnogramSession = {
...baseSession,
segments: [
{ stage: "CORE", start: instant, end: instant, minutes: 200 },
{ stage: "DEEP", start: instant, end: instant, minutes: 100 },
],
};
const html = render(session);
expect(html).not.toContain('role="img"');
expect(html).toContain('data-slot="sleep-hypnogram-breakdown"');
});
});
+8 -6
View File
@@ -209,15 +209,17 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
// need any positive per-stage minutes. A legacy stage-summary night (no
// segment spans) still shows its breakdown rather than collapsing the
// whole card. "Unavailable" is reserved for a truly empty session.
// Spans must also carry more than one distinct end instant: a summary-
// shaped writer (per-stage night totals all stamped on the sleep end)
// reconstructs into bars that all touch the night's right edge — that
// is a breakdown wearing a timeline costume, so paint it as the
// breakdown footer instead.
// Spans must also carry more than one distinct START instant: a real
// night with timed stages steps through the clock, so its segments begin
// at different instants and earn the timeline bar. Only a degenerate
// session whose every segment starts on a single instant collapses to the
// breakdown footer. (Gating on the END instant instead hid the bar for an
// ordinary multi-stage night whose segments happened to share a right
// edge — the regression this restores.)
const hasTrack =
spans.length > 0 &&
(spans.length === 1 ||
new Set(spans.map((s) => s.x2)).size > 1);
new Set(spans.map((s) => s.x1)).size > 1);
const hasBreakdown = breakdown.length > 0;
return (
@@ -7,6 +7,7 @@ import {
runRecordIntake,
runUndoIntake,
} from "@/components/medications/use-medication-intake";
import { queryKeys } from "@/lib/query-keys";
/**
* v1.12.2 — the shared intake orchestration consumed by both the generic
@@ -144,6 +145,38 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
expect(onRecorded).toHaveBeenCalledWith("evt-99", false);
});
it("forces the inactive dashboard snapshot to refetch so the due prompt clears without a reload", async () => {
// The dashboard snapshot is unmounted while the user is on the medication
// card/detail, so a default ("active") invalidation marks it stale but
// never refetches it; on navigating back, refetchOnMount:false serves the
// pre-write cache and the "due" prompt lingers until a hard reload.
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ data: { id: "evt-1" } }),
text: vi
.fn()
.mockResolvedValue(JSON.stringify({ data: { id: "evt-1" } })),
}),
);
const queryClient = fakeQueryClient();
await runRecordIntake({
medication,
skipped: false,
t,
queryClient,
setIntakeLoading: vi.fn(),
undoIntake: vi.fn(),
});
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: queryKeys.dashboardSnapshot(),
refetchType: "inactive",
});
});
it("sends the displayed dose's scheduledFor on the POST so the server marks THAT slot (v1.12.3)", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
@@ -5,9 +5,33 @@ import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import {
invalidateKeys,
medicationDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import { apiDelete, apiPost } from "@/lib/api/api-fetch";
/**
* Invalidate every read that reflects a dose's taken/due state after an
* intake write. `invalidateKeys` invalidates with the default
* `refetchType: "active"`, which only refetches mounted queries — so the
* dashboard snapshot, inactive while the user is on the medication card or
* detail page, is marked stale but never refetched. On navigating back the
* snapshot remounts under `refetchOnMount: false`, so the pre-write cache is
* served and the "due" prompt lingers until a hard reload. Force the inactive
* snapshot to refetch so the dashboard clears as soon as the dose is recorded.
*/
async function invalidateMedicationReads(
queryClient: QueryClient,
): Promise<void> {
await invalidateKeys(queryClient, medicationDependentKeys);
await queryClient.invalidateQueries({
queryKey: queryKeys.dashboardSnapshot(),
refetchType: "inactive",
});
}
type Translator = (
key: string,
params?: Record<string, string | number>,
@@ -136,7 +160,7 @@ export async function runRecordIntake(deps: {
}
: undefined,
);
await invalidateKeys(queryClient, medicationDependentKeys);
await invalidateMedicationReads(queryClient);
onRecorded?.(eventId, skipped);
} catch {
toast.error(t("medications.intakeToastFailed", { name: medication.name }));
@@ -188,7 +212,7 @@ export async function runLogIntake(deps: {
if (scheduledFor) body.scheduledFor = scheduledFor;
if (!skipped && doseTaken) body.doseTaken = doseTaken;
await apiPost(`/api/medications/${medication.id}/intake`, body);
await invalidateKeys(queryClient, medicationDependentKeys);
await invalidateMedicationReads(queryClient);
toast.success(
t(
skipped
@@ -217,7 +241,7 @@ export async function runUndoIntake(deps: {
const { medication, eventId, t, queryClient } = deps;
try {
await apiDelete(`/api/medications/${medication.id}/intake/${eventId}`);
await invalidateKeys(queryClient, medicationDependentKeys);
await invalidateMedicationReads(queryClient);
toast.success(t("medications.intakeUndone"));
} catch {
toast.error(t("medications.intakeUndoFailed"));