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 (
);
}
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}
/>
) : (
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