Merge pull request #284 from MBombeck/release/v1.15.8

release/v1.15.8 — medication cards
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-07 01:13:30 +02:00
commit 4420f81c8b
18 files changed
+330 -85

No files matched your search

+7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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", () => {
+11 -6
View File
@@ -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,
};
}
+5 -1
View File
@@ -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