feat(medications): adapt the compliance display to cadence density

A rolling 7-/30-day percentage is meaningful for daily and weekday
medications but noise for sparse cadences: a med dosed every few weeks
has at most one expected dose in a 7-day window, so the bar swings
between 0 and 100 off a single sample.

Decide the display server-side from expected-dose density. When the
30-day window holds at least four expected doses the percentage bars
stay; below that the card swaps to an uptime-style per-dose timeline
where each cell is one expected dose — taken, missed, skipped or
upcoming — with a "last N doses" summary and a dose-indexed streak.

The compliance route returns an additive complianceDisplay block
(mode, expected7/30, the dose timeline, the summary) alongside the
existing compliance7/30 fields, which the iOS client and the Health
Score read unchanged. Read-path only; no schema change.

- compliance.ts: add expectedSlotsBetween and buildComplianceDisplay
  over the canonical recurrence engine
- dose-adherence-timeline: plain flex cells reusing the heatmap colour
  tokens and tap-to-pin tooltip
- switch the standard and GLP-1 cards on the server-decided mode
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 19:54:26 +02:00
1 parent 93790aa043
commit 1fcc5ba96b
13 files changed
+967 -19

No files matched your search

+7
View File
@@ -547,6 +547,13 @@
"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",
+7
View File
@@ -547,6 +547,13 @@
"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",
+7
View File
@@ -547,6 +547,13 @@
"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",
+7
View File
@@ -547,6 +547,13 @@
"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",
+7
View File
@@ -547,6 +547,13 @@
"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",
+7
View File
@@ -547,6 +547,13 @@
"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",
@@ -115,6 +115,13 @@ async function callRoute(): Promise<Record<string, unknown>> {
return body.data.dailyCompliance as Record<string, unknown>;
}
async function callRouteData(): Promise<Record<string, unknown>> {
const res = await GET(new Request("http://localhost"), ROUTE_PARAMS);
expect(res.status).toBe(200);
const body = await res.json();
return body.data as Record<string, unknown>;
}
describe("GET /api/medications/[id]/compliance — per-slot timing", () => {
it("classifies both doses of a single-row twice-daily schedule as on_time (green cell)", async () => {
const { start: dayStart } = dayBoundsForOffset(DAY_OFFSET);
@@ -239,3 +246,91 @@ describe("GET /api/medications/[id]/compliance — per-slot timing", () => {
expect(entry!.onTime).toBe(1);
});
});
// 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.
describe("GET /api/medications/[id]/compliance — complianceDisplay", () => {
it("daily med → mode: percent", async () => {
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
medication([
{
id: "sched-1",
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["08:00"],
daysOfWeek: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
]) as never,
);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
const data = await callRouteData();
const display = data.complianceDisplay as {
mode: string;
expected30: number;
minStableDoses: number;
};
expect(display.mode).toBe("percent");
expect(display.expected30).toBeGreaterThanOrEqual(display.minStableDoses);
// The legacy fields stay on the wire.
expect(data.compliance7).toBeDefined();
expect(data.compliance30).toBeDefined();
});
it("35-day-interval med → mode: timeline", 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),
endsOn: null,
oneShot: false,
schedules: [
{
id: "sched-1",
windowStart: "10:00",
windowEnd: "11:00",
timesOfDay: ["10:00"],
daysOfWeek: null,
rrule: null,
rollingIntervalDays: 35,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
],
} 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),
skipped: false,
scheduledFor: new Date(Date.now() - 25 * 24 * 60 * 60 * 1000),
},
] as never);
const data = await callRouteData();
const display = data.complianceDisplay as {
mode: string;
expected30: number;
minStableDoses: number;
};
expect(display.mode).toBe("timeline");
expect(display.expected30).toBeLessThan(display.minStableDoses);
});
});
@@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError } from "@/lib/api-response";
import {
buildComplianceDisplay,
buildComplianceMedicationContext,
calculateCompliance,
classifyIntakeTiming,
@@ -141,6 +142,18 @@ 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.
const complianceDisplay = buildComplianceDisplay(
mapped,
medication.schedules,
medicationContext,
);
// Build daily compliance map for heatmap/line chart (90 days)
const now = new Date();
const dailyCompliance: Record<string, DailyComplianceEntry> = {};
@@ -273,9 +286,18 @@ export const GET = apiHandler(
entity_type: "medication",
entity_id: id,
},
meta: { compliance7: compliance7.rate, compliance30: compliance30.rate },
meta: {
compliance7: compliance7.rate,
compliance30: compliance30.rate,
displayMode: complianceDisplay.mode,
},
});
return apiSuccess({ compliance7, compliance30, dailyCompliance });
return apiSuccess({
compliance7,
compliance30,
dailyCompliance,
complianceDisplay,
});
},
);
@@ -0,0 +1,173 @@
"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>
);
}
@@ -10,6 +10,10 @@ 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 {
@@ -91,9 +95,24 @@ export interface Glp1Medication {
schedules: ScheduleLite[];
}
interface ComplianceDisplay {
mode: "percent" | "timeline";
expected7: number;
expected30: number;
minStableDoses: number;
doseTimeline: DoseAdherenceCell[];
recentDoseSummary: { taken: number; total: number; doseStreak: 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.
*/
complianceDisplay?: ComplianceDisplay;
}
interface DetailsResponse {
@@ -420,15 +439,24 @@ export function Glp1MedicationCard({
Glp1InventoryDTO slot on /api/medications/[id]/glp1 stays
in the response shape; only the web mounts are gone. */}
{/* Compliance bars — shared with the generic card so the page
grid stays harmonious. */}
{medication.active && compliance && (
<MedicationComplianceBars
rate7={rate7}
rate30={rate30}
streak={streak}
/>
)}
{/* 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}
/>
))}
{/* Primary actions row — shared with the generic medication
card. The GLP-1-specific side-effect quick-log lives in the
+37 -8
View File
@@ -9,6 +9,10 @@ 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";
@@ -62,6 +66,15 @@ interface Medication {
schedules: Schedule[];
}
interface ComplianceDisplay {
mode: "percent" | "timeline";
expected7: number;
expected30: number;
minStableDoses: number;
doseTimeline: DoseAdherenceCell[];
recentDoseSummary: { taken: number; total: number; doseStreak: number };
}
interface ComplianceData {
compliance7: {
totalExpected: number;
@@ -74,6 +87,12 @@ interface ComplianceData {
compliance30: {
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.
*/
complianceDisplay?: ComplianceDisplay;
}
interface MedicationCardProps {
@@ -327,14 +346,24 @@ export function MedicationCard({
</p>
)}
{/* Compliance bar */}
{medication.active && compliance && (
<MedicationComplianceBars
rate7={rate7}
rate30={rate30}
streak={streak}
/>
)}
{/* 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}
/>
))}
{/* Quick actions — primary buttons of the medication card. */}
{medication.active && (
@@ -14,10 +14,13 @@
*/
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import {
MIN_STABLE_DOSES,
buildComplianceDisplay,
buildComplianceMedicationContext,
calculateCompliance,
classifyIntakeTiming,
expectedSlotCountForDay,
expectedSlotsBetween,
lastNonSkippedTakenAt,
type ComplianceMedicationContext,
type ComplianceSchedule,
@@ -1019,3 +1022,275 @@ describe("classifyIntakeTiming", () => {
);
});
});
// v1.8.5 — the cadence-density rule + the per-dose uptime strip.
// `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", () => {
const NOW = new Date("2025-06-15T12:00:00Z");
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
function ctx(
overrides: Partial<ComplianceMedicationContext> = {},
): ComplianceMedicationContext {
return buildComplianceMedicationContext(
{
startsOn: overrides.startsOn ?? null,
endsOn: overrides.endsOn ?? null,
oneShot: overrides.oneShot ?? false,
// Created well before the windows so no day is excluded.
createdAt: overrides.createdAt ?? new Date("2025-01-01T00:00:00Z"),
},
overrides.lastIntakeAt ?? null,
overrides.timeZone ?? "UTC",
);
}
it("echoes the threshold so a client can re-derive the mode", () => {
const display = buildComplianceDisplay(
[],
[{ 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)", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
const display = buildComplianceDisplay([], schedules, ctx(), { now: NOW });
expect(display.mode).toBe("percent");
expect(display.expected30).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.
const schedules: ComplianceSchedule[] = [
{
windowStart: "10:00",
windowEnd: "11:00",
daysOfWeek: null,
rollingIntervalDays: 35,
timesOfDay: ["10:00"],
},
];
const display = buildComplianceDisplay(
[],
schedules,
ctx({
startsOn: new Date("2025-05-01T00:00:00Z"),
// Rolling cadences re-anchor on the last non-skipped intake.
lastIntakeAt: new Date("2025-05-20T10:00:00Z"),
}),
{ now: NOW },
);
expect(display.mode).toBe("timeline");
expect(display.expected30).toBeLessThan(MIN_STABLE_DOSES);
});
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.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=WEEKLY;BYDAY=MO",
timesOfDay: ["08:00"],
},
];
const display = buildComplianceDisplay(
[],
schedules,
ctx({ startsOn: new Date("2025-05-26T00: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");
});
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.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=WEEKLY;BYDAY=MO",
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(
events,
schedules,
ctx({ startsOn: new Date("2025-05-26T00:00:00Z") }),
{ now: NOW },
);
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(
events,
schedules,
ctx({ startsOn: new Date("2025-05-26T00:00:00Z") }),
{ now: NOW },
);
expect(display.recentDoseSummary.doseStreak).toBe(2);
});
it("caps the past strip at 12 cells for a dense daily med", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
const display = buildComplianceDisplay([], schedules, ctx(), { now: NOW });
const past = display.doseTimeline.filter((c) => c.status !== "upcoming");
expect(past.length).toBeLessThanOrEqual(12);
});
});
describe("expectedSlotsBetween", () => {
function ctx(
overrides: Partial<ComplianceMedicationContext> = {},
): ComplianceMedicationContext {
return buildComplianceMedicationContext(
{
startsOn: overrides.startsOn ?? null,
endsOn: overrides.endsOn ?? null,
oneShot: overrides.oneShot ?? false,
createdAt: overrides.createdAt ?? new Date("2025-01-01T00:00:00Z"),
},
overrides.lastIntakeAt ?? null,
overrides.timeZone ?? "UTC",
);
}
it("returns the occurrences themselves, ascending by instant", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00", "20:00"],
},
];
const slots = expectedSlotsBetween(
schedules,
new Date("2025-06-09T00:00:00Z"),
new Date("2025-06-10T23:59:59Z"),
ctx(),
);
// Two days × two times = four slots, ascending.
expect(slots.length).toBe(4);
for (let i = 1; i < slots.length; i++) {
expect(slots[i].at.getTime()).toBeGreaterThanOrEqual(
slots[i - 1].at.getTime(),
);
}
});
it("excludes PRN schedules from the expected slots", () => {
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
scheduleType: "PRN",
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
const slots = expectedSlotsBetween(
schedules,
new Date("2025-06-09T00:00:00Z"),
new Date("2025-06-12T00:00:00Z"),
ctx(),
);
expect(slots.length).toBe(0);
});
});
+284
View File
@@ -23,12 +23,15 @@ import {
buildCadenceTimeline,
type CadenceEngineContext,
type IntakeEventLike,
type PairedDose,
type ScheduleLike,
} from "@/lib/medications/scheduling/cadence";
import {
occurrencesBetween,
type Occurrence,
type ScheduleType,
} from "@/lib/medications/scheduling/recurrence";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
interface IntakeEvent {
takenAt: Date | null;
@@ -302,6 +305,287 @@ export function expectedSlotCountForDay(
return count;
}
/**
* v1.8.5 — the sibling of {@link expectedSlotCountForDay} that returns the
* expected-dose occurrences *themselves* (ascending by instant) over an
* arbitrary window, rather than just the per-day count. Same loop, same
* canonical-engine delegation; we keep the slots so the dose-adherence
* timeline can pair each expected slot to its intake.
*
* Used by {@link buildComplianceDisplay} to decide the card's render mode
* (percent bars vs an uptime-style per-dose strip) and to build the strip.
* `occurrencesBetween` is inclusive of both ends; the caller passes a
* `[from, to]` window and we sort the union of every schedule's slots so
* a multi-schedule medication interleaves its slots in time order.
*/
export function expectedSlotsBetween(
schedules: ComplianceSchedule[],
from: Date,
to: Date,
ctx: ComplianceMedicationContext,
): Occurrence[] {
const recurrenceCtx = {
medication: {
id: "compliance-slots",
startsOn: ctx.startsOn,
endsOn: ctx.endsOn,
oneShot: ctx.oneShot,
createdAt: ctx.createdAt,
},
timeZone: ctx.timeZone,
lastIntakeAt: ctx.lastIntakeAt,
};
const all: Occurrence[] = [];
for (let i = 0; i < schedules.length; i++) {
const s = schedules[i];
const canonical = {
id: `compliance-slots-${i}`,
rrule: s.rrule ?? null,
rollingIntervalDays: s.rollingIntervalDays ?? null,
timesOfDay: s.timesOfDay ?? [],
daysOfWeek: s.daysOfWeek ?? null,
windowStart: s.windowStart,
windowEnd: s.windowEnd,
reminderGraceMinutes: s.reminderGraceMinutes ?? null,
scheduleType: s.scheduleType ?? ("SCHEDULED" as const),
cyclicOnWeeks: s.cyclicOnWeeks ?? null,
cyclicOffWeeks: s.cyclicOffWeeks ?? null,
};
all.push(...occurrencesBetween(canonical, from, to, recurrenceCtx));
}
return all.sort((a, b) => a.at.getTime() - b.at.getTime());
}
/**
* 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
* 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.
*/
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;
/** 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.5 — compute the additive {@link ComplianceDisplay} block.
*
* 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.
*
* 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.
*/
export function buildComplianceDisplay(
events: IntakeEvent[],
schedules: ComplianceSchedule[],
ctx: ComplianceMedicationContext,
options?: { now?: Date },
): ComplianceDisplay {
const now = options?.now ?? new Date();
const DAY_MS = 24 * 60 * 60 * 1000;
// 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,
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++;
}
return {
mode,
expected7,
expected30,
minStableDoses: MIN_STABLE_DOSES,
doseTimeline,
recentDoseSummary: {
taken: summaryTaken,
total: summaryTotal,
doseStreak,
},
};
}
/**
* Calculate compliance for a medication over a given period.
*