From 13028a2605b205a7ce59d4159bb6599d7c266122 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 1 Jul 2026 16:47:31 +0200 Subject: [PATCH 1/5] fix(settings): drop the back-link on Appearance subpages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "back to Appearance" link above the heading on each /settings/layout/ subpage pushed the whole section down and read as clutter. Remove it — the subpage is a dead end reached from the hub or the module cog; the left nav already returns to Appearance. The shared back-link primitive stays (still used by the shell's layout-child fallback path). --- src/app/settings/layout/[module]/page.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/app/settings/layout/[module]/page.tsx b/src/app/settings/layout/[module]/page.tsx index 241ec4c51..ef0f6ad9a 100644 --- a/src/app/settings/layout/[module]/page.tsx +++ b/src/app/settings/layout/[module]/page.tsx @@ -6,7 +6,6 @@ import { LAYOUT_GROUP_IDS, isLayoutGroupId, } from "@/components/settings/layout-groups"; -import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link"; import { SettingsShell } from "@/components/settings/settings-shell"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { getServerTranslator } from "@/lib/i18n/server-translator"; @@ -67,12 +66,6 @@ export default async function SettingsLayoutModulePage({ params }: PageProps) { title: t(group.titleKey), subtitle: t(group.descriptionKey), headingId, - topSlot: ( - - ), }} >
From bb72cbe0923038c094e656c99f61f5210da38596 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 1 Jul 2026 18:51:36 +0200 Subject: [PATCH 2/5] fix(insights): stop the daily briefing vanishing on grounded vitals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daily-briefing number-grounding gate strips the whole dailyBriefing block to null when it finds a restated figure it cannot trace to a pre-computed server value. Its allow-set covered signalsOfDay plus the glucose / labs / preventive-care / workout / grip / waist / pain aggregates, but omitted the core vitals blocks — weight, blood pressure, pulse, body fat, mood, sleep, activity, rate-pressure product, seasonal variation, historical comparison — even though the briefing prompt is fed all of them. A verdict-first briefing that restated a blood-pressure average or a resting pulse (figures every BP-tracking user has) therefore tripped the gate on every run: one ungrounded number strips the entire briefing while the rest of the insight refreshes, so the surface reads as "every other text updated, only the briefing is gone". Admit every figure the server pre-computes for those blocks into the allow-set. The anti-fabrication guarantee is unchanged — only server-computed numbers are ever added, so a genuinely invented value is still flagged and stripped. --- .../ai/__tests__/briefing-grounding.test.ts | 66 ++++++++++++ src/lib/ai/briefing-grounding.ts | 101 ++++++++++++++++++ 2 files changed, 167 insertions(+) diff --git a/src/lib/ai/__tests__/briefing-grounding.test.ts b/src/lib/ai/__tests__/briefing-grounding.test.ts index 39156af55..50e2e5f0e 100644 --- a/src/lib/ai/__tests__/briefing-grounding.test.ts +++ b/src/lib/ai/__tests__/briefing-grounding.test.ts @@ -196,3 +196,69 @@ describe("W8 aggregate-block grounding extension", () => { expect(found.map((f) => f.value)).toContain(250); }); }); + +// v1.25.13 — the allow-set previously omitted the CORE vitals blocks (weight, +// blood pressure, pulse, mood, sleep, …) even though the briefing prompt is fed +// all of them. A verdict-first briefing that restated a blood-pressure average +// or a resting pulse — figures every BP-tracking user has — tripped the gate and +// got the WHOLE dailyBriefing stripped to null while the rest of the insight +// refreshed. These lock in that those figures are now grounded. +describe("core-vitals grounding extension", () => { + const signals = [signal({})]; + + it("flags blood-pressure + pulse figures when only signals are passed", () => { + const found = findUngroundedBriefingNumbers( + { + paragraph: + "Your blood pressure has averaged 128/82 with a resting pulse near 68.", + }, + signals, + ); + // Without the features block these are ungrounded (the pre-fix strip). + // (82 is omitted from the assertion — it collides with the default weight + // signal's `latest: 82`, so it grounds coincidentally; 128 + 68 do not.) + expect(found.map((f) => f.value)).toEqual( + expect.arrayContaining([128, 68]), + ); + }); + + it("admits blood-pressure + pulse averages once the features block is passed", () => { + const found = findUngroundedBriefingNumbers( + { + paragraph: + "Your blood pressure has averaged 128/82 with a resting pulse near 68.", + }, + signals, + { + bloodPressure: { avgSys30: 128, avgDia30: 82 }, + pulse: { avg30: 68 }, + } as never, + ); + expect(found).toEqual([]); + }); + + it("admits weight, mood and sleep means from the features block", () => { + const found = findUngroundedBriefingNumbers( + { + paragraph: + "Weight sits at 84.5 kg, your mood averaged 3.8 and you slept 7.2 hours.", + }, + signals, + { + weight: { latest: 84.5, bmi: 26.1 }, + mood: { avg30: 3.8 }, + sleep: { avg7: 7.2 }, + } as never, + ); + expect(found).toEqual([]); + }); + + it("still flags a genuinely fabricated vital absent from every block", () => { + const found = findUngroundedBriefingNumbers( + { paragraph: "Your systolic spiked to 195 overnight." }, + signals, + { bloodPressure: { avgSys30: 128, avgDia30: 82 } } as never, + ); + expect(found.map((f) => f.value)).toContain(195); + }); +}); diff --git a/src/lib/ai/briefing-grounding.ts b/src/lib/ai/briefing-grounding.ts index aa020deef..99c2419d1 100644 --- a/src/lib/ai/briefing-grounding.ts +++ b/src/lib/ai/briefing-grounding.ts @@ -176,6 +176,107 @@ function authoritativeValues( push(pain.avg30); push(pain.slope30); } + + // v1.25.13 — the core vitals blocks (weight, blood pressure, pulse, body + // fat, mood, sleep, activity, RPP, seasonal, historical) are ALL fed to the + // briefing prompt, but the allow-set only admitted signalsOfDay + the W8/ + // clinical aggregates — so a verdict-first briefing that restated a blood- + // pressure average or a resting pulse (values every BP-tracking user has) + // tripped the gate and got the WHOLE dailyBriefing stripped to null while + // the rest of the insight refreshed. Admit every figure the server actually + // pre-computed for these blocks; the anti-fabrication guarantee is intact + // (only server-computed numbers are ever added to the set). + const weight = features.weight; + if (weight) { + push(weight.latest); + push(weight.avg7); + push(weight.avg30); + push(weight.avg90); + push(weight.allTimeAvg); + push(weight.allTimeMin); + push(weight.allTimeMax); + push(weight.slope30); + push(weight.bmi); + } + const bp = features.bloodPressure; + if (bp) { + push(bp.avgSys30); + push(bp.avgDia30); + push(bp.avgSys90); + push(bp.avgDia90); + push(bp.allTimeAvgSys); + push(bp.allTimeAvgDia); + push(bp.allTimeMinSys); + push(bp.allTimeMaxSys); + push(bp.allTimeMinDia); + push(bp.allTimeMaxDia); + push(bp.slopeSys30); + push(bp.slopeDia30); + push(bp.sdSys30); + push(bp.sdDia30); + push(bp.pulsePressure30); + push(bp.pctInTarget); + } + const pulse = features.pulse; + if (pulse) { + push(pulse.avg7); + push(pulse.avg30); + push(pulse.avg90); + push(pulse.allTimeAvg); + push(pulse.allTimeMin); + push(pulse.allTimeMax); + push(pulse.slope30); + } + const bodyFat = features.bodyFat; + if (bodyFat) { + push(bodyFat.latest); + push(bodyFat.avg30); + push(bodyFat.slope30); + } + const mood = features.mood; + if (mood) { + push(mood.avg7); + push(mood.avg30); + push(mood.latest); + } + const sleep = features.sleep; + if (sleep) { + push(sleep.avg7); + push(sleep.avg30); + push(sleep.latest); + } + const activity = features.activity; + if (activity) { + push(activity.avg7); + push(activity.avg30); + push(activity.latest); + } + const rpp = features.ratePressureProduct; + if (rpp) { + push(rpp.rpp7); + push(rpp.rpp30); + } + const seasonal = features.seasonalVariation; + if (seasonal) { + push(seasonal.winterAvgSys); + push(seasonal.summerAvgSys); + push(seasonal.delta); + } + const hist = features.historicalComparison; + if (hist) { + for (const block of [ + hist.weight, + hist.systolic, + hist.diastolic, + hist.pulse, + ]) { + if (block) { + push(block.current7dAvg); + push(block.previous30dAvg); + push(block.change); + } + } + } } return values; From a0032ba7ed13c0fe8f80db50dbc7d52d58914bf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 1 Jul 2026 18:53:57 +0200 Subject: [PATCH 3/5] fix(medications): render the dose as a tag beside the category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card header carried the dose on its own muted line under the name and the category as an outline badge below that. Surface the dose as a tag sitting next to the category badge instead — `[7,5 mg] [Sonstiges]` — so a blood-pressure tablet and a GLP-1 injection present their strength the same way, in one tight badge row directly under the name. Both cards share this header, so the generic and GLP-1 variants pick it up together. State badges keep their own row below, so the narrow-viewport two-line contract is unchanged. --- .../__tests__/medication-card-header.test.tsx | 8 ++-- .../medications/medication-card-header.tsx | 46 +++++++++---------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/components/medications/__tests__/medication-card-header.test.tsx b/src/components/medications/__tests__/medication-card-header.test.tsx index 0cb7a17d5..d74a8c3a8 100644 --- a/src/components/medications/__tests__/medication-card-header.test.tsx +++ b/src/components/medications/__tests__/medication-card-header.test.tsx @@ -28,7 +28,7 @@ function render(node: React.ReactNode) { } describe("", () => { - it("paints the name on the title row and the dose on its own muted line", () => { + it("paints the name on the title row and the dose as a tag before the category", () => { const html = render( ", () => { categoryLabel="Blood Pressure" />, ); - // Name + dose are now separate elements — the dose rides its own - // `data-slot` line directly under the title, not glued to the name. + // Name + dose are separate elements — the dose is its own tagged badge + // (`data-slot`) sitting beside the category badge, not glued to the name. expect(html).toContain("Ramipril"); expect(html).toContain('data-slot="medication-card-header-dose"'); expect(html).toContain("5 mg"); expect(html).toContain("Blood Pressure"); - // The dose line paints between the name and the category badge. + // The dose tag paints between the name and the category badge. const nameIdx = html.indexOf("Ramipril"); const doseIdx = html.indexOf('data-slot="medication-card-header-dose"'); const categoryIdx = html.indexOf("Blood Pressure"); diff --git a/src/components/medications/medication-card-header.tsx b/src/components/medications/medication-card-header.tsx index b0959696b..17cb8151a 100644 --- a/src/components/medications/medication-card-header.tsx +++ b/src/components/medications/medication-card-header.tsx @@ -15,8 +15,9 @@ import { CardHeader, CardTitle } from "@/components/ui/card"; * surface stays one consistent shape: * * Line 1: `{name}` — bold, `text-lg` - * Line 2: `{dose}` — muted, rendered only when non-empty - * Line 3: `{categoryLabel}` outline badge + optional state badges + * Line 2: `{dose}` outline badge (only when non-empty) + `{categoryLabel}` + * outline badge, side by side — `[7,5 mg] [Sonstiges]` + * Line 3: optional state badges (own row, below) * * The trailing `actions` slot carries the overflow kebab on the right of * the row. State badges (without-notification, paused-since, inactive) @@ -82,28 +83,27 @@ export function MedicationCardHeader({ {name} {nameChip} - {/* The dose sits on its OWN muted line directly under the name, above - the category badge. Rendered only when a non-empty dose string is - supplied — Vorsorge / lab-list rows pass `dose=""` and get no empty - line. The unit is already localised by `formatDose` at the call - site (the raw `Medication.dose` carries the English unit key). */} - {dose ? ( -

