mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
commit
4788c81689
8 files changed
+132
-13
No files matched your search
@@ -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,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
@@ -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
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
|
||||
Reference in new issue
Block a user