mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(medications): restore the two-row compliance display, scaled to cadence
v1.8.5 swapped the long-standing 7-/30-day compliance rows for a per-dose adherence timeline on sparse-cadence medications. The timeline breaks the card layout and reads as restless, so every medication is back to two percentage rows. The two windows are no longer hardcoded — they scale with the dosing cadence. selectComplianceWindows walks a 7/30 -> 30/90 -> 90/365 ladder and returns the densest rung whose both windows clear MIN_STABLE_DOSES realised expected doses, so a daily med stays on 7/30, a weekly med steps up to 30/90, and a rolling 35-day injection lands on the widest 90/365 rung. When even the top rung can't clear the floor (a brand-new or very rare med) the widest rung is returned so the card still shows two honest rows. The row labels follow the chosen windows via a parametrised complianceWindow string. The compliance percentage math is unchanged: each row is the existing cadence-aware calculateCompliance over the selected window. compliance7 / compliance30 stay on the wire verbatim for iOS and the Health Score. - compliance.ts: replace the timeline display with selectComplianceWindows + the window ladder; buildComplianceDisplay now returns the two scaled rows - remove the dose-adherence-timeline component and its now-dead strings - medication-compliance-bars: parametrise the row labels on the day-counts - switch the standard and GLP-1 cards back to the always-two-row bars
This commit is contained in:
1 parent
6c44d4a2c1
commit
e5b873833a
15 files changed
+376
-627
No files matched your search
+1
-7
@@ -586,16 +586,10 @@
|
||||
"veryOverdue": "Stark überfällig",
|
||||
"compliance7d": "7-Tage-Compliance",
|
||||
"compliance30d": "30-Tage-Compliance",
|
||||
"complianceWindow": "{days}-Tage-Compliance",
|
||||
"lastIntake": "Letzte Einnahme:",
|
||||
"nextIntake": "Nächste Einnahme:",
|
||||
"dayStreak": "Tage Serie",
|
||||
"lastNDoses": "Letzte {total} Dosen: {taken} von {total}",
|
||||
"doseStreak": "{count} in Folge",
|
||||
"doseTimelineAriaLabel": "Jüngste Einnahmetreue",
|
||||
"doseTaken": "Genommen",
|
||||
"doseMissed": "Verpasst",
|
||||
"doseSkipped": "Übersprungen",
|
||||
"doseUpcoming": "Anstehend",
|
||||
"taken": "Genommen",
|
||||
"skipped": "Übersprungen",
|
||||
"intakeToastTaken": "{name} als genommen erfasst",
|
||||
|
||||
+1
-7
@@ -586,16 +586,10 @@
|
||||
"veryOverdue": "Very overdue",
|
||||
"compliance7d": "7-day compliance",
|
||||
"compliance30d": "30-day compliance",
|
||||
"complianceWindow": "{days}-day compliance",
|
||||
"lastIntake": "Last intake:",
|
||||
"nextIntake": "Next intake:",
|
||||
"dayStreak": "day streak",
|
||||
"lastNDoses": "Last {total} doses: {taken} of {total}",
|
||||
"doseStreak": "{count} in a row",
|
||||
"doseTimelineAriaLabel": "Recent dose adherence",
|
||||
"doseTaken": "Taken",
|
||||
"doseMissed": "Missed",
|
||||
"doseSkipped": "Skipped",
|
||||
"doseUpcoming": "Upcoming",
|
||||
"taken": "Taken",
|
||||
"skipped": "Skipped",
|
||||
"intakeToastTaken": "{name} recorded as taken",
|
||||
|
||||
+1
-7
@@ -586,16 +586,10 @@
|
||||
"veryOverdue": "Muy vencida",
|
||||
"compliance7d": "Cumplimiento 7 días",
|
||||
"compliance30d": "Cumplimiento 30 días",
|
||||
"complianceWindow": "Cumplimiento {days} días",
|
||||
"lastIntake": "Última toma:",
|
||||
"nextIntake": "Próxima toma:",
|
||||
"dayStreak": "días de racha",
|
||||
"lastNDoses": "Últimas {total} dosis: {taken} de {total}",
|
||||
"doseStreak": "{count} seguidas",
|
||||
"doseTimelineAriaLabel": "Adherencia reciente a las dosis",
|
||||
"doseTaken": "Tomada",
|
||||
"doseMissed": "Omitida",
|
||||
"doseSkipped": "Saltada",
|
||||
"doseUpcoming": "Próxima",
|
||||
"taken": "Tomada",
|
||||
"skipped": "Omitida",
|
||||
"intakeToastTaken": "{name} registrada como tomada",
|
||||
|
||||
+1
-7
@@ -586,16 +586,10 @@
|
||||
"veryOverdue": "Très en retard",
|
||||
"compliance7d": "Observance 7 jours",
|
||||
"compliance30d": "Observance 30 jours",
|
||||
"complianceWindow": "Observance {days} jours",
|
||||
"lastIntake": "Dernière prise :",
|
||||
"nextIntake": "Prochaine prise :",
|
||||
"dayStreak": "jours de série",
|
||||
"lastNDoses": "{total} dernières doses : {taken} sur {total}",
|
||||
"doseStreak": "{count} d'affilée",
|
||||
"doseTimelineAriaLabel": "Observance récente des doses",
|
||||
"doseTaken": "Pris",
|
||||
"doseMissed": "Manqué",
|
||||
"doseSkipped": "Ignoré",
|
||||
"doseUpcoming": "À venir",
|
||||
"taken": "Pris",
|
||||
"skipped": "Sauté",
|
||||
"intakeToastTaken": "{name} enregistré comme pris",
|
||||
|
||||
+1
-7
@@ -586,16 +586,10 @@
|
||||
"veryOverdue": "Molto in ritardo",
|
||||
"compliance7d": "Aderenza 7 giorni",
|
||||
"compliance30d": "Aderenza 30 giorni",
|
||||
"complianceWindow": "Aderenza {days} giorni",
|
||||
"lastIntake": "Ultima assunzione:",
|
||||
"nextIntake": "Prossima assunzione:",
|
||||
"dayStreak": "giorni di serie",
|
||||
"lastNDoses": "Ultime {total} dosi: {taken} su {total}",
|
||||
"doseStreak": "{count} di fila",
|
||||
"doseTimelineAriaLabel": "Aderenza recente alle dosi",
|
||||
"doseTaken": "Assunta",
|
||||
"doseMissed": "Mancata",
|
||||
"doseSkipped": "Saltata",
|
||||
"doseUpcoming": "In arrivo",
|
||||
"taken": "Assunta",
|
||||
"skipped": "Saltata",
|
||||
"intakeToastTaken": "{name} registrata come assunta",
|
||||
|
||||
+1
-7
@@ -586,16 +586,10 @@
|
||||
"veryOverdue": "Mocno po terminie",
|
||||
"compliance7d": "Przestrzeganie 7 dni",
|
||||
"compliance30d": "Przestrzeganie 30 dni",
|
||||
"complianceWindow": "Przestrzeganie {days} dni",
|
||||
"lastIntake": "Ostatnie przyjęcie:",
|
||||
"nextIntake": "Następne przyjęcie:",
|
||||
"dayStreak": "dni serii",
|
||||
"lastNDoses": "Ostatnie {total} dawek: {taken} z {total}",
|
||||
"doseStreak": "{count} z rzędu",
|
||||
"doseTimelineAriaLabel": "Ostatnie przestrzeganie dawek",
|
||||
"doseTaken": "Przyjęte",
|
||||
"doseMissed": "Pominięte",
|
||||
"doseSkipped": "Opuszczone",
|
||||
"doseUpcoming": "Nadchodzące",
|
||||
"taken": "Przyjęte",
|
||||
"skipped": "Pominięte",
|
||||
"intakeToastTaken": "{name} oznaczone jako przyjęte",
|
||||
|
||||
@@ -247,13 +247,12 @@ describe("GET /api/medications/[id]/compliance — per-slot timing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// v1.8.5 — the additive `complianceDisplay` block. The server decides the
|
||||
// render mode from expected-dose density: a daily med keeps the percentage
|
||||
// bars (`"percent"`), a sparse rolling/long-interval med swaps to the
|
||||
// per-dose timeline (`"timeline"`). The existing `compliance7` /
|
||||
// `compliance30` fields stay untouched.
|
||||
// v1.8.6 — the `complianceDisplay` block is always two percentage rows; the
|
||||
// server scales the two windows to the dosing cadence. A daily med keeps
|
||||
// 7 / 30 days; a sparse rolling med steps both windows up to the widest rung.
|
||||
// The existing `compliance7` / `compliance30` fields stay untouched.
|
||||
describe("GET /api/medications/[id]/compliance — complianceDisplay", () => {
|
||||
it("daily med → mode: percent", async () => {
|
||||
it("daily med → 7 / 30-day windows", async () => {
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
medication([
|
||||
{
|
||||
@@ -277,24 +276,32 @@ describe("GET /api/medications/[id]/compliance — complianceDisplay", () => {
|
||||
|
||||
const data = await callRouteData();
|
||||
const display = data.complianceDisplay as {
|
||||
mode: string;
|
||||
expected30: number;
|
||||
shortDays: number;
|
||||
longDays: number;
|
||||
expectedLong: number;
|
||||
minStableDoses: number;
|
||||
short: { rate: number; streak: number };
|
||||
long: { rate: number };
|
||||
};
|
||||
expect(display.mode).toBe("percent");
|
||||
expect(display.expected30).toBeGreaterThanOrEqual(display.minStableDoses);
|
||||
expect(display.shortDays).toBe(7);
|
||||
expect(display.longDays).toBe(30);
|
||||
expect(display.expectedLong).toBeGreaterThanOrEqual(display.minStableDoses);
|
||||
expect(display.short).toBeDefined();
|
||||
expect(display.long).toBeDefined();
|
||||
// No timeline field — the display is two rows only.
|
||||
expect(display).not.toHaveProperty("mode");
|
||||
// The legacy fields stay on the wire.
|
||||
expect(data.compliance7).toBeDefined();
|
||||
expect(data.compliance30).toBeDefined();
|
||||
});
|
||||
|
||||
it("35-day-interval med → mode: timeline", async () => {
|
||||
it("35-day-interval med → steps the windows up past 7 / 30 days", async () => {
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
{
|
||||
id: "med-1",
|
||||
userId: "user-1",
|
||||
createdAt: new Date(Date.now() - 200 * 24 * 60 * 60 * 1000),
|
||||
startsOn: new Date(Date.now() - 25 * 24 * 60 * 60 * 1000),
|
||||
createdAt: new Date(Date.now() - 400 * 24 * 60 * 60 * 1000),
|
||||
startsOn: new Date(Date.now() - 380 * 24 * 60 * 60 * 1000),
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
schedules: [
|
||||
@@ -314,8 +321,6 @@ describe("GET /api/medications/[id]/compliance — complianceDisplay", () => {
|
||||
],
|
||||
} as never,
|
||||
);
|
||||
// One past intake re-anchors the rolling cadence ~25 days ago, so the
|
||||
// next slot lands beyond the 30-day window → <4 expected → timeline.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
{
|
||||
takenAt: new Date(Date.now() - 25 * 24 * 60 * 60 * 1000),
|
||||
@@ -326,11 +331,12 @@ describe("GET /api/medications/[id]/compliance — complianceDisplay", () => {
|
||||
|
||||
const data = await callRouteData();
|
||||
const display = data.complianceDisplay as {
|
||||
mode: string;
|
||||
expected30: number;
|
||||
minStableDoses: number;
|
||||
shortDays: number;
|
||||
longDays: number;
|
||||
};
|
||||
expect(display.mode).toBe("timeline");
|
||||
expect(display.expected30).toBeLessThan(display.minStableDoses);
|
||||
// A 35-day cadence can't clear four expected doses in a 30-day window, so
|
||||
// the ladder steps the short window beyond 7 days and the long beyond 30.
|
||||
expect(display.shortDays).toBeGreaterThan(7);
|
||||
expect(display.longDays).toBeGreaterThan(30);
|
||||
});
|
||||
});
|
||||
@@ -142,12 +142,12 @@ export const GET = apiHandler(
|
||||
{ medicationContext },
|
||||
);
|
||||
|
||||
// v1.8.5 — the additive display block. `mode` is server-decided from
|
||||
// expected-dose density: dense cadences keep the 7-/30-day percentage
|
||||
// bars (`"percent"`), sparse cadences (weekly, bi-/tri-weekly, rolling
|
||||
// 35-day injections) swap to the per-dose uptime strip (`"timeline"`).
|
||||
// `compliance7` / `compliance30` above are untouched — iOS + the Health
|
||||
// Score read them verbatim.
|
||||
// v1.8.6 — the two-row compliance display. The card always shows two
|
||||
// percentage rows; the server scales the two windows to the dosing
|
||||
// cadence (dense meds keep 7 / 30 days, sparse meds step both windows
|
||||
// up) and computes each row's rate over the chosen span. `compliance7`
|
||||
// / `compliance30` above are untouched — iOS + the Health Score read
|
||||
// them verbatim.
|
||||
const complianceDisplay = buildComplianceDisplay(
|
||||
mapped,
|
||||
medication.schedules,
|
||||
@@ -289,7 +289,8 @@ export const GET = apiHandler(
|
||||
meta: {
|
||||
compliance7: compliance7.rate,
|
||||
compliance30: compliance30.rate,
|
||||
displayMode: complianceDisplay.mode,
|
||||
complianceShortDays: complianceDisplay.shortDays,
|
||||
complianceLongDays: complianceDisplay.longDays,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -56,6 +56,23 @@ describe("medication card-parts — shared presentational components", () => {
|
||||
expect(html).not.toContain("text-orange-400");
|
||||
});
|
||||
|
||||
it("compliance bars scale the row labels to the chosen windows", () => {
|
||||
// v1.8.6 — a sparse cadence steps both windows up; the labels follow.
|
||||
const html = render(
|
||||
<MedicationComplianceBars
|
||||
rate7={75}
|
||||
rate30={80}
|
||||
streak={0}
|
||||
shortDays={90}
|
||||
longDays={365}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("90-day compliance");
|
||||
expect(html).toContain("365-day compliance");
|
||||
expect(html).not.toContain("7-day compliance");
|
||||
expect(html).not.toContain("30-day compliance");
|
||||
});
|
||||
|
||||
it("compliance bars hide the streak flame when streak is zero", () => {
|
||||
const html = render(
|
||||
<MedicationComplianceBars rate7={90} rate30={88} streak={0} />,
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Flame } from "lucide-react";
|
||||
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.8.5 — uptime-style per-dose adherence strip for sparse cadences.
|
||||
*
|
||||
* The medication cards keep the 7-/30-day percentage bars for dense
|
||||
* cadences (daily / multi-daily / weekday meds) where the denominator is
|
||||
* large enough for the percentage to be stable. For sparse cadences
|
||||
* (weekly, bi-/tri-weekly, rolling 35-day injections) a rolling percentage
|
||||
* swings violently off one sample and tells the user nothing; the server
|
||||
* flips `complianceDisplay.mode` to `"timeline"` and this strip takes over.
|
||||
*
|
||||
* Each cell is one EXPECTED dose (not a calendar day), oldest → newest:
|
||||
* taken=green / missed=red / skipped=grey / upcoming=outline. The color
|
||||
* tokens and the tap-to-pin tooltip mirror the 90-day compliance heatmap
|
||||
* (`src/components/charts/compliance-heatmap.tsx`) so the two surfaces read
|
||||
* the same. Plain flex divs, not Recharts — a fixed-size status-cell row
|
||||
* needs no chart container, axis, or tooltip plumbing.
|
||||
*/
|
||||
|
||||
export type DoseStatus = "taken" | "missed" | "skipped" | "upcoming";
|
||||
export type DoseTiming = "early" | "on_time" | "late" | "very_late" | "missed";
|
||||
|
||||
export interface DoseAdherenceCell {
|
||||
scheduledFor: string;
|
||||
status: DoseStatus;
|
||||
takenAt: string | null;
|
||||
timing: DoseTiming | null;
|
||||
site: string | null;
|
||||
}
|
||||
|
||||
interface DoseAdherenceTimelineProps {
|
||||
doses: DoseAdherenceCell[];
|
||||
summary: { taken: number; total: number; doseStreak: number };
|
||||
}
|
||||
|
||||
// v1.4.27 MB7 / CF-10 precedent — clear the 14 px touch floor (with the
|
||||
// gap the row stays tap-friendly on narrow viewports). Mirrors the
|
||||
// heatmap's `CELL_FLOOR_PX`.
|
||||
const CELL_PX = 14;
|
||||
|
||||
/** Token mirror of the heatmap's status palette. */
|
||||
function cellColor(status: DoseStatus, timing: DoseTiming | null): string {
|
||||
if (status === "upcoming") return "transparent";
|
||||
if (status === "skipped") return "var(--secondary)";
|
||||
if (status === "missed") return "var(--dracula-red)";
|
||||
// taken — shade by punctuality, same ladder the heatmap uses.
|
||||
if (timing === "very_late") return "var(--dracula-orange)";
|
||||
if (timing === "late") return "var(--dracula-yellow)";
|
||||
return "var(--dracula-green)";
|
||||
}
|
||||
|
||||
export function DoseAdherenceTimeline({
|
||||
doses,
|
||||
summary,
|
||||
}: DoseAdherenceTimelineProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [tooltip, setTooltip] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
pinned?: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// Mirror the heatmap: an outside tap clears a pinned tooltip so a touch
|
||||
// user can dismiss the per-cell detail. Gated on `pinned` so the hover
|
||||
// flow never pays the indirection.
|
||||
useEffect(() => {
|
||||
if (!tooltip?.pinned) return;
|
||||
const handlePointer = (event: PointerEvent) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
if (!container.contains(event.target as Node)) setTooltip(null);
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointer, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointer, true);
|
||||
};
|
||||
}, [tooltip?.pinned]);
|
||||
|
||||
function statusLabel(status: DoseStatus): string {
|
||||
if (status === "taken") return t("medications.doseTaken");
|
||||
if (status === "missed") return t("medications.doseMissed");
|
||||
if (status === "skipped") return t("medications.doseSkipped");
|
||||
return t("medications.doseUpcoming");
|
||||
}
|
||||
|
||||
function cellText(cell: DoseAdherenceCell): string {
|
||||
const when = fmt.dateTime(cell.scheduledFor);
|
||||
return `${when} — ${statusLabel(cell.status)}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2" ref={containerRef}>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="flex flex-wrap gap-1"
|
||||
role="img"
|
||||
aria-label={t("medications.doseTimelineAriaLabel")}
|
||||
>
|
||||
{doses.map((cell, i) => {
|
||||
const isUpcoming = cell.status === "upcoming";
|
||||
return (
|
||||
<div
|
||||
key={`${cell.scheduledFor}-${i}`}
|
||||
className={`shrink-0 cursor-pointer rounded-sm ${
|
||||
isUpcoming ? "border-border border border-dashed" : ""
|
||||
}`}
|
||||
style={{
|
||||
width: CELL_PX,
|
||||
height: CELL_PX,
|
||||
backgroundColor: cellColor(cell.status, cell.timing),
|
||||
}}
|
||||
onPointerEnter={(e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
setTooltip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
text: cellText(cell),
|
||||
});
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
setTooltip((prev) => (prev?.pinned ? prev : null));
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.pointerType !== "touch") return;
|
||||
setTooltip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
text: cellText(cell),
|
||||
pinned: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{tooltip && (
|
||||
<div
|
||||
className="bg-popover text-popover-foreground border-border pointer-events-none fixed z-50 rounded-md border px-2 py-1 text-xs shadow-md"
|
||||
style={{ left: tooltip.x + 10, top: tooltip.y - 30 }}
|
||||
>
|
||||
{tooltip.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground flex items-center gap-3 text-xs">
|
||||
<span>
|
||||
{t("medications.lastNDoses", {
|
||||
total: summary.total,
|
||||
taken: summary.taken,
|
||||
})}
|
||||
</span>
|
||||
{summary.doseStreak > 0 && (
|
||||
<span className="text-dracula-orange flex items-center gap-1 font-medium">
|
||||
<Flame className="h-3.5 w-3.5" />
|
||||
{t("medications.doseStreak", { count: summary.doseStreak })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,13 +7,28 @@ interface MedicationComplianceBarsProps {
|
||||
rate7: number;
|
||||
rate30: number;
|
||||
streak: number;
|
||||
/**
|
||||
* v1.8.6 — the span of the short row in days. The server scales the two
|
||||
* windows to the dosing cadence (7 / 30 for dense meds, stepping up to
|
||||
* 90 / 365 for sparse ones), so the labels follow the chosen windows
|
||||
* instead of a hardcoded 7 / 30. Defaults to 7 / 30 so older callers and
|
||||
* fixtures keep their prior labels.
|
||||
*/
|
||||
shortDays?: number;
|
||||
/** v1.8.6 — the span of the long row in days. */
|
||||
longDays?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared 7-day / 30-day compliance bars plus the day-streak flame for the
|
||||
* Shared two-row compliance bars plus the day-streak flame for the
|
||||
* medication cards. Extracted from the generic and GLP-1 cards so the bars
|
||||
* are structurally identical rather than hand-synced.
|
||||
*
|
||||
* v1.8.6 — the two windows scale with the dosing cadence. A daily med shows
|
||||
* 7-day / 30-day; a weekly med 30-day / 90-day; a rare injection up to a
|
||||
* 365-day long window. The labels are parametrised on the chosen day-counts
|
||||
* so each row names the window it actually covers.
|
||||
*
|
||||
* The streak flame uses the canonical `text-dracula-orange` warning/streak
|
||||
* token (globals.css). The generic card historically drifted onto Tailwind
|
||||
* stock `text-orange-400`; unifying here closes that token gap.
|
||||
@@ -22,38 +37,31 @@ export function MedicationComplianceBars({
|
||||
rate7,
|
||||
rate30,
|
||||
streak,
|
||||
shortDays = 7,
|
||||
longDays = 30,
|
||||
}: MedicationComplianceBarsProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const shortLabel = t("medications.complianceWindow", { days: shortDays });
|
||||
const longLabel = t("medications.complianceWindow", { days: longDays });
|
||||
|
||||
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">
|
||||
{t("medications.compliance7d")}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{shortLabel}</span>
|
||||
<span className="font-medium">{rate7}%</span>
|
||||
</div>
|
||||
{/* aria-label so the bar has an accessible name. */}
|
||||
<Progress
|
||||
value={rate7}
|
||||
className="h-2"
|
||||
aria-label={t("medications.compliance7d")}
|
||||
/>
|
||||
<Progress value={rate7} className="h-2" aria-label={shortLabel} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t("medications.compliance30d")}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{longLabel}</span>
|
||||
<span className="font-medium">{rate30}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={rate30}
|
||||
className="h-2"
|
||||
aria-label={t("medications.compliance30d")}
|
||||
/>
|
||||
<Progress value={rate30} className="h-2" aria-label={longLabel} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
|
||||
@@ -10,10 +10,6 @@ import { MedicationCardMenu } from "@/components/medications/medication-card-men
|
||||
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
|
||||
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
|
||||
import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars";
|
||||
import {
|
||||
DoseAdherenceTimeline,
|
||||
type DoseAdherenceCell,
|
||||
} from "@/components/medications/card-parts/dose-adherence-timeline";
|
||||
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
@@ -104,21 +100,24 @@ export interface Glp1Medication {
|
||||
}
|
||||
|
||||
interface ComplianceDisplay {
|
||||
mode: "percent" | "timeline";
|
||||
expected7: number;
|
||||
expected30: number;
|
||||
shortDays: number;
|
||||
longDays: number;
|
||||
expectedShort: number;
|
||||
expectedLong: number;
|
||||
minStableDoses: number;
|
||||
doseTimeline: DoseAdherenceCell[];
|
||||
recentDoseSummary: { taken: number; total: number; doseStreak: number };
|
||||
short: { rate: number; streak: number };
|
||||
long: { rate: number };
|
||||
}
|
||||
|
||||
interface ComplianceData {
|
||||
compliance7: { rate: number; streak: number };
|
||||
compliance30: { rate: number };
|
||||
/**
|
||||
* v1.8.5 — server-decided render mode. GLP-1 injections are commonly
|
||||
* weekly, so this card flips to the per-dose timeline more often than
|
||||
* the generic one. Additive — older mocks omit it.
|
||||
* v1.8.6 — the two compliance windows scaled to the dosing cadence. GLP-1
|
||||
* injections are commonly weekly, so this card's windows step up to the
|
||||
* 30-/90-day rung where the bars stay meaningful. Additive — older mocks
|
||||
* omit it, in which case the card falls back to the static 7-/30-day
|
||||
* fields.
|
||||
*/
|
||||
complianceDisplay?: ComplianceDisplay;
|
||||
}
|
||||
@@ -309,9 +308,15 @@ export function Glp1MedicationCard({
|
||||
const schedule = medication.schedules[0] ?? null;
|
||||
const now = new Date();
|
||||
const next = nextInjectionFromServer(medication.nextDueAt, now);
|
||||
const rate7 = compliance?.compliance7?.rate ?? 0;
|
||||
const rate30 = compliance?.compliance30?.rate ?? 0;
|
||||
const streak = compliance?.compliance7?.streak ?? 0;
|
||||
// v1.8.6 — the two compliance windows scale with the dosing cadence. When
|
||||
// the server supplies `complianceDisplay` the card reads its cadence-scaled
|
||||
// rows; otherwise it falls back to the static 7-/30-day fields.
|
||||
const display = compliance?.complianceDisplay;
|
||||
const shortDays = display?.shortDays ?? 7;
|
||||
const longDays = display?.longDays ?? 30;
|
||||
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.4.37 W4b — symmetric take-now / overdue pill with the generic
|
||||
// card. Sort schedules by `windowStart` so the most-actionable
|
||||
@@ -480,24 +485,19 @@ export function Glp1MedicationCard({
|
||||
Glp1InventoryDTO slot on /api/medications/[id]/glp1 stays
|
||||
in the response shape; only the web mounts are gone. */}
|
||||
|
||||
{/* Compliance region — shared decision with the generic card.
|
||||
Dense cadences keep the bars; sparse cadences (most weekly
|
||||
GLP-1 injections) swap to the per-dose uptime strip. Falls back
|
||||
to the bars when the older payload omits `complianceDisplay`. */}
|
||||
{medication.active &&
|
||||
compliance &&
|
||||
(compliance.complianceDisplay?.mode === "timeline" ? (
|
||||
<DoseAdherenceTimeline
|
||||
doses={compliance.complianceDisplay.doseTimeline}
|
||||
summary={compliance.complianceDisplay.recentDoseSummary}
|
||||
/>
|
||||
) : (
|
||||
<MedicationComplianceBars
|
||||
rate7={rate7}
|
||||
rate30={rate30}
|
||||
streak={streak}
|
||||
/>
|
||||
))}
|
||||
{/* Compliance bars — always two rows, shared with the generic
|
||||
card so the page grid stays harmonious. The server scales the
|
||||
two windows to the dosing cadence; most weekly GLP-1 injections
|
||||
land on the 30-/90-day rung where the bars stay meaningful. */}
|
||||
{medication.active && compliance && (
|
||||
<MedicationComplianceBars
|
||||
rate7={rate7}
|
||||
rate30={rate30}
|
||||
streak={streak}
|
||||
shortDays={shortDays}
|
||||
longDays={longDays}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Primary actions row — shared with the generic medication
|
||||
card. The GLP-1-specific side-effect quick-log lives in the
|
||||
|
||||
@@ -9,10 +9,6 @@ import { MedicationCardMenu } from "@/components/medications/medication-card-men
|
||||
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
|
||||
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
|
||||
import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars";
|
||||
import {
|
||||
DoseAdherenceTimeline,
|
||||
type DoseAdherenceCell,
|
||||
} from "@/components/medications/card-parts/dose-adherence-timeline";
|
||||
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
|
||||
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
||||
import { formatDateTime, formatTime } from "@/lib/format";
|
||||
@@ -76,12 +72,13 @@ interface Medication {
|
||||
}
|
||||
|
||||
interface ComplianceDisplay {
|
||||
mode: "percent" | "timeline";
|
||||
expected7: number;
|
||||
expected30: number;
|
||||
shortDays: number;
|
||||
longDays: number;
|
||||
expectedShort: number;
|
||||
expectedLong: number;
|
||||
minStableDoses: number;
|
||||
doseTimeline: DoseAdherenceCell[];
|
||||
recentDoseSummary: { taken: number; total: number; doseStreak: number };
|
||||
short: { rate: number; streak: number };
|
||||
long: { rate: number };
|
||||
}
|
||||
|
||||
interface ComplianceData {
|
||||
@@ -97,9 +94,11 @@ interface ComplianceData {
|
||||
rate: number;
|
||||
};
|
||||
/**
|
||||
* v1.8.5 — server-decided render mode for the compliance region. Dense
|
||||
* cadences keep the 7-/30-day bars (`"percent"`); sparse cadences swap to
|
||||
* the per-dose uptime strip (`"timeline"`). Additive — older mocks omit it.
|
||||
* v1.8.6 — the two compliance windows scaled to the dosing cadence. The
|
||||
* card always shows two percentage rows; `shortDays` / `longDays` name the
|
||||
* windows and `short` / `long` carry their rates. Additive — older mocks
|
||||
* omit it, in which case the card falls back to the static 7-/30-day
|
||||
* `compliance7` / `compliance30` fields.
|
||||
*/
|
||||
complianceDisplay?: ComplianceDisplay;
|
||||
}
|
||||
@@ -222,9 +221,15 @@ export function MedicationCard({
|
||||
}
|
||||
}
|
||||
|
||||
const rate7 = compliance?.compliance7?.rate ?? 0;
|
||||
const rate30 = compliance?.compliance30?.rate ?? 0;
|
||||
const streak = compliance?.compliance7?.streak ?? 0;
|
||||
// v1.8.6 — the two compliance windows scale with the dosing cadence. When
|
||||
// the server supplies `complianceDisplay` the card reads its cadence-scaled
|
||||
// rows; otherwise it falls back to the static 7-/30-day fields.
|
||||
const display = compliance?.complianceDisplay;
|
||||
const shortDays = display?.shortDays ?? 7;
|
||||
const longDays = display?.longDays ?? 30;
|
||||
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;
|
||||
const categoryLabel = getMedicationCategoryLabel(medication.category, t);
|
||||
const sortedSchedules = [...medication.schedules].sort(
|
||||
(a, b) =>
|
||||
@@ -391,24 +396,19 @@ export function MedicationCard({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Compliance region — the server decides whether the percentage
|
||||
bars stay (dense cadences) or the per-dose uptime strip takes
|
||||
over (sparse cadences). Falls back to the bars when the older
|
||||
payload omits `complianceDisplay`. */}
|
||||
{medication.active &&
|
||||
compliance &&
|
||||
(compliance.complianceDisplay?.mode === "timeline" ? (
|
||||
<DoseAdherenceTimeline
|
||||
doses={compliance.complianceDisplay.doseTimeline}
|
||||
summary={compliance.complianceDisplay.recentDoseSummary}
|
||||
/>
|
||||
) : (
|
||||
<MedicationComplianceBars
|
||||
rate7={rate7}
|
||||
rate30={rate30}
|
||||
streak={streak}
|
||||
/>
|
||||
))}
|
||||
{/* Compliance bars — always two rows. The server scales the two
|
||||
windows to the dosing cadence (7 / 30 days for dense meds,
|
||||
stepping up to 90 / 365 for sparse ones); the labels follow the
|
||||
chosen windows. */}
|
||||
{medication.active && compliance && (
|
||||
<MedicationComplianceBars
|
||||
rate7={rate7}
|
||||
rate30={rate30}
|
||||
streak={streak}
|
||||
shortDays={shortDays}
|
||||
longDays={longDays}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Quick actions — primary buttons of the medication card. */}
|
||||
{medication.active && (
|
||||
|
||||
@@ -1027,7 +1027,13 @@ describe("classifyIntakeTiming", () => {
|
||||
// `buildComplianceDisplay` decides whether the card keeps the 7-/30-day
|
||||
// percentage bars (dense cadences) or swaps to a per-dose timeline
|
||||
// (sparse cadences). The 30-day window is the density yardstick.
|
||||
describe("buildComplianceDisplay — density rule + dose timeline", () => {
|
||||
// v1.8.6 — the compliance display is always two percentage rows; only the
|
||||
// window day-counts scale with the dosing cadence. `selectComplianceWindows`
|
||||
// walks a `[7,30] → [30,90] → [90,365]` ladder and returns the densest rung
|
||||
// whose BOTH windows clear `MIN_STABLE_DOSES` realised expected doses, so a
|
||||
// daily med stays on 7 / 30, a weekly med steps up, and a rare injection
|
||||
// lands on the widest rung. The timeline rendering is gone.
|
||||
describe("buildComplianceDisplay — two rows, cadence-scaled windows", () => {
|
||||
const NOW = new Date("2025-06-15T12:00:00Z");
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -1054,17 +1060,25 @@ describe("buildComplianceDisplay — density rule + dose timeline", () => {
|
||||
);
|
||||
}
|
||||
|
||||
it("echoes the threshold so a client can re-derive the mode", () => {
|
||||
it("echoes the density floor so a client can re-derive the rung", () => {
|
||||
const display = buildComplianceDisplay(
|
||||
[],
|
||||
[{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null, rrule: "FREQ=DAILY", timesOfDay: ["08:00"] }],
|
||||
[
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
timesOfDay: ["08:00"],
|
||||
},
|
||||
],
|
||||
ctx(),
|
||||
{ now: NOW },
|
||||
);
|
||||
expect(display.minStableDoses).toBe(MIN_STABLE_DOSES);
|
||||
});
|
||||
|
||||
it("daily med → percent mode (30-day window clears the 4-dose floor)", () => {
|
||||
it("daily med → 7 / 30-day windows (the densest rung clears the floor)", () => {
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
@@ -1075,13 +1089,37 @@ describe("buildComplianceDisplay — density rule + dose timeline", () => {
|
||||
},
|
||||
];
|
||||
const display = buildComplianceDisplay([], schedules, ctx(), { now: NOW });
|
||||
expect(display.mode).toBe("percent");
|
||||
expect(display.expected30).toBeGreaterThanOrEqual(MIN_STABLE_DOSES);
|
||||
expect(display.shortDays).toBe(7);
|
||||
expect(display.longDays).toBe(30);
|
||||
// Both windows hold ≥ 4 expected doses.
|
||||
expect(display.expectedShort).toBeGreaterThanOrEqual(MIN_STABLE_DOSES);
|
||||
expect(display.expectedLong).toBeGreaterThanOrEqual(MIN_STABLE_DOSES);
|
||||
});
|
||||
|
||||
it("35-day-interval med → timeline mode (sparse: <4 doses in 30 days)", () => {
|
||||
// A monthly injection: one dose every 5 weeks. Anchored on a recent
|
||||
// start so the engine emits at most one slot inside the 30-day window.
|
||||
it("weekly med → steps up to 30 / 90-day windows", () => {
|
||||
// Weekly Mondays-only: the 7-day window holds at most one Monday (< 4),
|
||||
// so the ladder steps up. The 30-day window holds ~4 Mondays and the
|
||||
// 90-day window ~13 — the [30, 90] rung is the densest that clears the
|
||||
// floor on both rows.
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
rrule: "FREQ=WEEKLY;BYDAY=MO",
|
||||
timesOfDay: ["08:00"],
|
||||
},
|
||||
];
|
||||
const display = buildComplianceDisplay([], schedules, ctx(), { now: NOW });
|
||||
expect(display.shortDays).toBe(30);
|
||||
expect(display.longDays).toBe(90);
|
||||
expect(display.expectedShort).toBeGreaterThanOrEqual(MIN_STABLE_DOSES);
|
||||
expect(display.expectedLong).toBeGreaterThanOrEqual(MIN_STABLE_DOSES);
|
||||
});
|
||||
|
||||
it("35-day rolling injection → widest 90 / 365-day windows", () => {
|
||||
// One dose every five weeks: the 30-day window never holds ≥ 4, so the
|
||||
// ladder climbs to the top rung where a full year holds ~10 doses.
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{
|
||||
windowStart: "10:00",
|
||||
@@ -1095,129 +1133,79 @@ describe("buildComplianceDisplay — density rule + dose timeline", () => {
|
||||
[],
|
||||
schedules,
|
||||
ctx({
|
||||
startsOn: new Date("2025-05-01T00:00:00Z"),
|
||||
// Rolling cadences re-anchor on the last non-skipped intake.
|
||||
startsOn: new Date("2024-06-01T00:00:00Z"),
|
||||
lastIntakeAt: new Date("2025-05-20T10:00:00Z"),
|
||||
}),
|
||||
{ now: NOW },
|
||||
);
|
||||
expect(display.mode).toBe("timeline");
|
||||
expect(display.expected30).toBeLessThan(MIN_STABLE_DOSES);
|
||||
expect(display.shortDays).toBe(90);
|
||||
expect(display.longDays).toBe(365);
|
||||
});
|
||||
|
||||
it("weekly med → timeline mode (≥4 needs ~28 days; 30d holds ~4 but <4 when phased)", () => {
|
||||
// Weekly Mondays-only: across a 30-day trailing window this yields ~4
|
||||
// Mondays. With a start mid-window the realised count drops below 4 →
|
||||
// timeline. Pin the realised count rather than asserting a fixed mode
|
||||
// independent of phase.
|
||||
it("a brand-new sparse med still returns two rows on the widest rung", () => {
|
||||
// Started two days ago: no rung clears the floor. The selection falls
|
||||
// back to the widest rung so the card still shows two honest rows.
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
rrule: "FREQ=WEEKLY;BYDAY=MO",
|
||||
rollingIntervalDays: 35,
|
||||
timesOfDay: ["08:00"],
|
||||
},
|
||||
];
|
||||
const display = buildComplianceDisplay(
|
||||
[],
|
||||
schedules,
|
||||
ctx({ startsOn: new Date("2025-05-26T00:00:00Z") }),
|
||||
ctx({ startsOn: new Date("2025-06-13T00:00:00Z") }),
|
||||
{ now: NOW },
|
||||
);
|
||||
// 2025-05-26 .. 2025-06-15 → Mondays 5/26, 6/2, 6/9 → 3 expected < 4.
|
||||
expect(display.expected30).toBeLessThan(MIN_STABLE_DOSES);
|
||||
expect(display.mode).toBe("timeline");
|
||||
expect(display.shortDays).toBe(90);
|
||||
expect(display.longDays).toBe(365);
|
||||
});
|
||||
|
||||
it("builds taken / missed / skipped cells from the paired engine timeline", () => {
|
||||
// Weekly Mondays, three past Mondays inside the window: one taken on
|
||||
// time, one skipped, one missed (no intake). The strip mirrors that.
|
||||
it("the row rates match calculateCompliance over the chosen windows", () => {
|
||||
// Daily med, perfect adherence over the trailing two weeks → 100% both
|
||||
// rows. Pins that the display rate equals the cadence-aware calculator.
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
rrule: "FREQ=WEEKLY;BYDAY=MO",
|
||||
rrule: "FREQ=DAILY",
|
||||
timesOfDay: ["08:00"],
|
||||
},
|
||||
];
|
||||
const events = [
|
||||
// 2025-06-09 Monday — taken on time.
|
||||
{
|
||||
scheduledFor: new Date("2025-06-09T08:00:00Z"),
|
||||
takenAt: new Date("2025-06-09T08:10:00Z"),
|
||||
skipped: false,
|
||||
},
|
||||
// 2025-06-02 Monday — skipped.
|
||||
{
|
||||
scheduledFor: new Date("2025-06-02T08:00:00Z"),
|
||||
takenAt: null,
|
||||
skipped: true,
|
||||
},
|
||||
// 2025-05-26 Monday — no event → missed.
|
||||
];
|
||||
const display = buildComplianceDisplay(
|
||||
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 context = ctx();
|
||||
const display = buildComplianceDisplay(events, schedules, context, {
|
||||
now: NOW,
|
||||
});
|
||||
const short = calculateCompliance(
|
||||
events,
|
||||
schedules,
|
||||
ctx({ startsOn: new Date("2025-05-26T00:00:00Z") }),
|
||||
{ now: NOW },
|
||||
display.shortDays,
|
||||
context.createdAt,
|
||||
{ now: NOW, medicationContext: context },
|
||||
);
|
||||
const past = display.doseTimeline.filter((c) => c.status !== "upcoming");
|
||||
const statuses = past.map((c) => c.status);
|
||||
expect(statuses).toContain("taken");
|
||||
expect(statuses).toContain("skipped");
|
||||
expect(statuses).toContain("missed");
|
||||
|
||||
const takenCell = past.find((c) => c.status === "taken")!;
|
||||
expect(takenCell.takenAt).not.toBeNull();
|
||||
expect(takenCell.timing).toBe("on_time");
|
||||
|
||||
const skippedCell = past.find((c) => c.status === "skipped")!;
|
||||
expect(skippedCell.takenAt).toBeNull();
|
||||
expect(skippedCell.timing).toBeNull();
|
||||
|
||||
// Summary: taken counts in numerator + denominator; skipped excluded
|
||||
// from both; missed in denominator only.
|
||||
expect(display.recentDoseSummary.taken).toBe(1);
|
||||
expect(display.recentDoseSummary.total).toBe(2); // taken + missed
|
||||
});
|
||||
|
||||
it("dose-streak: counts consecutive taken-or-skipped back from the newest, breaks on a miss", () => {
|
||||
// Weekly Mondays: 5/26 missed, 6/2 taken, 6/9 taken → newest-back
|
||||
// streak = 2 (stops at the 5/26 miss).
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
rrule: "FREQ=WEEKLY;BYDAY=MO",
|
||||
timesOfDay: ["08:00"],
|
||||
},
|
||||
];
|
||||
const events = [
|
||||
{
|
||||
scheduledFor: new Date("2025-06-09T08:00:00Z"),
|
||||
takenAt: new Date("2025-06-09T08:05:00Z"),
|
||||
skipped: false,
|
||||
},
|
||||
{
|
||||
scheduledFor: new Date("2025-06-02T08:00:00Z"),
|
||||
takenAt: new Date("2025-06-02T08:05:00Z"),
|
||||
skipped: false,
|
||||
},
|
||||
// 5/26 missed.
|
||||
];
|
||||
const display = buildComplianceDisplay(
|
||||
const long = calculateCompliance(
|
||||
events,
|
||||
schedules,
|
||||
ctx({ startsOn: new Date("2025-05-26T00:00:00Z") }),
|
||||
{ now: NOW },
|
||||
display.longDays,
|
||||
context.createdAt,
|
||||
{ now: NOW, medicationContext: context },
|
||||
);
|
||||
expect(display.recentDoseSummary.doseStreak).toBe(2);
|
||||
expect(display.short.rate).toBe(short.rate);
|
||||
expect(display.long.rate).toBe(long.rate);
|
||||
expect(display.short.streak).toBe(short.streak);
|
||||
});
|
||||
|
||||
it("caps the past strip at 12 cells for a dense daily med", () => {
|
||||
it("never reports a timeline mode field — the display is two rows only", () => {
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
@@ -1228,8 +1216,10 @@ describe("buildComplianceDisplay — density rule + dose timeline", () => {
|
||||
},
|
||||
];
|
||||
const display = buildComplianceDisplay([], schedules, ctx(), { now: NOW });
|
||||
const past = display.doseTimeline.filter((c) => c.status !== "upcoming");
|
||||
expect(past.length).toBeLessThanOrEqual(12);
|
||||
expect(display).not.toHaveProperty("mode");
|
||||
expect(display).not.toHaveProperty("doseTimeline");
|
||||
expect(display.short).toBeDefined();
|
||||
expect(display.long).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+141
-205
@@ -23,7 +23,6 @@ import {
|
||||
buildCadenceTimeline,
|
||||
type CadenceEngineContext,
|
||||
type IntakeEventLike,
|
||||
type PairedDose,
|
||||
type ScheduleLike,
|
||||
} from "@/lib/medications/scheduling/cadence";
|
||||
import {
|
||||
@@ -33,7 +32,6 @@ import {
|
||||
type RecurrenceContext,
|
||||
type ScheduleType,
|
||||
} from "@/lib/medications/scheduling/recurrence";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
|
||||
interface IntakeEvent {
|
||||
takenAt: Date | null;
|
||||
@@ -365,122 +363,142 @@ export function expectedSlotsBetween(
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.8.5 — render mode for the per-medication compliance display. A
|
||||
* window's percentage is only meaningful once the denominator is large
|
||||
* enough that a single miss moves the rate by a small amount; below that
|
||||
* the bar swings violently and tells the user nothing. So:
|
||||
*
|
||||
* - `"percent"`: keep the 7-/30-day percentage bars (dense cadences:
|
||||
* daily, multi-daily, weekday meds). The window holds ≥
|
||||
* `MIN_STABLE_DOSES` expected doses.
|
||||
* - `"timeline"`: swap to an uptime-style per-dose strip (sparse cadences:
|
||||
* weekly, bi-/tri-weekly, rolling 35-day injections) where each cell is
|
||||
* one expected dose, taken=green / missed=red / skipped=grey.
|
||||
*/
|
||||
export type ComplianceDisplayMode = "percent" | "timeline";
|
||||
|
||||
/**
|
||||
* v1.8.5 — one cell of the dose-adherence timeline. A projection of the
|
||||
* canonical engine's expected-dose slots paired with the recorded intake,
|
||||
* oldest → newest. `site` is reserved for injection meds so the strip can
|
||||
* dual-encode rotation; it is `null` until the per-dose injection-site
|
||||
* write path lands.
|
||||
*/
|
||||
export interface DoseTimelineCell {
|
||||
/** ISO instant of the expected dose slot. */
|
||||
scheduledFor: string;
|
||||
status: "taken" | "missed" | "skipped" | "upcoming";
|
||||
/** ISO instant the dose was logged, or null when not taken. */
|
||||
takenAt: string | null;
|
||||
/** Punctuality bucket for a taken dose; null when not taken. */
|
||||
timing: IntakeTimingClass | null;
|
||||
/** Injection zone for injection meds; null otherwise / when unrecorded. */
|
||||
site: InjectionSiteKey | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.8.5 — the additive compliance-display block returned alongside the
|
||||
* existing `compliance7` / `compliance30` fields (which iOS + the Health
|
||||
* Score still read verbatim). The server decides `mode` from the density
|
||||
* rule so the client stays dumb.
|
||||
*/
|
||||
export interface ComplianceDisplay {
|
||||
mode: ComplianceDisplayMode;
|
||||
/** Realised expected dose count over the 7-day window. */
|
||||
expected7: number;
|
||||
/** Realised expected dose count over the 30-day window. */
|
||||
expected30: number;
|
||||
/** Echo of the density threshold so a client can re-derive the mode. */
|
||||
minStableDoses: number;
|
||||
/** Uptime strip — last N expected dose slots, oldest → newest. */
|
||||
doseTimeline: DoseTimelineCell[];
|
||||
/** Compact summary for the "last N doses" line. */
|
||||
recentDoseSummary: { taken: number; total: number; doseStreak: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.8.5 — the floor at which a window's percentage is stable. Four
|
||||
* v1.8.6 — the floor at which a window's percentage is stable. Four
|
||||
* expected doses is the point where a single miss moves the rate by ≤25%
|
||||
* rather than ±50–100%. A daily med clears it in 4 days; a weekly med
|
||||
* needs ~28 days; a tri-weekly / 35-day-rolling med never clears it in a
|
||||
* 30-day window — which is exactly the signal to show the timeline.
|
||||
* rather than ±50–100%. A daily med clears it in a 7-day window; a weekly
|
||||
* med needs ~30 days; a tri-weekly / 35-day-rolling med needs a quarter or
|
||||
* more. The window ladder below steps up until both rows clear this floor.
|
||||
*/
|
||||
export const MIN_STABLE_DOSES = 4;
|
||||
|
||||
/** Number of past dose cells the card strip carries. */
|
||||
const TIMELINE_PAST_DOSES = 12;
|
||||
/** Number of upcoming dose cells appended so the strip shows what's next. */
|
||||
const TIMELINE_UPCOMING_DOSES = 2;
|
||||
/**
|
||||
* v1.8.6 — the rung ladder for the two compliance windows. Each rung is a
|
||||
* `[short, long]` pair of day-counts. The card always shows two percentage
|
||||
* rows; the only thing that scales with cadence is which rung the windows
|
||||
* sit on. Dense meds (daily / weekday) sit on `[7, 30]`; as the expected
|
||||
* dose frequency drops, both windows step up so each row still spans enough
|
||||
* expected doses to mean something, up to a 12-month long window for very
|
||||
* rare meds.
|
||||
*/
|
||||
export const COMPLIANCE_WINDOW_LADDER: ReadonlyArray<readonly [number, number]> =
|
||||
[
|
||||
[7, 30],
|
||||
[30, 90],
|
||||
[90, 365],
|
||||
];
|
||||
|
||||
/** A single paired dose, distilled into a timeline cell. */
|
||||
function toTimelineCell(dose: PairedDose): DoseTimelineCell {
|
||||
const takenAt = dose.match?.takenAt ?? null;
|
||||
return {
|
||||
scheduledFor: dose.windowStart.toISOString(),
|
||||
status: dose.status,
|
||||
takenAt: takenAt ? takenAt.toISOString() : null,
|
||||
// Punctuality is computed against the slot window the engine already
|
||||
// emitted. `windowStart` / `windowEnd` are full instants here; reuse
|
||||
// the HH:mm classifier by formatting them back to wall-clock on the
|
||||
// slot's own day so an early / late dose reads correctly.
|
||||
timing:
|
||||
dose.status === "taken" && takenAt
|
||||
? classifyIntakeTiming(
|
||||
takenAt,
|
||||
hhmmOf(dose.windowStart),
|
||||
hhmmOf(dose.windowEnd),
|
||||
dose.windowStart,
|
||||
)
|
||||
: null,
|
||||
// Injection-site dual-encoding is reserved for the per-dose write
|
||||
// path; the compliance read does not yet carry it.
|
||||
site: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Format a Date's UTC wall-clock as "HH:mm" for the punctuality classifier. */
|
||||
function hhmmOf(d: Date): string {
|
||||
const h = String(d.getUTCHours()).padStart(2, "0");
|
||||
const m = String(d.getUTCMinutes()).padStart(2, "0");
|
||||
return `${h}:${m}`;
|
||||
/**
|
||||
* v1.8.6 — the day-counts of the two compliance windows a medication's
|
||||
* cadence resolves to, plus the realised expected-dose count each window
|
||||
* holds. `shortDays` / `longDays` drive the row labels; the expected counts
|
||||
* are surfaced so a client can show the denominator or re-derive the rung.
|
||||
*/
|
||||
export interface ComplianceWindowSelection {
|
||||
shortDays: number;
|
||||
longDays: number;
|
||||
expectedShort: number;
|
||||
expectedLong: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.8.5 — compute the additive {@link ComplianceDisplay} block.
|
||||
* v1.8.6 — pick the two compliance windows for a medication from its
|
||||
* dosing cadence.
|
||||
*
|
||||
* The mode is decided purely by density: when the 30-day window holds at
|
||||
* least {@link MIN_STABLE_DOSES} expected doses the percentage bars stay
|
||||
* (`"percent"`); otherwise the per-dose timeline takes over (`"timeline"`).
|
||||
* The 30-day window is the honest denominator — a weekly med clears the
|
||||
* floor there, a sparse injection never does.
|
||||
* Walks {@link COMPLIANCE_WINDOW_LADDER} from densest to sparsest and
|
||||
* returns the first rung whose BOTH windows clear {@link MIN_STABLE_DOSES}
|
||||
* realised expected doses. A daily med clears `[7, 30]` immediately; a
|
||||
* weekly med fails the 7-day row (one dose) and lands on `[30, 90]`; a
|
||||
* 35-day-rolling injection needs the top rung `[90, 365]`. When even the
|
||||
* top rung can't clear the floor (a brand-new prescription, a very rare
|
||||
* med) the top rung is returned anyway so the card still shows two honest
|
||||
* percentage rows over the widest windows available.
|
||||
*
|
||||
* The timeline itself is a projection of {@link buildCadenceTimeline}: the
|
||||
* engine already pairs each expected slot to its closest intake, so we
|
||||
* take the last {@link TIMELINE_PAST_DOSES} past slots plus a couple of
|
||||
* upcoming ones and distil each into a cell. `doseStreak` counts
|
||||
* consecutive taken-or-skipped doses back from the newest non-upcoming
|
||||
* dose — dose-indexed, unlike the day-indexed `streak` on
|
||||
* {@link ComplianceResult} which inflates on off-cadence days.
|
||||
* The expected count routes through {@link expectedSlotsBetween} (the
|
||||
* canonical recurrence engine), so PRN / off-cadence / pre-creation days
|
||||
* never inflate the denominator.
|
||||
*/
|
||||
export function selectComplianceWindows(
|
||||
schedules: ComplianceSchedule[],
|
||||
ctx: ComplianceMedicationContext,
|
||||
options?: { now?: Date },
|
||||
): ComplianceWindowSelection {
|
||||
const now = options?.now ?? new Date();
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const expectedOver = (days: number): number =>
|
||||
expectedSlotsBetween(
|
||||
schedules,
|
||||
new Date(now.getTime() - days * DAY_MS),
|
||||
now,
|
||||
ctx,
|
||||
).length;
|
||||
|
||||
// Memoise per distinct window so a shared rung boundary (e.g. 30 / 90)
|
||||
// isn't re-walked across rungs.
|
||||
const cache = new Map<number, number>();
|
||||
const expected = (days: number): number => {
|
||||
const hit = cache.get(days);
|
||||
if (hit !== undefined) return hit;
|
||||
const v = expectedOver(days);
|
||||
cache.set(days, v);
|
||||
return v;
|
||||
};
|
||||
|
||||
for (const [shortDays, longDays] of COMPLIANCE_WINDOW_LADDER) {
|
||||
const expectedShort = expected(shortDays);
|
||||
const expectedLong = expected(longDays);
|
||||
if (
|
||||
expectedShort >= MIN_STABLE_DOSES &&
|
||||
expectedLong >= MIN_STABLE_DOSES
|
||||
) {
|
||||
return { shortDays, longDays, expectedShort, expectedLong };
|
||||
}
|
||||
}
|
||||
|
||||
// No rung cleared the floor — fall back to the widest rung so both rows
|
||||
// still render over the most data the cadence affords.
|
||||
const [shortDays, longDays] =
|
||||
COMPLIANCE_WINDOW_LADDER[COMPLIANCE_WINDOW_LADDER.length - 1];
|
||||
return {
|
||||
shortDays,
|
||||
longDays,
|
||||
expectedShort: expected(shortDays),
|
||||
expectedLong: expected(longDays),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.8.6 — the compliance-display block returned alongside the existing
|
||||
* `compliance7` / `compliance30` fields (which iOS + the Health Score read
|
||||
* verbatim). The card always renders two percentage rows; the server picks
|
||||
* the two windows from the medication's cadence and computes each row's
|
||||
* rate over the chosen span. A dense med keeps `7` / `30`; a sparse med
|
||||
* steps both windows up so each row covers enough expected doses to be
|
||||
* meaningful.
|
||||
*/
|
||||
export interface ComplianceDisplay {
|
||||
shortDays: number;
|
||||
longDays: number;
|
||||
/** Realised expected dose count over the short window. */
|
||||
expectedShort: number;
|
||||
/** Realised expected dose count over the long window. */
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.8.6 — compute the two-row {@link ComplianceDisplay} block.
|
||||
*
|
||||
* The card always shows two percentage rows. {@link selectComplianceWindows}
|
||||
* decides which windows they span from the medication's cadence, then each
|
||||
* row's rate is the cadence-aware {@link calculateCompliance} over that
|
||||
* window. The short row also carries the day-streak. The compliance math is
|
||||
* unchanged from the legacy 7-/30-day path — only the window day-counts move.
|
||||
*/
|
||||
export function buildComplianceDisplay(
|
||||
events: IntakeEvent[],
|
||||
@@ -489,108 +507,26 @@ export function buildComplianceDisplay(
|
||||
options?: { now?: Date },
|
||||
): ComplianceDisplay {
|
||||
const now = options?.now ?? new Date();
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const { shortDays, longDays, expectedShort, expectedLong } =
|
||||
selectComplianceWindows(schedules, ctx, { now });
|
||||
|
||||
// Realised expected counts over each window. `expectedSlotsBetween`
|
||||
// already excludes PRN / off-cadence days through the canonical engine.
|
||||
const from7 = new Date(now.getTime() - 7 * DAY_MS);
|
||||
const from30 = new Date(now.getTime() - 30 * DAY_MS);
|
||||
const expected7 = expectedSlotsBetween(schedules, from7, now, ctx).length;
|
||||
const expected30 = expectedSlotsBetween(schedules, from30, now, ctx).length;
|
||||
|
||||
const mode: ComplianceDisplayMode =
|
||||
expected30 >= MIN_STABLE_DOSES ? "percent" : "timeline";
|
||||
|
||||
// Build the per-dose strip from the same engine the cadence chart uses.
|
||||
// Pull a generous past window (90 days) so even a 35-day-rolling med
|
||||
// surfaces a few cells, then keep the last N plus a short look-ahead.
|
||||
const engineCtx: CadenceEngineContext = {
|
||||
startsOn: ctx.startsOn,
|
||||
endsOn: ctx.endsOn,
|
||||
oneShot: ctx.oneShot,
|
||||
createdAt: ctx.createdAt,
|
||||
lastIntakeAt: ctx.lastIntakeAt,
|
||||
timeZone: ctx.timeZone,
|
||||
};
|
||||
const normalisedSchedules: ScheduleLike[] = schedules.map((s, i) => ({
|
||||
id: `display-${i}`,
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
daysOfWeek: s.daysOfWeek ?? null,
|
||||
rrule: s.rrule ?? null,
|
||||
rollingIntervalDays: s.rollingIntervalDays ?? null,
|
||||
timesOfDay: s.timesOfDay,
|
||||
reminderGraceMinutes: s.reminderGraceMinutes ?? null,
|
||||
scheduleType: s.scheduleType ?? null,
|
||||
cyclicOnWeeks: s.cyclicOnWeeks ?? null,
|
||||
cyclicOffWeeks: s.cyclicOffWeeks ?? null,
|
||||
}));
|
||||
const normalisedEvents: IntakeEventLike[] = events.map((e) => ({
|
||||
scheduledFor: e.scheduledFor,
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
}));
|
||||
|
||||
// Past window for the strip — bounded so a daily med doesn't return
|
||||
// hundreds of cells. `buildCadenceTimeline` anchors on `now - windowDays`
|
||||
// and projects forward; a small look-ahead is added below.
|
||||
const STRIP_PAST_DAYS = 90;
|
||||
const past = buildCadenceTimeline(
|
||||
normalisedSchedules,
|
||||
normalisedEvents,
|
||||
const short = calculateCompliance(events, schedules, shortDays, ctx.createdAt, {
|
||||
now,
|
||||
STRIP_PAST_DAYS,
|
||||
ctx.createdAt,
|
||||
engineCtx.timeZone,
|
||||
engineCtx,
|
||||
)
|
||||
.filter((d) => d.status !== "upcoming")
|
||||
.sort((a, b) => a.windowStart.getTime() - b.windowStart.getTime());
|
||||
|
||||
// Upcoming slots — expand a short forward window and keep the soonest.
|
||||
const lookahead = new Date(now.getTime() + 60 * DAY_MS);
|
||||
const upcoming = buildCadenceTimeline(
|
||||
normalisedSchedules,
|
||||
normalisedEvents,
|
||||
lookahead,
|
||||
60,
|
||||
ctx.createdAt,
|
||||
engineCtx.timeZone,
|
||||
engineCtx,
|
||||
)
|
||||
.filter((d) => d.status === "upcoming" && d.windowStart.getTime() > now.getTime())
|
||||
.sort((a, b) => a.windowStart.getTime() - b.windowStart.getTime())
|
||||
.slice(0, TIMELINE_UPCOMING_DOSES);
|
||||
|
||||
const recentPast = past.slice(-TIMELINE_PAST_DOSES);
|
||||
const doseTimeline = [...recentPast, ...upcoming].map(toTimelineCell);
|
||||
|
||||
// Summary over the past cells the strip shows.
|
||||
const summaryTaken = recentPast.filter((d) => d.status === "taken").length;
|
||||
const summaryTotal = recentPast.filter(
|
||||
(d) => d.status === "taken" || d.status === "missed",
|
||||
).length;
|
||||
|
||||
// Dose-streak: consecutive taken-or-skipped doses, newest → oldest, from
|
||||
// the newest non-upcoming dose. A miss breaks it; a skip does not.
|
||||
let doseStreak = 0;
|
||||
for (let i = recentPast.length - 1; i >= 0; i--) {
|
||||
const s = recentPast[i].status;
|
||||
if (s === "missed") break;
|
||||
if (s === "taken" || s === "skipped") doseStreak++;
|
||||
}
|
||||
medicationContext: ctx,
|
||||
});
|
||||
const long = calculateCompliance(events, schedules, longDays, ctx.createdAt, {
|
||||
now,
|
||||
medicationContext: ctx,
|
||||
});
|
||||
|
||||
return {
|
||||
mode,
|
||||
expected7,
|
||||
expected30,
|
||||
shortDays,
|
||||
longDays,
|
||||
expectedShort,
|
||||
expectedLong,
|
||||
minStableDoses: MIN_STABLE_DOSES,
|
||||
doseTimeline,
|
||||
recentDoseSummary: {
|
||||
taken: summaryTaken,
|
||||
total: summaryTotal,
|
||||
doseStreak,
|
||||
},
|
||||
short: { rate: short.rate, streak: short.streak },
|
||||
long: { rate: long.rate },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user