- {dose} -

- ) : null} - {/* D-H6 — state badges (without-notification, paused-since, - inactive) used to share line 2 with the category badge - via `flex flex-wrap`. At 320 px any state badge pushed - the row to three lines and broke the FB-G1 "two-line, - no exceptions" contract for ~20 % of configured drugs. - State badges now ride their own row below the category - badge so the canonical row stays two lines on narrow - viewports. */} + {/* v1.25.13 — the dose reads as a TAG sitting beside the category badge + (`[7,5 mg] [Sonstiges]`), not as a muted grey line of its own. Both + are outline badges on the same row directly under the name so a + blood-pressure drug and a GLP-1 injection surface their strength the + same way. Rendered only when a non-empty dose string is supplied — + Vorsorge / lab-list rows pass `dose=""` and get only the category + badge. The unit is already localised by `formatDose` at the call site + (the raw `Medication.dose` carries the English unit key). + D-H6 — state badges (without-notification, paused-since, inactive) + ride their OWN row below (see `stateBadges`) so the canonical row + stays two lines at 320 px even for a drug carrying a state badge. */}
+ {dose ? ( + + {dose} + + ) : null} {categoryLabel} From 7c4b54e3ea51736643509ffd3b0be13590cb8f19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 1 Jul 2026 18:56:43 +0200 Subject: [PATCH 4/5] fix(medications): let the custom dose-window toggle stay on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-dose "custom window" switch could never be turned on. Switching it on seeded the range from the default band and emitted immediately, but the serialiser drops a range equal to the default band, so the persisted value round-tripped straight back to empty, the controlled `checked` recomputed false, and the switch snapped off. The explicit range fields only appear once the switch is on, so there was no way to edit the range into a non-default one first — a deadlock. Track the opened-switch intent in local editor state, decoupled from serialisation. A toggled-on row renders its default band immediately and the switch stays on; an explicit range persists only once the user edits it away from the default. Toggling off drops any stored range back to the default derivation. --- .../scheduling/__tests__/dose-window.test.ts | 15 ++++++ .../scheduling/dose-window-editor.tsx | 46 +++++++++++++++---- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/src/components/medications/scheduling/__tests__/dose-window.test.ts b/src/components/medications/scheduling/__tests__/dose-window.test.ts index 0e0737a7a..4e3508de6 100644 --- a/src/components/medications/scheduling/__tests__/dose-window.test.ts +++ b/src/components/medications/scheduling/__tests__/dose-window.test.ts @@ -158,6 +158,21 @@ describe("dose-window helpers", () => { ]; expect(rowsToEntries(rows)).toEqual([]); }); + + // v1.25.13 regression — a row toggled custom but still sitting at the + // default band serialises to `[]`. This is WHY tracks + // the "opened" switch state locally instead of reconstructing it from the + // persisted value: a value round-trip would drop the row and snap the + // switch back off, so the toggle could never turn on until an explicit + // range existed — which the editor won't reveal until the toggle is on. + it("round-trips a default-band custom row to empty (the toggle-deadlock trap)", () => { + const seeded = buildRows(["08:00"], []); // custom:false, band 07:00–09:00 + const openedAtDefault: DoseWindowRow[] = seeded.map((r) => ({ + ...r, + custom: true, + })); + expect(rowsToEntries(openedAtDefault)).toEqual([]); + }); }); describe("isOrderedRange", () => { diff --git a/src/components/medications/scheduling/dose-window-editor.tsx b/src/components/medications/scheduling/dose-window-editor.tsx index acfe00943..0a39d305f 100644 --- a/src/components/medications/scheduling/dose-window-editor.tsx +++ b/src/components/medications/scheduling/dose-window-editor.tsx @@ -18,7 +18,7 @@ * section chrome the other Zeitplan / Erinnerung rows use. */ -import { useCallback, useId, useMemo } from "react"; +import { useCallback, useId, useMemo, useState } from "react"; import { TimeField } from "@/components/ui/time-field"; import { Switch } from "@/components/ui/switch"; @@ -26,7 +26,6 @@ import { Label } from "@/components/ui/label"; import { useTranslations } from "@/lib/i18n/context"; import { buildRows, - defaultBandForTime, isOrderedRange, isValidHhmm, lateTailDays, @@ -68,7 +67,31 @@ export function DoseWindowEditor({ const generatedId = useId(); const prefix = idPrefix ?? generatedId; - const rows = useMemo(() => buildRows(timesOfDay, value), [timesOfDay, value]); + // v1.25.13 — the switch reflects the user's INTENT to customise, held in + // local state, because it cannot be reconstructed from `value` alone: the + // persisted contract stores ONLY ranges that differ from the default band + // (`rowsToEntries` drops a default-equal range so the column stays minimal). + // The former editor seeded the range from the default band on toggle-ON and + // emitted immediately, so serialisation dropped it right back to `[]`, the + // controlled `checked={row.custom}` recomputed `false`, and the switch + // snapped off — the toggle could never turn on without first editing a range + // the editor wouldn't reveal until the toggle was on (a deadlock). Tracking + // the opened set here decouples the toggle from serialisation: the row shows + // its default band, and an explicit range only persists once it differs. + const [opened, setOpened] = useState>( + () => new Set(), + ); + + const rows = useMemo(() => { + const base = buildRows(timesOfDay, value); + if (opened.size === 0) return base; + // A row the user opened reads as custom even while its range still equals + // the default band (nothing persisted yet); a row already carrying a + // stored explicit range is custom via `value` regardless. + return base.map((r) => + opened.has(r.timeOfDay) ? { ...r, custom: true } : r, + ); + }, [timesOfDay, value, opened]); // Rebuild the persisted set from the next row state and bubble it up. const emit = useCallback( @@ -89,12 +112,17 @@ export function DoseWindowEditor({ const toggleCustom = useCallback( (row: DoseWindowRow, custom: boolean) => { - if (custom) { - // Switching ON seeds the range from the current default band so - // the user starts from the same band the engine would derive. - const def = defaultBandForTime(row.timeOfDay); - setRow(row.timeOfDay, { custom: true, start: def.start, end: def.end }); - } else { + setOpened((prev) => { + const next = new Set(prev); + if (custom) next.add(row.timeOfDay); + else next.delete(row.timeOfDay); + return next; + }); + // Switching OFF drops any persisted explicit range for the slot (back to + // the default derivation). Switching ON needs no emit — the row already + // renders the default band and the opened set keeps the switch on; a + // range only persists once the user edits it away from the default. + if (!custom) { setRow(row.timeOfDay, { custom: false }); } }, From 5d5ec681ee4f2b1c4259a058033270b36f72fab6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 1 Jul 2026 19:29:26 +0200 Subject: [PATCH 5/5] =?UTF-8?q?chore(release):=20v1.25.13=20=E2=80=94=20br?= =?UTF-8?q?iefing=20and=20medication=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the daily briefing when it cites your own vitals (the number-grounding gate stripped it on any blood-pressure, pulse, weight, mood or sleep figure it did not recognise), surface a medication's dose as a tag beside its category, let the custom on-time window switch stay on, and drop the layout-shifting back-link from the Appearance sub-pages. --- CHANGELOG.md | 9 +++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 050368bf0..7f36d844b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +## [1.25.13] — 2026-07-01 — Briefing and medication fixes + +### Fixed + +- The daily briefing no longer vanishes when it mentions one of your own vitals. It could quote a blood-pressure average, a resting pulse, a weight, a mood or a sleep figure that the server had computed but the briefing's number check did not recognise; a single unrecognised figure dropped the entire briefing while the rest of the insight refreshed, so the briefing looked like it had stopped working. Every figure the server pre-computes for those signals is now recognised, and a genuinely invented number is still caught. +- A medication's dose now reads as a tag next to its category — `7,5 mg` beside `Other` — instead of a separate grey line under the name. Blood-pressure tablets and GLP-1 injections present their strength the same way. +- The custom on-time window switch in a medication's schedule can now be turned on. Turning it on was previously undone in the same step, so it snapped straight back off and no custom window could be set. +- The Appearance sub-pages no longer carry a "Back to appearance" link that pushed the layout down; each sub-page is a dead-end by design. + ## [1.25.12] — 2026-06-30 — Follow-up fixes ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index cea382712..73ee36bbd 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.25.12 + version: 1.25.13 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 f9dc4f089..4493b1b45 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.25.12", + "version": "1.25.13", "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 751bc2d91..29ba0993b 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.25.12"; + /* @sw-version-fallback */ "v1.25.13"; 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/*`