diff --git a/CHANGELOG.md b/CHANGELOG.md index d8c8bd35f..a2979e664 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 14045abca..b48e464e8 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.16.16 + version: 1.16.17 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 150b46b72..8e8c30f74 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/sw.js b/public/sw.js index 39183a427..dc90a884f 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.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; diff --git a/src/components/insights/__tests__/sleep-hypnogram.test.tsx b/src/components/insights/__tests__/sleep-hypnogram.test.tsx index 113ff9b93..a04a0ea17 100644 --- a/src/components/insights/__tests__/sleep-hypnogram.test.tsx +++ b/src/components/insights/__tests__/sleep-hypnogram.test.tsx @@ -92,3 +92,54 @@ describe(" — lane derivation", () => { expect(src).toContain("ticks={lanes.map((_, i) => lanes.length - 1 - i)}"); }); }); + +describe(" — 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"'); + }); +}); diff --git a/src/components/insights/sleep-hypnogram.tsx b/src/components/insights/sleep-hypnogram.tsx index c337e25c5..27b341252 100644 --- a/src/components/insights/sleep-hypnogram.tsx +++ b/src/components/insights/sleep-hypnogram.tsx @@ -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 ( diff --git a/src/components/medications/__tests__/use-medication-intake.test.ts b/src/components/medications/__tests__/use-medication-intake.test.ts index 0c4791549..f87903eeb 100644 --- a/src/components/medications/__tests__/use-medication-intake.test.ts +++ b/src/components/medications/__tests__/use-medication-intake.test.ts @@ -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, diff --git a/src/components/medications/use-medication-intake.ts b/src/components/medications/use-medication-intake.ts index 96055fdab..bde93e547 100644 --- a/src/components/medications/use-medication-intake.ts +++ b/src/components/medications/use-medication-intake.ts @@ -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 { + await invalidateKeys(queryClient, medicationDependentKeys); + await queryClient.invalidateQueries({ + queryKey: queryKeys.dashboardSnapshot(), + refetchType: "inactive", + }); +} + type Translator = ( key: string, params?: Record, @@ -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"));