mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge pull request #284 from MBombeck/release/v1.15.8
release/v1.15.8 — medication cards
This commit is contained in:
18 files changed
+330
-85
No files matched your search
@@ -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
|
||||
|
||||
+11
-1
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-2
@@ -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)",
|
||||
|
||||
+1
-2
@@ -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)",
|
||||
|
||||
+1
-2
@@ -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)",
|
||||
|
||||
+1
-2
@@ -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)",
|
||||
|
||||
+1
-2
@@ -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)",
|
||||
|
||||
+1
-2
@@ -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)",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
<MedicationComplianceBars
|
||||
rate7={100}
|
||||
rate30={100}
|
||||
streak={3}
|
||||
takenShort={4}
|
||||
expectedShort={4}
|
||||
takenLong={52}
|
||||
expectedLong={52}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<MedicationComplianceBars
|
||||
rate7={80}
|
||||
rate30={75}
|
||||
streak={0}
|
||||
expectedShort={12}
|
||||
expectedLong={52}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<MedicationComplianceBars rate7={90} rate30={88} streak={0} />,
|
||||
);
|
||||
expect(html).not.toContain("·");
|
||||
expect(html).not.toContain("doses");
|
||||
});
|
||||
|
||||
it("status pill stamps the success token + take-now glyph in window", () => {
|
||||
const html = render(
|
||||
<MedicationStatusPill
|
||||
|
||||
@@ -320,14 +320,16 @@ describe("<Glp1MedicationCard> — 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("<Glp1MedicationCard> — 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");
|
||||
|
||||
@@ -3,6 +3,30 @@ import { Flame } from "lucide-react";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
|
||||
type Translate = ReturnType<typeof useTranslations>["t"];
|
||||
type Formatters = ReturnType<typeof useFormatters>;
|
||||
|
||||
/**
|
||||
* v1.15.8 — build the dose-count caption shown after the percentage.
|
||||
*
|
||||
* Returns `taken / expected` (`4 / 12`) when both counts are known, the
|
||||
* pluralised `<expected> 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 (
|
||||
<div className="space-y-2.5">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{shortLabel}</span>
|
||||
<span className="font-medium">{shortPct}%</span>
|
||||
<span className="font-medium">
|
||||
{shortPct}%
|
||||
{shortCount && (
|
||||
<span className="text-muted-foreground ml-1.5 font-normal">
|
||||
· {shortCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{/* aria-label so the bar has an accessible name. */}
|
||||
<Progress value={rate7} className="h-2" aria-label={shortLabel} />
|
||||
@@ -68,7 +125,14 @@ export function MedicationComplianceBars({
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{longLabel}</span>
|
||||
<span className="font-medium">{longPct}%</span>
|
||||
<span className="font-medium">
|
||||
{longPct}%
|
||||
{longCount && (
|
||||
<span className="text-muted-foreground ml-1.5 font-normal">
|
||||
· {longCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={rate30} className="h-2" aria-label={longLabel} />
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-h-[2.75rem] space-y-3.5 text-sm">
|
||||
{next &&
|
||||
(labelled ? (
|
||||
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
|
||||
<span className="min-w-0 flex-shrink truncate font-medium">
|
||||
{nextLabel ?? t("medications.nextIntake")}
|
||||
</span>
|
||||
<span className="text-right">{next}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">{next}</p>
|
||||
))}
|
||||
{last &&
|
||||
(labelled ? (
|
||||
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
|
||||
<span className="min-w-0 flex-shrink truncate font-medium">
|
||||
{lastLabel ?? t("medications.lastIntake")}
|
||||
</span>
|
||||
<span className="text-right">{last}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">{last}</p>
|
||||
))}
|
||||
{next && (
|
||||
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
|
||||
<span className="min-w-0 flex-shrink truncate font-medium">
|
||||
{t("medications.nextIntake")}
|
||||
</span>
|
||||
<span className="text-right">{next}</span>
|
||||
</div>
|
||||
)}
|
||||
{last && (
|
||||
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
|
||||
<span className="min-w-0 flex-shrink truncate font-medium">
|
||||
{t("medications.lastIntake")}
|
||||
</span>
|
||||
<span className="text-right">{last}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
<MedicationNextLastSlot> 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. */}
|
||||
<MedicationNextLastSlot
|
||||
nextLabel={t("medications.glp1NextLabel")}
|
||||
lastLabel={t("medications.glp1LastLabel")}
|
||||
next={
|
||||
next && currentWindowStatus.status !== "in_window" ? (
|
||||
<>
|
||||
@@ -462,6 +469,10 @@ export function Glp1MedicationCard({
|
||||
streak={streak}
|
||||
shortDays={shortDays}
|
||||
longDays={longDays}
|
||||
takenShort={takenShort}
|
||||
expectedShort={expectedShort}
|
||||
takenLong={takenLong}
|
||||
expectedLong={expectedLong}
|
||||
/>
|
||||
) : (
|
||||
<MedicationComplianceSkeleton />
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
<MedicationComplianceSkeleton />
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user