From a3410efb5ce791b013dfb126c031ef943d8fc384 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 7 Jun 2026 01:05:59 +0200 Subject: [PATCH 1/3] Unify medication card next/last labels across both variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GLP-1 card overrode the shared next/last slot with appointment-phrased labels (Next / Last, "Nächster Termin" / "Letzter Termin") while the generic card used Next intake / Last intake. Drop the override so both cards name the concept identically; the GLP-1 relative-day value phrasing (in N days / today) is unchanged. Remove the now-dead label override props from the shared slot and the orphaned glp1NextLabel / glp1LastLabel strings from all locales. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- .../__tests__/medication-card-glp1.test.tsx | 14 ++-- .../card-parts/medication-next-last-slot.tsx | 77 +++++++------------ .../medications/glp1-medication-card.tsx | 25 ++++-- 9 files changed, 60 insertions(+), 74 deletions(-) diff --git a/messages/de.json b/messages/de.json index b3cb6f540..5aa7eb089 100644 --- a/messages/de.json +++ b/messages/de.json @@ -700,6 +700,7 @@ "compliance7d": "7-Tage-Compliance", "compliance30d": "30-Tage-Compliance", "complianceWindow": "{days}-Tage-Compliance", + "complianceDoses": "{count} Dosen", "lastIntake": "Letzte Einnahme:", "nextIntake": "Nächste Einnahme:", "dayStreak": "Tage Serie", @@ -745,8 +746,6 @@ "glp1DosesPerUnit": "Dosen pro Pen / Ampulle", "glp1DosesPerUnitPlaceholder": "z. B. 4 für einen Mounjaro KwikPen", "glp1DosesPerUnitHelp": "Optional — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy Einmal-Pen = 1. Leer lassen, wenn keine Dosis-pro-Pen-Verfolgung gewünscht ist.", - "glp1NextLabel": "Nächster Termin:", - "glp1LastLabel": "Letzter Termin:", "glp1LastInjection": "{label}", "glp1LastInjectionWithSite": "{label} · {site}", "glp1NextInjectionDays": "{label} (in {days} Tagen)", diff --git a/messages/en.json b/messages/en.json index 659f3aa2b..bcfa155bf 100644 --- a/messages/en.json +++ b/messages/en.json @@ -700,6 +700,7 @@ "compliance7d": "7-day compliance", "compliance30d": "30-day compliance", "complianceWindow": "{days}-day compliance", + "complianceDoses": "{count} doses", "lastIntake": "Last intake:", "nextIntake": "Next intake:", "dayStreak": "day streak", @@ -745,8 +746,6 @@ "glp1DosesPerUnit": "Doses per pen / vial", "glp1DosesPerUnitPlaceholder": "e.g. 4 for a Mounjaro KwikPen", "glp1DosesPerUnitHelp": "Optional — Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank if you don't track per-pen dosing.", - "glp1NextLabel": "Next:", - "glp1LastLabel": "Last:", "glp1LastInjection": "{label}", "glp1LastInjectionWithSite": "{label} · {site}", "glp1NextInjectionDays": "{label} (in {days} days)", diff --git a/messages/es.json b/messages/es.json index b8fb454ac..6e5146b72 100644 --- a/messages/es.json +++ b/messages/es.json @@ -700,6 +700,7 @@ "compliance7d": "Cumplimiento 7 días", "compliance30d": "Cumplimiento 30 días", "complianceWindow": "Cumplimiento {days} días", + "complianceDoses": "{count} dosis", "lastIntake": "Última toma:", "nextIntake": "Próxima toma:", "dayStreak": "días de racha", @@ -745,8 +746,6 @@ "glp1DosesPerUnit": "Dosis por pluma / vial", "glp1DosesPerUnitPlaceholder": "p. ej. 4 para una pluma Mounjaro KwikPen", "glp1DosesPerUnitHelp": "Opcional — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy pluma de un solo uso = 1. Déjalo vacío si no quieres llevar el conteo de dosis por pluma.", - "glp1NextLabel": "Próxima cita:", - "glp1LastLabel": "Última cita:", "glp1LastInjection": "{label}", "glp1LastInjectionWithSite": "{label} · {site}", "glp1NextInjectionDays": "{label} (en {days} días)", diff --git a/messages/fr.json b/messages/fr.json index 5d034153b..c422e5303 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -700,6 +700,7 @@ "compliance7d": "Observance 7 jours", "compliance30d": "Observance 30 jours", "complianceWindow": "Observance {days} jours", + "complianceDoses": "{count} doses", "lastIntake": "Dernière prise :", "nextIntake": "Prochaine prise :", "dayStreak": "jours de série", @@ -745,8 +746,6 @@ "glp1DosesPerUnit": "Doses par stylo / flacon", "glp1DosesPerUnitPlaceholder": "p. ex. 4 pour un stylo Mounjaro KwikPen", "glp1DosesPerUnitHelp": "Optionnel — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy stylo à usage unique = 1. Laisse vide si tu ne veux pas suivre la dose par stylo.", - "glp1NextLabel": "Prochain rendez-vous :", - "glp1LastLabel": "Dernier rendez-vous :", "glp1LastInjection": "{label}", "glp1LastInjectionWithSite": "{label} · {site}", "glp1NextInjectionDays": "{label} (dans {days} jours)", diff --git a/messages/it.json b/messages/it.json index 97b5284fb..112f8c9ba 100644 --- a/messages/it.json +++ b/messages/it.json @@ -700,6 +700,7 @@ "compliance7d": "Aderenza 7 giorni", "compliance30d": "Aderenza 30 giorni", "complianceWindow": "Aderenza {days} giorni", + "complianceDoses": "{count} dosi", "lastIntake": "Ultima assunzione:", "nextIntake": "Prossima assunzione:", "dayStreak": "giorni di serie", @@ -745,8 +746,6 @@ "glp1DosesPerUnit": "Dosi per penna / fiala", "glp1DosesPerUnitPlaceholder": "es. 4 per una penna Mounjaro KwikPen", "glp1DosesPerUnitHelp": "Opzionale — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy penna monouso = 1. Lascia vuoto se non vuoi monitorare le dosi per penna.", - "glp1NextLabel": "Prossimo appuntamento:", - "glp1LastLabel": "Ultimo appuntamento:", "glp1LastInjection": "{label}", "glp1LastInjectionWithSite": "{label} · {site}", "glp1NextInjectionDays": "{label} (tra {days} giorni)", diff --git a/messages/pl.json b/messages/pl.json index d238c5397..fa0e5dead 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -700,6 +700,7 @@ "compliance7d": "Przestrzeganie 7 dni", "compliance30d": "Przestrzeganie 30 dni", "complianceWindow": "Przestrzeganie {days} dni", + "complianceDoses": "{count} dawek", "lastIntake": "Ostatnie przyjęcie:", "nextIntake": "Następne przyjęcie:", "dayStreak": "dni serii", @@ -745,8 +746,6 @@ "glp1DosesPerUnit": "Dawki na pen / fiolkę", "glp1DosesPerUnitPlaceholder": "np. 4 dla pena Mounjaro KwikPen", "glp1DosesPerUnitHelp": "Opcjonalne — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy pen jednorazowy = 1. Zostaw puste, jeśli nie chcesz śledzić dawek na pen.", - "glp1NextLabel": "Następny termin:", - "glp1LastLabel": "Ostatni termin:", "glp1LastInjection": "{label}", "glp1LastInjectionWithSite": "{label} · {site}", "glp1NextInjectionDays": "{label} (za {days} dni)", diff --git a/src/components/medications/__tests__/medication-card-glp1.test.tsx b/src/components/medications/__tests__/medication-card-glp1.test.tsx index c77f983ea..242391403 100644 --- a/src/components/medications/__tests__/medication-card-glp1.test.tsx +++ b/src/components/medications/__tests__/medication-card-glp1.test.tsx @@ -320,14 +320,16 @@ describe(" — GLP-1 variant rendering", () => { client, ); - // The last-line renders the "Last:" label in its own left column and + // v1.15.8 — the GLP-1 card unified onto the generic "Next intake" / + // "Last intake" labels (the appointment-phrased override is gone). The + // last-line renders the "Last intake:" label in its own left column and // the value (label · site) flush right; both halves show in the SSR. - expect(html).toContain("Last:"); + expect(html).toContain("Last intake:"); expect(html).toContain("Abdomen, lower left"); - // The next-line carries the "Next:" label in its left column, + // The next-line carries the "Next intake:" label in its left column, // independent of which of the three value variants (today / tomorrow / // "in N days") the helper produced. - expect(html).toMatch(/Next:/); + expect(html).toMatch(/Next intake:/); }); it("no longer renders the inline dose-history disclosure (retired v1.4.28)", () => { @@ -606,7 +608,9 @@ describe(" — GLP-1 variant rendering", () => { // visually symmetric with the generic card on the medications list. expect(html).toContain("Sonstiges"); expect(html).not.toContain("GLP-1-Injektion"); - expect(html).toContain("Letzter Termin:"); + // v1.15.8 — unified onto the generic "Letzte Einnahme:" label (the + // appointment-phrased "Letzter Termin:" override is gone). + expect(html).toContain("Letzte Einnahme:"); expect(html).toContain("Bauch, unten links"); // v1.4.28 retired the "Dosis-Historie" disclosure on the GLP-1 card. expect(html).not.toContain("Dosis-Historie"); diff --git a/src/components/medications/card-parts/medication-next-last-slot.tsx b/src/components/medications/card-parts/medication-next-last-slot.tsx index 9c77b61f2..5232f57b7 100644 --- a/src/components/medications/card-parts/medication-next-last-slot.tsx +++ b/src/components/medications/card-parts/medication-next-last-slot.tsx @@ -22,16 +22,18 @@ import { useTranslations } from "@/lib/i18n/context"; * that renders both lines, so the dose rows line up across the grid. * * Each card still builds the value *content* itself (the generic card emits a - * day-label + window-range; the GLP-1 card emits a weekly-cadence string), - * because the two cadences read differently — but the wrapper, order, colour, - * label/value layout, and gating are now guaranteed identical. The GLP-1 card - * overrides the left-column label with its appointment-phrased wording. + * day-label + window-range; the GLP-1 card emits a relative-day cadence + * string), because the two cadences read differently — but the wrapper, + * order, colour, label/value layout, and gating are guaranteed identical. + * v1.15.8 dropped the GLP-1-specific appointment-phrased label override so + * both cards name the concept identically ("Next intake" / "Last intake"); + * only the value content stays per-card. * - * Layout: when `labelled`, each line is a `flex justify-between` row with the - * bold label pinned left and the value flush right (`text-right`), so the - * label and the time read as two distinct columns rather than crammed onto - * one run of text. The label truncates first on a narrow viewport; the value - * (the time / cadence) never truncates. + * Layout: each line is a `flex justify-between` row with the bold label + * pinned left and the value flush right (`text-right`), so the label and the + * time read as two distinct columns rather than crammed onto one run of text. + * The label truncates first on a narrow viewport; the value (the time / + * cadence) never truncates. */ interface MedicationNextLastSlotProps { /** @@ -42,57 +44,32 @@ interface MedicationNextLastSlotProps { next: React.ReactNode | null; /** The last-intake line content, or null when the med has never been taken. */ last: React.ReactNode | null; - /** - * When true the part renders each line as a two-column row: a bold label - * pinned left and the value flush right. The generic card uses the default - * oral "Next intake" / "Last intake" labels; the GLP-1 card overrides them - * with its appointment-phrased labels (see `nextLabel` / `lastLabel`). - * When false the line renders as a single full-width run of text. - */ - labelled?: boolean; - /** - * Override for the left-column label on the next-line. Defaults to the - * oral `medications.nextIntake` string. The GLP-1 card passes its own - * "next appointment" wording so the value column carries only the time. - */ - nextLabel?: React.ReactNode; - /** Override for the left-column label on the last-line. */ - lastLabel?: React.ReactNode; } export function MedicationNextLastSlot({ next, last, - labelled = true, - nextLabel, - lastLabel, }: MedicationNextLastSlotProps) { const { t } = useTranslations(); return (
- {next && - (labelled ? ( -
- - {nextLabel ?? t("medications.nextIntake")} - - {next} -
- ) : ( -

{next}

- ))} - {last && - (labelled ? ( -
- - {lastLabel ?? t("medications.lastIntake")} - - {last} -
- ) : ( -

{last}

- ))} + {next && ( +
+ + {t("medications.nextIntake")} + + {next} +
+ )} + {last && ( +
+ + {t("medications.lastIntake")} + + {last} +
+ )}
); } diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index df75a98a8..8bfdbae2a 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -285,6 +285,13 @@ export function Glp1MedicationCard({ const rate7 = display?.short.rate ?? compliance?.compliance7?.rate ?? 0; const rate30 = display?.long.rate ?? compliance?.compliance30?.rate ?? 0; const streak = display?.short.streak ?? compliance?.compliance7?.streak ?? 0; + // v1.15.8 — taken-of-expected counts, shown after each percentage so two + // identical rates stay distinguishable (a weekly GLP-1 reading 100% on + // both windows shows e.g. `100% · 4 / 4` vs `100% · 13 / 13`). + const takenShort = display?.short.taken; + const expectedShort = display?.expectedShort; + const takenLong = display?.long.taken; + const expectedLong = display?.expectedLong; // v1.4.37 W4b — symmetric take-now / overdue pill with the generic // card. Sort schedules by `windowStart` so the most-actionable @@ -403,14 +410,14 @@ export function Glp1MedicationCard({ {/* Injection state — next + last, rendered through the SAME shared the generic card uses, so the order - (next-then-last), colour, spacing, gating, reserved min-height - and the label-left / value-right two-column layout are identical - across the two card types. The GLP-1 card overrides only the - left-column label with its appointment-phrased wording; the value - column carries the time (plus the optional rotation nudge). */} + (next-then-last), labels ("Next intake" / "Last intake"), colour, + spacing, gating, reserved min-height and the label-left / + value-right two-column layout are identical across the two card + types. v1.15.8 dropped the GLP-1-specific appointment-phrased + label override so both cards name the concept identically; the + relative-day value phrasing ("in N days" / "today") stays the + GLP-1 card's own and is preserved. */} @@ -462,6 +469,10 @@ export function Glp1MedicationCard({ streak={streak} shortDays={shortDays} longDays={longDays} + takenShort={takenShort} + expectedShort={expectedShort} + takenLong={takenLong} + expectedLong={expectedLong} /> ) : ( From 61c35740fcf6119be3c1a85e235be56f54554cfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 7 Jun 2026 01:06:17 +0200 Subject: [PATCH 2/3] Show the dose-count denominator on the compliance bars Two identical compliance percentages with no context read as a stuck display, even when the math is correct (a rolling weekly med on cadence is ~100% on every window by construction; a daily med with full adherence rounds to ~100% on both 7d and 30d). Render the taken-of-expected count after each rate (e.g. 100% with 4 / 4 and 100% with 52 / 52) so the two rows stay distinguishable and trustworthy. Falls back to the bare expected count when only the denominator is on the wire, and to nothing when neither count is available. Thread a per-window taken count onto ComplianceDisplay (short.taken / long.taken) alongside the existing expectedShort / expectedLong, wire both through to the shared bars component, and regen the OpenAPI schema. Add unit tests asserting the windows do not structurally collapse to one value: a daily med with misses outside the 7-day window scores 7d above 30d, and a rolling weekly med with a partial-adherence history yields a short rate above the long rate. Add card-part tests for the count / fallback / no-count render branches. --- docs/api/openapi.yaml | 10 ++ .../medications/__tests__/card-parts.test.tsx | 45 +++++++ .../card-parts/medication-compliance-bars.tsx | 68 ++++++++++- .../medications/medication-card.tsx | 12 ++ .../analytics/__tests__/compliance.test.ts | 112 ++++++++++++++++++ src/lib/analytics/compliance.ts | 17 ++- src/lib/openapi/routes.ts | 6 +- 7 files changed, 261 insertions(+), 9 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index cb857b4fe..69bf8b718 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -8456,12 +8456,17 @@ components: type: integer minimum: 0 maximum: 100 + taken: + type: integer + minimum: 0 + maximum: 9007199254740991 streak: type: integer minimum: 0 maximum: 9007199254740991 required: - rate + - taken - streak additionalProperties: false long: @@ -8471,8 +8476,13 @@ components: type: integer minimum: 0 maximum: 100 + taken: + type: integer + minimum: 0 + maximum: 9007199254740991 required: - rate + - taken additionalProperties: false currentCycle: type: object diff --git a/src/components/medications/__tests__/card-parts.test.tsx b/src/components/medications/__tests__/card-parts.test.tsx index 4fbea4ab1..215dd08e2 100644 --- a/src/components/medications/__tests__/card-parts.test.tsx +++ b/src/components/medications/__tests__/card-parts.test.tsx @@ -84,6 +84,51 @@ describe("medication card-parts — shared presentational components", () => { expect(html).not.toContain("lucide-flame"); }); + it("compliance bars show taken / expected counts next to each rate", () => { + // v1.15.8 — two identical percentages with no context read as a stuck + // display; the count after each rate makes them distinguishable. A + // rolling weekly med reading 100% on both windows now shows + // `100% · 4 / 4` and `100% · 52 / 52`. + const html = render( + , + ); + expect(html).toContain("4 / 4"); + expect(html).toContain("52 / 52"); + // The middle-dot separator joins the percentage and the count. + expect(html).toContain("·"); + }); + + it("compliance bars fall back to the bare expected count when no taken count is on the wire", () => { + const html = render( + , + ); + expect(html).toContain("12 doses"); + expect(html).toContain("52 doses"); + }); + + it("compliance bars render no count line when neither count is available", () => { + // Older mocks / the pre-display fallback path pass only the rates. + const html = render( + , + ); + expect(html).not.toContain("·"); + expect(html).not.toContain("doses"); + }); + it("status pill stamps the success token + take-now glyph in window", () => { const html = render( ["t"]; +type Formatters = ReturnType; + +/** + * v1.15.8 — build the dose-count caption shown after the percentage. + * + * Returns `taken / expected` (`4 / 12`) when both counts are known, the + * pluralised ` doses` string when only the denominator is on the + * wire, and `null` when neither is available (older mocks / pre-display + * fallback) so the caller renders nothing rather than an empty separator. + */ +function doseCount( + t: Translate, + fmt: Formatters, + taken: number | undefined, + expected: number | undefined, +): string | null { + if (typeof expected !== "number") return null; + if (typeof taken === "number") { + return `${fmt.number(taken)} / ${fmt.number(expected)}`; + } + return t("medications.complianceDoses", { count: expected }); +} + interface MedicationComplianceBarsProps { rate7: number; rate30: number; @@ -17,6 +41,20 @@ interface MedicationComplianceBarsProps { shortDays?: number; /** v1.8.6 — the span of the long row in days. */ longDays?: number; + /** + * v1.15.8 — taken-dose count over the short window (numerator). Rendered + * after the percentage as a `taken / expected` count so two identical + * percentages stay distinguishable: a rolling weekly med reading 100% on + * every window now shows `100% · 4 / 4` vs `100% · 52 / 52` instead of two + * bare identical numbers that read as a stuck display. + */ + takenShort?: number; + /** v1.15.8 — expected-dose count over the short window (denominator). */ + expectedShort?: number; + /** v1.15.8 — taken-dose count over the long window (numerator). */ + takenLong?: number; + /** v1.15.8 — expected-dose count over the long window (denominator). */ + expectedLong?: number; } /** @@ -41,6 +79,10 @@ export function MedicationComplianceBars({ streak, shortDays = 7, longDays = 30, + takenShort, + expectedShort, + takenLong, + expectedLong, }: MedicationComplianceBarsProps) { const { t } = useTranslations(); const fmt = useFormatters(); @@ -54,12 +96,27 @@ export function MedicationComplianceBars({ const shortPct = fmt.number(Math.round(rate7)); const longPct = fmt.number(Math.round(rate30)); + // v1.15.8 — the dose-count line rendered after each percentage. Shows the + // taken-of-expected count when both are known (`4 / 12`), else the bare + // expected count when only the denominator is on the wire (`12 Dosen`). + // Two windows reading the same percentage stay distinguishable by their + // counts, which is what an operator needs to trust the number. + const shortCount = doseCount(t, fmt, takenShort, expectedShort); + const longCount = doseCount(t, fmt, takenLong, expectedLong); + return (
{shortLabel} - {shortPct}% + + {shortPct}% + {shortCount && ( + + · {shortCount} + + )} +
{/* aria-label so the bar has an accessible name. */} @@ -68,7 +125,14 @@ export function MedicationComplianceBars({
{longLabel} - {longPct}% + + {longPct}% + {longCount && ( + + · {longCount} + + )} +
diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 3f3a85607..157231c45 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -211,6 +211,14 @@ export function MedicationCard({ const rate7 = display?.short.rate ?? compliance?.compliance7?.rate ?? 0; const rate30 = display?.long.rate ?? compliance?.compliance30?.rate ?? 0; const streak = display?.short.streak ?? compliance?.compliance7?.streak ?? 0; + // v1.15.8 — taken-of-expected counts, shown after each percentage so two + // identical rates stay distinguishable. Falls back to the static 7-day + // `taken` when the cadence-scaled display block is absent. + const takenShort = display?.short.taken ?? compliance?.compliance7?.taken; + const expectedShort = + display?.expectedShort ?? compliance?.compliance7?.totalExpected; + const takenLong = display?.long.taken; + const expectedLong = display?.expectedLong; const categoryLabel = getMedicationCategoryLabel(medication.category, t); const sortedSchedules = [...medication.schedules].sort( (a, b) => @@ -398,6 +406,10 @@ export function MedicationCard({ streak={streak} shortDays={shortDays} longDays={longDays} + takenShort={takenShort} + expectedShort={expectedShort} + takenLong={takenLong} + expectedLong={expectedLong} /> ) : ( diff --git a/src/lib/analytics/__tests__/compliance.test.ts b/src/lib/analytics/__tests__/compliance.test.ts index 7e190da28..35b22ef4b 100644 --- a/src/lib/analytics/__tests__/compliance.test.ts +++ b/src/lib/analytics/__tests__/compliance.test.ts @@ -1653,6 +1653,118 @@ describe("buildComplianceDisplay — two rows, cadence-scaled windows", () => { expect(display.shortDays).toBe(90); expect(display.longDays).toBe(365); }); + + // v1.15.8 — the card renders the taken-of-expected count next to each rate + // so two identical percentages stay distinguishable. These pin the count + // numerators onto the display block and guard against a future regression + // where every window collapses to the same rate AND the same count (which + // would read as a stuck display rather than a real, trustworthy number). + it("carries a taken-dose count on each window row", () => { + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rrule: "FREQ=DAILY", + timesOfDay: ["08:00"], + }, + ]; + // Daily med, perfect adherence over the trailing 30 days. + const events = []; + for (let d = 1; d <= 30; d++) { + const at = new Date(NOW.getTime() - d * DAY_MS); + at.setUTCHours(8, 10, 0, 0); + events.push({ scheduledFor: at, takenAt: at, skipped: false }); + } + const display = buildComplianceDisplay(events, schedules, ctx(), { + now: NOW, + }); + // Both rows ~100% — but the COUNTS differ (7-day window has fewer taken + // doses than the 30-day window), so the two rows are distinguishable even + // when the percentages are identical. + expect(display.short.taken).toBeGreaterThan(0); + expect(display.long.taken).toBeGreaterThan(0); + expect(display.long.taken).toBeGreaterThan(display.short.taken); + // The taken count never exceeds its window's expected denominator. + expect(display.short.taken).toBeLessThanOrEqual(display.expectedShort); + expect(display.long.taken).toBeLessThanOrEqual(display.expectedLong); + }); + + it("a daily med with misses concentrated outside the 7-day window → 7d ≠ 30d rate", () => { + // Guards the "looks broken" report from the other direction: the windows + // must NOT collapse to one value. A daily med taken every day inside the + // last 7 days but with a cluster of misses in the 8–30-day range yields a + // perfect short window and a degraded long window, so the two rows + // genuinely differ. + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rrule: "FREQ=DAILY", + timesOfDay: ["08:00"], + }, + ]; + const events = []; + for (let d = 1; d <= 30; d++) { + // Days 1–7: every dose taken. Days 10–20: missed (no event row). + if (d >= 10 && d <= 20) continue; + const at = new Date(NOW.getTime() - d * DAY_MS); + at.setUTCHours(8, 10, 0, 0); + events.push({ scheduledFor: at, takenAt: at, skipped: false }); + } + const display = buildComplianceDisplay(events, schedules, ctx(), { + now: NOW, + }); + // Daily med stays on the 7 / 30-day rung. + expect(display.shortDays).toBe(7); + expect(display.longDays).toBe(30); + // The recent week is clean; the month carries the 10–20-day miss cluster. + // The windows MUST diverge — the short window outscores the long one, + // which is the whole point of showing two rows rather than one number. + expect(display.short.rate).toBeGreaterThan(display.long.rate); + expect(display.long.rate).toBeLessThan(100); + expect(display.short.rate).not.toBe(display.long.rate); + }); + + it("a rolling weekly med with a partial-adherence history → short and long rates CAN differ", () => { + // The operator's "every window is 100%" suspicion is real ONLY for a + // faithfully-on-cadence med; once doses are missed the windows MUST be + // able to diverge. A weekly rolling injection with recent perfect weeks + // but missed shots further back drives the short rate above the long rate + // — proving the metric does not structurally collapse to one value. + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rollingIntervalDays: 7, + timesOfDay: ["08:00"], + }, + ]; + const lastShot = new Date(NOW.getTime() - 3 * DAY_MS); + // 16 weekly slots back; drop a cluster of older shots (weeks 8–12) so the + // long window scores a real miss while the recent weeks stay perfect. + const events = Array.from({ length: 16 }, (_, i) => i) + .filter((i) => i < 6 || i > 11) + .map((i) => { + const at = new Date(lastShot.getTime() - i * 7 * DAY_MS); + at.setUTCHours(20, 0, 0, 0); + return { scheduledFor: at, takenAt: at, skipped: false }; + }); + const display = buildComplianceDisplay( + events, + schedules, + ctx({ + startsOn: new Date(lastShot.getTime() - 15 * 7 * DAY_MS), + lastIntakeAt: lastShot, + }), + { now: NOW }, + ); + // The windows are NOT forced equal: the recent short window outscores the + // longer one that carries the dropped cluster. + expect(display.short.rate).toBeGreaterThan(display.long.rate); + }); }); describe("expectedSlotsBetween", () => { diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index 97aad3372..b49779f2d 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -636,10 +636,15 @@ export interface ComplianceDisplay { expectedLong: number; /** Echo of the density floor so a client can re-derive the rung. */ minStableDoses: number; - /** Compliance percentage + day-streak over the short window. */ - short: { rate: number; streak: number }; - /** Compliance percentage over the long window. */ - long: { rate: number }; + /** + * Compliance percentage + taken-dose count + day-streak over the short + * window. `taken` is the count of doses taken in the window (the numerator + * the card renders next to the rate so two identical percentages stay + * distinguishable and trustworthy). + */ + short: { rate: number; taken: number; streak: number }; + /** Compliance percentage + taken-dose count over the long window. */ + long: { rate: number; taken: number }; /** * v1.13.x Fix 4 — the open-cycle state, decoupled from the percentage * rows so a between-doses sparse med never renders a scary red number. @@ -785,8 +790,8 @@ export function buildComplianceDisplay( expectedShort, expectedLong, minStableDoses: MIN_STABLE_DOSES, - short: { rate: short.rate, streak: short.streak }, - long: { rate: long.rate }, + short: { rate: short.rate, taken: short.taken, streak: short.streak }, + long: { rate: long.rate, taken: long.taken }, currentCycle, }; } diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index fae3fca3b..f33c09e93 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -1273,9 +1273,13 @@ const complianceDisplay = z minStableDoses: z.number().int().nonnegative(), short: z.object({ rate: z.number().int().min(0).max(100), + taken: z.number().int().nonnegative(), streak: z.number().int().nonnegative(), }), - long: z.object({ rate: z.number().int().min(0).max(100) }), + long: z.object({ + rate: z.number().int().min(0).max(100), + taken: z.number().int().nonnegative(), + }), currentCycle: z .object({ state: z From 60901ff4fa8cd4b2bb16cc62813e77d45e615063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 7 Jun 2026 01:07:19 +0200 Subject: [PATCH 3/3] =?UTF-8?q?chore(release):=20v1.15.8=20=E2=80=94=20med?= =?UTF-8?q?ication=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71f7d466a..b6c1583d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +## [1.15.8] — 2026-06-07 — medication cards + +### Changed + +- **Consistent medication cards.** Both medication card styles now use the same structure and the same labels for the same thing — "Next intake" and "Last intake" everywhere — so the two cards read the same way. The GLP-1 card keeps its relative-day hint ("Saturday 13 Jul (in 7 days)"). +- **Compliance bars show the counts.** Each compliance bar now shows the doses behind the percentage (for example "100% · 12 / 12"), so two windows that genuinely land at the same percentage are legible at a glance instead of looking like a glitch. + ## [1.15.7] — 2026-06-07 — data import in the browser ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 69bf8b718..513615cee 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.15.7 + version: 1.15.8 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 2713351c5..e98c2d64a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.15.7", + "version": "1.15.8", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev",