", () => {
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;