Merge pull request #395 from MBombeck/release/v1.25.13

release/v1.25.13
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-01 19:38:23 +02:00
commit a88c90fbfe
11 files changed
+258 -46

No files matched your search

+9
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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",
+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.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/*`
@@ -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: (
<SettingsHubBackLink
href="/settings/layout"
labelKey="settings.sections.layout.backToHub"
/>
),
}}
>
<section aria-labelledby={headingId} className="space-y-6">
@@ -28,7 +28,7 @@ function render(node: React.ReactNode) {
}
describe("<MedicationCardHeader>", () => {
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(
<MedicationCardHeader
name="Ramipril"
@@ -36,13 +36,13 @@ describe("<MedicationCardHeader>", () => {
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");
@@ -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({
<span>{name}</span>
{nameChip}
</CardTitle>
{/* 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 ? (
<p
data-slot="medication-card-header-dose"
className="text-muted-foreground text-sm"
>
{dose}
</p>
) : 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. */}
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
{dose ? (
<Badge
data-slot="medication-card-header-dose"
variant="outline"
className="text-xs"
>
{dose}
</Badge>
) : null}
<Badge variant="outline" className="text-xs">
{categoryLabel}
</Badge>
@@ -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 <DoseWindowEditor> 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", () => {
@@ -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<ReadonlySet<string>>(
() => new Set<string>(),
);
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 });
}
},
@@ -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);
});
});
+101
View File
@@ -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;