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/*` 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: ( - - ), }} >
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} 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 }); } }, 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;