mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge pull request #395 from MBombeck/release/v1.25.13
release/v1.25.13
This commit is contained in:
11 files changed
+258
-46
No files matched your search
@@ -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,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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user