mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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.
This commit is contained in:
1 parent
a3410efb5c
commit
61c35740fc
7 files changed
+261
-9
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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