Merge pull request #1007 from muhdusama/medfix/apple-health-scheduled-mirror

fix: allow scheduled Apple Health medication mirrors
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-09-20 23:47:32 +02:00
commit aa58495cbe
28 files changed
+531 -62

No files matched your search

+52 -22
View File
@@ -5827,12 +5827,10 @@ paths:
tags:
- Medications
summary: Batched adherence read for every medication of the caller
description: Returns one compact adherence row per medication the caller owns (active + paused), ordered by `createdAt
DESC` — the single round trip the medication cards consume instead of fanning out one
`/api/medications/{id}/compliance` request per card. Each row carries the 7-/30-day summaries and the
cadence-scaled display block; the per-day grid stays on the per-medication endpoint. Pure computation — no
writes. Served through the same per-medication server cache as the per-id read, so the two endpoints warm each
other.
description: Returns one compact row per non-PRN medication. A scheduled medication with no local schedule remains in
the array with applicable=false and reason NO_LOCAL_SCHEDULE. compliance7/compliance30 stay non-null as all-zero
compatibility placeholders for released clients; aware clients must ignore those percentages when
applicable=false. complianceDisplay is null.
responses:
"200":
description: One adherence row per medication.
@@ -5849,11 +5847,11 @@ paths:
tags:
- Medications
summary: Adherence read for a medication
description: Returns the 7- and 30-day adherence summaries, the per-day compliance grid for the history glyph track, and
the two-row display block. Pure computation — no writes. Day boundaries are resolved in the user's IANA
timezone, and the expected-dose denominator is cadence-aware (RRULE / rolling / one-shot / PRN / cyclic) and
clamped to the medication's `createdAt`. Read `compliance30` for the headline 30-day taken-vs-expected
percentage; build the per-day glyph track from `dailyCompliance` (draw a cell only where `due === true`).
description: Returns the cadence-aware adherence payload. A scheduled medication with no local schedule returns
applicable=false, reason NO_LOCAL_SCHEDULE, non-null all-zero compliance7/compliance30 compatibility
placeholders, a null display block, and an empty daily grid. Aware clients must not render the placeholder
percentages. Medications with a local schedule preserve the existing arithmetic, including windows in which no
dose is due.
parameters:
- in: path
name: id
@@ -42937,21 +42935,35 @@ components:
properties:
medicationId:
type: string
applicable:
type: boolean
description: Whether this medication has a local expected-dose grid and therefore a displayable adherence percentage.
notApplicableReason:
anyOf:
- type: string
const: NO_LOCAL_SCHEDULE
- type: "null"
compliance7:
description: Seven-day adherence summary, or an all-zero compatibility placeholder when applicable is false.
$ref: "#/components/schemas/ComplianceResult"
compliance30:
description: Thirty-day adherence summary, or an all-zero compatibility placeholder when applicable is false.
$ref: "#/components/schemas/ComplianceResult"
complianceDisplay:
$ref: "#/components/schemas/ComplianceDisplay"
anyOf:
- $ref: "#/components/schemas/ComplianceDisplay"
- type: "null"
required:
- medicationId
- applicable
- notApplicableReason
- compliance7
- compliance30
- complianceDisplay
additionalProperties: false
description: "Compact per-medication adherence row for the batched card read: the 7-/30-day summaries plus the
cadence-scaled display block. The per-day `dailyCompliance` grid is NOT on this shape — read the per-medication
`/compliance` endpoint for the history glyph track."
description: Compact per-medication adherence row for the batched card read. Zero-local-schedule scheduled medications
remain present with applicable=false so clients render a settled not-applicable state instead of a loading
skeleton.
ComplianceResult:
type: object
properties:
@@ -43181,9 +43193,23 @@ components:
MedicationComplianceResponse:
type: object
properties:
applicable:
type: boolean
description: Whether a local adherence percentage applies to this medication.
notApplicableReason:
anyOf:
- type: string
const: NO_LOCAL_SCHEDULE
- type: "null"
description: Reason adherence is not applicable. NO_LOCAL_SCHEDULE means the medication has no HealthLog-owned
expected-dose grid.
compliance7:
description: Seven-day adherence summary. When applicable is false, this remains a non-null all-zero compatibility
placeholder for released clients and must not be rendered as a percentage.
$ref: "#/components/schemas/ComplianceResult"
compliance30:
description: Thirty-day adherence summary. When applicable is false, this remains a non-null all-zero compatibility
placeholder for released clients and must not be rendered as a percentage.
$ref: "#/components/schemas/ComplianceResult"
dailyCompliance:
type: object
@@ -43191,20 +43217,24 @@ components:
type: string
additionalProperties:
$ref: "#/components/schemas/DailyComplianceEntry"
description: Flat per-day map keyed `YYYY-MM-DD` in the user timezone, one entry per day for up to 90 days back, clamped
to the medication's `createdAt` (so a recently-created med has fewer entries). No weekly/monthly collapse —
this is the raw daily grid.
description: Flat per-day map keyed YYYY-MM-DD in the user timezone. Empty when adherence is not applicable.
complianceDisplay:
$ref: "#/components/schemas/ComplianceDisplay"
anyOf:
- $ref: "#/components/schemas/ComplianceDisplay"
- type: "null"
description: Cadence-scaled display block, or null when adherence is not applicable.
required:
- applicable
- notApplicableReason
- compliance7
- compliance30
- dailyCompliance
- complianceDisplay
additionalProperties: false
description: Adherence read for a single medication. `compliance30` is the authoritative 30-day taken-vs-expected
summary; `dailyCompliance` is the per-day grid for the history glyph track. The graded raw→week→month→year
series used elsewhere for AI prompts does NOT apply here — this response is never downsampled.
description: Adherence read for a single medication. A scheduled medication with no local schedule returns
applicable=false and NO_LOCAL_SCHEDULE rather than a vacuous 100 percent. Scheduled medications that have a
local schedule keep the existing cadence-aware arithmetic, including windows with no doses due. PRN behaviour is
unchanged.
DailyComplianceEntry:
type: object
properties:
+2
View File
@@ -1208,6 +1208,8 @@
"veryOverdue": "Stark überfällig",
"complianceWindow": "Therapietreue ({days} Tage)",
"complianceUnavailable": "Therapietreue konnte nicht geladen werden",
"complianceNotApplicable": "Therapietreue nicht anwendbar",
"complianceNotApplicableReason": "In HealthLog ist kein lokaler Zeitplan gespeichert.",
"complianceInfo": "Anteil der erfassten an den erwarteten Dosen. Wöchentliche Pläne zählen wochenweise.",
"complianceInfoLabel": "Was bedeutet Therapietreue?",
"lastIntake": "Letzte Einnahme:",
+2
View File
@@ -1208,6 +1208,8 @@
"veryOverdue": "Very overdue",
"complianceWindow": "Adherence ({days} days)",
"complianceUnavailable": "Adherence could not be loaded",
"complianceNotApplicable": "Adherence not applicable",
"complianceNotApplicableReason": "No local schedule is stored in HealthLog.",
"complianceInfo": "Share of expected doses you logged. Weekly schedules count week by week.",
"complianceInfoLabel": "What does adherence mean?",
"lastIntake": "Last intake:",
+2
View File
@@ -1208,6 +1208,8 @@
"veryOverdue": "Muy vencida",
"complianceWindow": "Cumplimiento {days} días",
"complianceUnavailable": "No se pudo cargar el cumplimiento",
"complianceNotApplicable": "El cumplimiento no es aplicable",
"complianceNotApplicableReason": "No hay un horario local guardado en HealthLog.",
"complianceInfo": "Proporción de dosis registradas respecto a las esperadas. Las pautas semanales se cuentan semana a semana.",
"complianceInfoLabel": "¿Qué significa el cumplimiento?",
"lastIntake": "Última toma:",
+2
View File
@@ -1208,6 +1208,8 @@
"veryOverdue": "Très en retard",
"complianceWindow": "Observance {days} jours",
"complianceUnavailable": "Impossible de charger l'observance",
"complianceNotApplicable": "Observance non applicable",
"complianceNotApplicableReason": "Aucun planning local n’est enregistré dans HealthLog.",
"complianceInfo": "Part des doses attendues que vous avez enregistrées. Les plans hebdomadaires comptent semaine par semaine.",
"complianceInfoLabel": "Que signifie l'observance ?",
"lastIntake": "Dernière prise :",
+2
View File
@@ -1208,6 +1208,8 @@
"veryOverdue": "Molto in ritardo",
"complianceWindow": "Aderenza {days} giorni",
"complianceUnavailable": "Impossibile caricare l'aderenza",
"complianceNotApplicable": "Aderenza non applicabile",
"complianceNotApplicableReason": "In HealthLog non è memorizzato alcun programma locale.",
"complianceInfo": "Quota delle dosi attese che hai registrato. I piani settimanali contano settimana per settimana.",
"complianceInfoLabel": "Cosa significa aderenza?",
"lastIntake": "Ultima assunzione:",
+2
View File
@@ -1208,6 +1208,8 @@
"veryOverdue": "많이 지남",
"complianceWindow": "복약 순응도 ({days}일)",
"complianceUnavailable": "복약 순응도를 불러오지 못했어요",
"complianceNotApplicable": "복약 순응도 해당 없음",
"complianceNotApplicableReason": "HealthLog에 로컬 복용 일정이 저장되어 있지 않습니다.",
"complianceInfo": "예정된 복용 중 기록한 비율이에요. 주간 일정은 주 단위로 계산해요.",
"complianceInfoLabel": "복약 순응도란?",
"lastIntake": "마지막 복용:",
+2
View File
@@ -1208,6 +1208,8 @@
"veryOverdue": "Mocno po terminie",
"complianceWindow": "Przestrzeganie {days} dni",
"complianceUnavailable": "Nie udało się wczytać danych o przestrzeganiu",
"complianceNotApplicable": "Przestrzeganie zaleceń nie ma zastosowania",
"complianceNotApplicableReason": "W HealthLog nie zapisano lokalnego harmonogramu.",
"complianceInfo": "Udział zarejestrowanych dawek względem oczekiwanych. Plany tygodniowe liczone są tydzień po tygodniu.",
"complianceInfoLabel": "Co oznacza przestrzeganie zaleceń?",
"lastIntake": "Ostatnie przyjęcie:",
@@ -625,6 +625,28 @@ describe("PUT /api/medications/[id] — as-needed (v1.16.11, #316)", () => {
expect(prisma.medication.update).not.toHaveBeenCalled();
});
it("allows a normal update on an existing zero-schedule scheduled mirror when schedules are omitted", async () => {
mockExisting({ asNeeded: false, scheduleCount: 0 });
vi.mocked(getMedicationCategories).mockResolvedValue({} as never);
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(prisma.medication.update).mockResolvedValue({
id: "m1",
userId: "user-1",
dose: "10 mg",
asNeeded: false,
schedules: [],
} as never);
const res = await PUT(putReq({ dose: "10 mg" }), ROUTE_CTX);
expect(res.status).toBe(200);
const updateData = vi.mocked(prisma.medication.update).mock.calls[0][0] as {
data: Record<string, unknown>;
};
expect(updateData.data.dose).toBe("10 mg");
expect(updateData.data.schedules).toBeUndefined();
});
it("flips to as-needed with schedules:[] — old rows deleted, none recreated", async () => {
mockExisting({ scheduleCount: 1 });
// Re-stub deps the outer beforeEach's resetAllMocks cleared.
@@ -125,6 +125,7 @@ function medication(schedules: unknown[]) {
startsOn: null,
endsOn: null,
oneShot: false,
asNeeded: false,
schedules,
};
}
@@ -160,6 +161,43 @@ async function callRouteData(): Promise<Record<string, unknown>> {
return body.data as Record<string, unknown>;
}
describe("GET /api/medications/[id]/compliance - applicability", () => {
it("returns not-applicable instead of 100 percent for a scheduled mirror with no local schedule", async () => {
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
...medication([]),
asNeeded: false,
externalSource: "APPLE_HEALTH",
externalId: "hk-concept-1",
} as never);
const data = await callRouteData();
expect(data).toEqual({
applicable: false,
notApplicableReason: "NO_LOCAL_SCHEDULE",
compliance7: {
totalExpected: 0,
taken: 0,
skipped: 0,
missed: 0,
rate: 0,
streak: 0,
},
compliance30: {
totalExpected: 0,
taken: 0,
skipped: 0,
missed: 0,
rate: 0,
streak: 0,
},
dailyCompliance: {},
complianceDisplay: null,
});
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
});
});
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);
@@ -334,6 +372,8 @@ describe("GET /api/medications/[id]/compliance — complianceDisplay", () => {
short: { rate: number; streak: number };
long: { rate: number };
};
expect(data.applicable).toBe(true);
expect(data.notApplicableReason).toBeNull();
expect(display.shortDays).toBe(7);
expect(display.longDays).toBe(30);
expect(display.expectedLong).toBeGreaterThanOrEqual(display.minStableDoses);
@@ -96,12 +96,17 @@ export const GET = apiHandler(
entity_type: "medication",
entity_id: id,
},
meta: {
compliance7: payload.compliance7.rate,
compliance30: payload.compliance30.rate,
complianceShortDays: payload.complianceDisplay.shortDays,
complianceLongDays: payload.complianceDisplay.longDays,
},
meta: payload.applicable
? {
compliance7: payload.compliance7!.rate,
compliance30: payload.compliance30!.rate,
complianceShortDays: payload.complianceDisplay!.shortDays,
complianceLongDays: payload.complianceDisplay!.longDays,
}
: {
complianceApplicable: false,
complianceNotApplicableReason: payload.notApplicableReason,
},
});
return apiSuccess(payload);
@@ -526,6 +526,28 @@ describe("POST /api/medications — Apple Health mirror (v1.28)", () => {
.data;
}
it("admits a scheduled Apple Health mirror without a HealthLog-owned schedule", async () => {
vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.medication.count).mockResolvedValue(0 as never);
const res = await POST(
postReq({
name: "Telmisartan",
dose: "1 count",
externalSource: "APPLE_HEALTH",
externalId: "hk-concept-scheduled",
asNeeded: false,
}),
);
expect(res.status).toBe(201);
const data = lastCreateData();
expect(data.externalSource).toBe("APPLE_HEALTH");
expect(data.externalId).toBe("hk-concept-scheduled");
expect(data.asNeeded).toBe(false);
expect(data.schedules).toEqual({ create: [] });
});
it("persists externalSource + externalId field-by-field on create", async () => {
vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never);
const res = await POST(postReq(MIRROR_BODY));
@@ -59,6 +59,15 @@ import { checkRateLimit } from "@/lib/rate-limit";
const TZ = "UTC";
const LEGACY_EMPTY_COMPLIANCE = {
totalExpected: 0,
taken: 0,
skipped: 0,
missed: 0,
rate: 0,
streak: 0,
};
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
@@ -93,6 +102,7 @@ function medication(id: string) {
startsOn: null,
endsOn: null,
oneShot: false,
asNeeded: false,
schedules: [dailySchedule(`${id}-sched`)],
scheduleRevisions: [],
};
@@ -133,6 +143,8 @@ describe("GET /api/medications/compliance", () => {
expect(items).toHaveLength(2);
expect(items.map((i) => i.medicationId)).toEqual(["med-1", "med-2"]);
for (const item of items) {
expect(item.applicable).toBe(true);
expect(item.notApplicableReason).toBeNull();
expect(item.compliance7).toBeDefined();
expect(item.compliance30).toBeDefined();
expect(item.complianceDisplay).toBeDefined();
@@ -141,6 +153,67 @@ describe("GET /api/medications/compliance", () => {
}
});
it("returns a settled not-applicable row for a scheduled mirror with no local schedule", async () => {
vi.mocked(prisma.medication.findMany).mockResolvedValue([
{
...medication("mirror-1"),
asNeeded: false,
externalSource: "APPLE_HEALTH",
externalId: "hk-concept-1",
schedules: [],
},
] as never);
const res = await (GET as (req: Request) => Promise<Response>)(
new Request("http://localhost"),
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toEqual([
{
medicationId: "mirror-1",
applicable: false,
notApplicableReason: "NO_LOCAL_SCHEDULE",
compliance7: LEGACY_EMPTY_COMPLIANCE,
compliance30: LEGACY_EMPTY_COMPLIANCE,
complianceDisplay: null,
},
]);
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
});
it("keeps a mixed scheduled/mirror batch decodable by the legacy non-null contract", async () => {
vi.mocked(prisma.medication.findMany).mockResolvedValue([
medication("med-1"),
{
...medication("mirror-1"),
externalSource: "APPLE_HEALTH",
externalId: "hk-concept-1",
schedules: [],
},
] as never);
const res = await (GET as (req: Request) => Promise<Response>)(
new Request("http://localhost"),
);
expect(res.status).toBe(200);
const body = await res.json();
const items = body.data as Array<Record<string, unknown>>;
expect(items).toHaveLength(2);
expect(items[0]?.applicable).toBe(true);
expect(items[0]?.compliance7).toBeTruthy();
expect(items[0]?.compliance30).toBeTruthy();
expect(items[1]).toMatchObject({
medicationId: "mirror-1",
applicable: false,
notApplicableReason: "NO_LOCAL_SCHEDULE",
compliance7: LEGACY_EMPTY_COMPLIANCE,
compliance30: LEGACY_EMPTY_COMPLIANCE,
complianceDisplay: null,
});
});
it("reads each medication through the shared per-medication cache cell", async () => {
vi.mocked(prisma.medication.findMany).mockResolvedValue([
medication("med-1"),
@@ -174,7 +247,10 @@ describe("GET /api/medications/compliance", () => {
const call = vi.mocked(prisma.medication.findMany).mock.calls[0][0] as {
where: { userId: string };
};
expect(call.where.userId).toBe("user-1");
expect(call.where).toEqual({
userId: "user-1",
asNeeded: false,
});
});
it("returns 429 when the per-user rate limit is exhausted", async () => {
@@ -80,6 +80,8 @@ export const GET = apiHandler(async () => {
);
return {
medicationId: medication.id,
applicable: payload.applicable,
notApplicableReason: payload.notApplicableReason,
compliance7: payload.compliance7,
compliance30: payload.compliance30,
complianceDisplay: payload.complianceDisplay,
@@ -11,6 +11,7 @@ import {
import {
MedicationComplianceBars,
MedicationComplianceError,
MedicationComplianceNotApplicable,
MedicationComplianceSkeleton,
} from "@/components/medications/card-parts/medication-compliance-bars";
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
@@ -116,6 +117,15 @@ describe("medication card-parts — shared presentational components", () => {
}
});
it("not-applicable compliance is a settled empty state with no percentage or retry", () => {
const html = render(<MedicationComplianceNotApplicable />);
expect(html).toContain("Adherence not applicable");
expect(html).toContain("No local schedule is stored in HealthLog.");
expect(html).toContain('data-slot="medication-compliance-not-applicable"');
expect(html).not.toContain('data-slot="progress"');
expect(html).not.toContain("Retry");
});
it("compliance bars render the percentage only — no dose count beside it", () => {
// v1.15.9 — the operator does not want a count next to the percentage.
// The auto-miss engine now makes the two windows' percentages genuinely
@@ -637,6 +647,7 @@ describe("compliance error fallback (v1.16.8)", () => {
function renderBodyCompliance(args: {
compliance: boolean;
complianceError?: boolean;
complianceNotApplicable?: boolean;
}) {
return render(
<MedicationCardBody
@@ -659,6 +670,7 @@ describe("compliance error fallback (v1.16.8)", () => {
: null
}
complianceError={args.complianceError}
complianceNotApplicable={args.complianceNotApplicable}
onRetryCompliance={() => {}}
currentCycle={null}
intakeLoading={null}
@@ -694,6 +706,17 @@ describe("compliance error fallback (v1.16.8)", () => {
expect(html).not.toContain('aria-hidden="true"><div class="space-y-1.5"');
});
it("the body renders not-applicable instead of a permanent loading skeleton", () => {
const html = renderBodyCompliance({
compliance: false,
complianceNotApplicable: true,
});
expect(html).toContain("Adherence not applicable");
expect(html).toContain('data-slot="medication-compliance-not-applicable"');
expect(html).not.toContain("Adherence could not be loaded");
expect(html).not.toContain("Retry");
});
it("the body keeps the skeleton while the query is merely in flight", () => {
const html = renderBodyCompliance({ compliance: false });
expect(html).not.toContain("Adherence could not be loaded");
@@ -22,6 +22,8 @@
* doesn't silently drift across the eight section consumers.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, it, expect } from "vitest";
import { medicationDependentKeys, queryKeys } from "@/lib/query-keys";
@@ -91,3 +93,26 @@ describe("medication-detail-page query-key cascade (D-3 §9)", () => {
expect(isUnderPrefix(apiEndpointKey, queryKeys.medications())).toBe(true);
});
});
describe("medication detail compliance applicability", () => {
const source = readFileSync(
join(
process.cwd(),
"src/components/medications/detail/medication-detail-tabs.tsx",
),
"utf8",
);
it("renders the settled not-applicable state instead of a percentage or loading fallback", () => {
expect(source).toContain(
"const complianceNotApplicable = compliance?.applicable === false",
);
expect(source).toContain("<MedicationComplianceNotApplicable />");
});
it("keeps PRN compliance disabled at the query boundary", () => {
expect(source).toContain(
'enabled: activeTab === "uebersicht" && !asNeeded',
);
});
});
@@ -59,8 +59,28 @@ function seedCompliance(
...existing.filter((row) => row.medicationId !== medId),
{
medicationId: medId,
applicable: true,
notApplicableReason: null,
compliance7: { rate: rate7, streak: 0 },
compliance30: { rate: rate30 },
complianceDisplay: null,
},
]);
}
function seedComplianceNotApplicable(client: QueryClient, medId: string) {
const key = ["medications", "compliance-summary"];
const existing =
(client.getQueryData(key) as Array<{ medicationId: string }>) ?? [];
client.setQueryData(key, [
...existing.filter((row) => row.medicationId !== medId),
{
medicationId: medId,
applicable: false,
notApplicableReason: "NO_LOCAL_SCHEDULE",
compliance7: null,
compliance30: null,
complianceDisplay: null,
},
]);
}
@@ -197,6 +217,32 @@ describe("<MedicationTable> — structure + shared payloads", () => {
expect(html).toContain("30 d");
});
it("renders a settled not-applicable state for a scheduled mirror with no local schedule", () => {
const client = makeClient();
seedComplianceNotApplicable(client, "mirror1");
const html = render(
<MedicationTable
activeMedications={[
med({
id: "mirror1",
name: "Apple Health mirror",
asNeeded: false,
schedules: [],
}),
]}
inactiveMedications={[]}
/>,
client,
);
expect(html).toContain("Adherence not applicable");
expect(html).toContain(
'data-slot="medication-table-compliance-not-applicable"',
);
expect(html).not.toContain('data-slot="progress"');
expect(html).not.toContain('aria-hidden="true"><div class="space-y-1.5"');
});
it("labels the action buttons per row and keeps the 44px tap floor", () => {
const client = makeClient();
seedCompliance(client, "m1", 90, 88);
@@ -11,6 +11,7 @@ import { MedicationStatusPill } from "@/components/medications/card-parts/medica
import {
MedicationComplianceBars,
MedicationComplianceError,
MedicationComplianceNotApplicable,
MedicationComplianceSkeleton,
} from "@/components/medications/card-parts/medication-compliance-bars";
import { MedicationCycleStatus } from "@/components/medications/card-parts/medication-cycle-status";
@@ -112,6 +113,11 @@ export interface MedicationCardBodyProps {
* while a failed read needs a visible (but calm) retry affordance.
*/
complianceError?: boolean;
/**
* Settled not-applicable state for a scheduled medication with no local
* schedule. This state must win over the loading skeleton.
*/
complianceNotApplicable?: boolean;
/** Refetch the shared compliance query (the error fallback's retry). */
onRetryCompliance?: () => void;
@@ -172,6 +178,7 @@ export function MedicationCardBody({
lastLine,
compliance,
complianceError = false,
complianceNotApplicable = false,
onRetryCompliance,
currentCycle,
asNeeded = false,
@@ -299,7 +306,11 @@ export function MedicationCardBody({
notice now costs zero layout shift. */}
{active &&
!asNeeded &&
(compliance ? (
(complianceNotApplicable ? (
<MedicationComplianceNotApplicable
lowStockRunwayDays={lowStockRunwayDays}
/>
) : compliance ? (
<MedicationComplianceBars
rate7={compliance.rate7}
rate30={compliance.rate30}
@@ -219,6 +219,44 @@ export function MedicationComplianceSkeleton({
);
}
/**
* Settled empty state for a medication that has no local expected-dose grid.
* Unlike an error, this is not retryable; unlike loading, it must never leave
* the card on a skeleton. The two reserved rows mirror the other compliance
* states so card geometry remains stable.
*/
export function MedicationComplianceNotApplicable({
lowStockRunwayDays = null,
}: {
lowStockRunwayDays?: number | null;
}) {
const { t } = useTranslations();
return (
<div
className="space-y-2.5"
data-slot="medication-compliance-not-applicable"
>
<div className="space-y-1.5">
<div className="flex h-5 items-center text-sm">
<span className="text-muted-foreground min-w-0 truncate">
{t("medications.complianceNotApplicable")}
</span>
</div>
<div className="bg-muted/40 h-2 rounded" />
</div>
<div className="space-y-1.5">
<div className="flex h-5 items-center text-xs">
<span className="text-muted-foreground min-w-0 truncate">
{t("medications.complianceNotApplicableReason")}
</span>
</div>
<div className="bg-muted/40 h-2 rounded" />
</div>
<ComplianceMetaRow streak={0} lowStockRunwayDays={lowStockRunwayDays} />
</div>
);
}
/**
* Quiet error fallback for the compliance block, shown when the batched
* `/api/medications/compliance` query failed after its retries. Mirrors
@@ -50,7 +50,10 @@ import { MedicationDetailSummary } from "@/components/medications/medication-det
import { EfficacyTab } from "@/components/medications/detail/efficacy/efficacy-tab";
import { resolveMedicationTargets } from "@/lib/medications/med-target-map";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars";
import {
MedicationComplianceBars,
MedicationComplianceNotApplicable,
} from "@/components/medications/card-parts/medication-compliance-bars";
import { DoseHistoryLedger } from "@/components/medications/dose-history-ledger";
import { IntakeHistoryListV2 } from "@/components/medications/intake-history-list-v2";
import { SegmentedToggle } from "@/components/medications/detail/segmented-toggle";
@@ -377,9 +380,11 @@ export function MedicationDetailTabs({
queryFn: async () => {
try {
return await apiGet<{
compliance7?: { rate: number; streak: number };
compliance30?: { rate: number };
complianceDisplay?: ComplianceDisplay;
applicable: boolean;
notApplicableReason: "NO_LOCAL_SCHEDULE" | null;
compliance7: { rate: number; streak: number };
compliance30: { rate: number };
complianceDisplay: ComplianceDisplay | null;
}>(`/api/medications/${id}/compliance`);
} catch {
return null;
@@ -391,7 +396,8 @@ export function MedicationDetailTabs({
staleTime: 30_000,
});
const display = compliance?.complianceDisplay;
const complianceNotApplicable = compliance?.applicable === false;
const display = compliance?.complianceDisplay ?? undefined;
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;
@@ -595,7 +601,9 @@ export function MedicationDetailTabs({
title={t("medications.detail.uebersicht.complianceTitle")}
dataSlot="medication-uebersicht-compliance"
>
{compliance ? (
{complianceNotApplicable ? (
<MedicationComplianceNotApplicable />
) : compliance ? (
<MedicationComplianceBars
rate7={rate7}
rate30={rate30}
@@ -307,7 +307,8 @@ export function Glp1MedicationCard({
// 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 complianceNotApplicable = compliance?.applicable === false;
const display = compliance?.complianceDisplay ?? undefined;
const shortDays = display?.shortDays ?? 7;
const longDays = display?.longDays ?? 30;
const rate7 = display?.short.rate ?? compliance?.compliance7?.rate ?? 0;
@@ -540,11 +541,16 @@ export function Glp1MedicationCard({
nextLine={nextLine}
lastLine={lastLine}
compliance={
compliance ? { rate7, rate30, streak, shortDays, longDays } : null
compliance && !complianceNotApplicable
? { rate7, rate30, streak, shortDays, longDays }
: null
}
complianceError={complianceError}
complianceNotApplicable={complianceNotApplicable}
onRetryCompliance={refetchCompliance}
currentCycle={display?.currentCycle ?? null}
currentCycle={
complianceNotApplicable ? null : (display?.currentCycle ?? null)
}
lowStockRunwayDays={lowStockRunwayDays}
intakeLoading={intakeLoading}
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
@@ -226,7 +226,8 @@ export function MedicationCard({
// 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 complianceNotApplicable = compliance?.applicable === false;
const display = compliance?.complianceDisplay ?? undefined;
const shortDays = display?.shortDays ?? 7;
const longDays = display?.longDays ?? 30;
const rate7 = display?.short.rate ?? compliance?.compliance7?.rate ?? 0;
@@ -505,11 +506,16 @@ export function MedicationCard({
}
asNeeded={medication.asNeeded === true}
compliance={
compliance ? { rate7, rate30, streak, shortDays, longDays } : null
compliance && !complianceNotApplicable
? { rate7, rate30, streak, shortDays, longDays }
: null
}
complianceError={complianceError}
complianceNotApplicable={complianceNotApplicable}
onRetryCompliance={refetchCompliance}
currentCycle={display?.currentCycle ?? null}
currentCycle={
complianceNotApplicable ? null : (display?.currentCycle ?? null)
}
lowStockRunwayDays={lowStockRunwayDays}
intakeLoading={intakeLoading}
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
@@ -218,10 +218,12 @@ export function MedicationTable({
// every row at once. Same key + fetcher as the per-row hook.
const { data: complianceRows } = useMedicationComplianceSummaryAll();
const shortRateById = new Map<string, number>(
(complianceRows ?? []).map((row) => [
row.medicationId,
row.complianceDisplay?.short.rate ?? row.compliance7?.rate ?? 0,
]),
(complianceRows ?? []).flatMap((row) => {
if (row.applicable === false) return [];
const rate =
row.complianceDisplay?.short.rate ?? row.compliance7?.rate ?? null;
return rate === null ? [] : [[row.medicationId, rate] as const];
}),
);
// Re-render once a minute so the status pill tracks wall-clock
@@ -412,7 +414,8 @@ function MedicationTableRowItem({
// SAME batched compliance source as the cards.
const { data: compliance } = useMedicationComplianceSummary(medication.id);
const display = compliance?.complianceDisplay;
const complianceNotApplicable = compliance?.applicable === false;
const display = compliance?.complianceDisplay ?? undefined;
const shortDays = display?.shortDays ?? 7;
const longDays = display?.longDays ?? 30;
const rateShort = display?.short.rate ?? compliance?.compliance7?.rate ?? 0;
@@ -568,7 +571,14 @@ function MedicationTableRowItem({
// Therapietreue cell — the same two cadence-scaled windows the card
// bars show, compacted to label + mini bar + percentage per row.
const complianceCell = compliance ? (
const complianceCell = complianceNotApplicable ? (
<span
className="text-muted-foreground text-sm"
data-slot="medication-table-compliance-not-applicable"
>
{t("medications.complianceNotApplicable")}
</span>
) : compliance ? (
<div className="flex flex-col gap-1">
{(
[
+52 -1
View File
@@ -13,6 +13,7 @@ import { prisma } from "@/lib/db";
import {
buildComplianceMedicationContext,
buildMedicationComplianceBundle,
expectsDoses,
lastNonSkippedTakenAt,
type ComplianceDisplay,
type ComplianceResult,
@@ -33,12 +34,38 @@ const EVENT_FETCH_WINDOW_DAYS = 366;
/** The cached response body (the exact public wire shape). */
export interface CompliancePayload {
/**
* False only when adherence is not meaningful for this medication.
* Today that means a scheduled (non-PRN) medication with no local schedule,
* such as an Apple Health mirror whose cadence remains source-owned.
*/
applicable: boolean;
notApplicableReason: "NO_LOCAL_SCHEDULE" | null;
compliance7: ComplianceResult;
compliance30: ComplianceResult;
dailyCompliance: Record<string, DailyComplianceEntry>;
complianceDisplay: ComplianceDisplay;
complianceDisplay: ComplianceDisplay | null;
}
/**
* Legacy-compatible placeholder for a medication whose adherence percentage
* is explicitly not applicable.
*
* Released iOS 1.0.3 requires `compliance7` and `compliance30` to decode as
* non-null `ComplianceWindowResult` objects. Keep those fields structurally
* present while `applicable=false` remains the authoritative semantic signal
* for clients that understand the newer contract. All-zero values avoid
* reviving the old vacuous 100% result.
*/
const NOT_APPLICABLE_LEGACY_COMPLIANCE: ComplianceResult = {
totalExpected: 0,
taken: 0,
skipped: 0,
missed: 0,
rate: 0,
streak: 0,
};
/** The medication slice the payload builder consumes. */
export interface ComplianceMedicationInput {
id: string;
@@ -46,6 +73,7 @@ export interface ComplianceMedicationInput {
startsOn: Date | null;
endsOn: Date | null;
oneShot: boolean;
asNeeded: boolean;
schedules: Parameters<typeof buildMedicationComplianceBundle>[1];
/** v1.16.3 — archived schedule eras for era-aware compliance. */
scheduleRevisions?: Parameters<
@@ -132,6 +160,27 @@ export async function buildCompliancePayload(
userId: string,
userTz: string,
): Promise<CompliancePayload> {
// A scheduled medication with ZERO local schedules has no local expected
// dose grid. This is a real shape for Apple Health mirrors: the source owns
// the cadence while HealthLog stores the mirrored medication itself. The
// legacy arithmetic intentionally answers 100 % when there are zero
// expected doses, which is mathematically consistent but misleading when
// rendered as adherence. Mark this shape explicitly not-applicable BEFORE
// reading intake history or invoking the arithmetic.
//
// Keep PRN behaviour unchanged. The batched endpoint already excludes PRN
// medications, and a direct per-id read retains its existing payload.
if (!medication.asNeeded && !expectsDoses(medication)) {
return {
applicable: false,
notApplicableReason: "NO_LOCAL_SCHEDULE",
compliance7: NOT_APPLICABLE_LEGACY_COMPLIANCE,
compliance30: NOT_APPLICABLE_LEGACY_COMPLIANCE,
dailyCompliance: {},
complianceDisplay: null,
};
}
// v1.15.9 — pin a single `now` and thread it into every cadence
// computation so no block can straddle a day boundary on a slow request.
const now = new Date();
@@ -236,6 +285,8 @@ export async function buildCompliancePayload(
}
return {
applicable: true,
notApplicableReason: null,
compliance7: bundle.compliance7,
compliance30: bundle.compliance30,
dailyCompliance,
+2 -2
View File
@@ -1413,7 +1413,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Medications"],
summary: "Batched adherence read for every medication of the caller",
description:
"Returns one compact adherence row per medication the caller owns (active + paused), ordered by `createdAt DESC` — the single round trip the medication cards consume instead of fanning out one `/api/medications/{id}/compliance` request per card. Each row carries the 7-/30-day summaries and the cadence-scaled display block; the per-day grid stays on the per-medication endpoint. Pure computation — no writes. Served through the same per-medication server cache as the per-id read, so the two endpoints warm each other.",
"Returns one compact row per non-PRN medication. A scheduled medication with no local schedule remains in the array with applicable=false and reason NO_LOCAL_SCHEDULE. compliance7/compliance30 stay non-null as all-zero compatibility placeholders for released clients; aware clients must ignore those percentages when applicable=false. complianceDisplay is null.",
responses: {
...recordRefusal(),
"200": {
@@ -1436,7 +1436,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Medications"],
summary: "Adherence read for a medication",
description:
"Returns the 7- and 30-day adherence summaries, the per-day compliance grid for the history glyph track, and the two-row display block. Pure computation — no writes. Day boundaries are resolved in the user's IANA timezone, and the expected-dose denominator is cadence-aware (RRULE / rolling / one-shot / PRN / cyclic) and clamped to the medication's `createdAt`. Read `compliance30` for the headline 30-day taken-vs-expected percentage; build the per-day glyph track from `dailyCompliance` (draw a cell only where `due === true`).",
"Returns the cadence-aware adherence payload. A scheduled medication with no local schedule returns applicable=false, reason NO_LOCAL_SCHEDULE, non-null all-zero compliance7/compliance30 compatibility placeholders, a null display block, and an empty daily grid. Aware clients must not render the placeholder percentages. Medications with a local schedule preserve the existing arithmetic, including windows in which no dose is due.",
requestParams: {
path: z.object({ id: z.string() }),
},
+38 -9
View File
@@ -755,32 +755,61 @@ export const complianceDisplay = z
export const medicationComplianceResponse = z
.object({
compliance7: complianceResult,
compliance30: complianceResult,
applicable: z
.boolean()
.describe(
"Whether a local adherence percentage applies to this medication.",
),
notApplicableReason: z
.literal("NO_LOCAL_SCHEDULE")
.nullable()
.describe(
"Reason adherence is not applicable. NO_LOCAL_SCHEDULE means the medication has no HealthLog-owned expected-dose grid.",
),
compliance7: complianceResult.describe(
"Seven-day adherence summary. When applicable is false, this remains a non-null all-zero compatibility placeholder for released clients and must not be rendered as a percentage.",
),
compliance30: complianceResult.describe(
"Thirty-day adherence summary. When applicable is false, this remains a non-null all-zero compatibility placeholder for released clients and must not be rendered as a percentage.",
),
dailyCompliance: z
.record(z.string(), dailyComplianceEntry)
.describe(
"Flat per-day map keyed `YYYY-MM-DD` in the user timezone, one entry per day for up to 90 days back, clamped to the medication's `createdAt` (so a recently-created med has fewer entries). No weekly/monthly collapse — this is the raw daily grid.",
"Flat per-day map keyed YYYY-MM-DD in the user timezone. Empty when adherence is not applicable.",
),
complianceDisplay: complianceDisplay
.nullable()
.describe(
"Cadence-scaled display block, or null when adherence is not applicable.",
),
complianceDisplay,
})
.meta({
id: "MedicationComplianceResponse",
description:
"Adherence read for a single medication. `compliance30` is the authoritative 30-day taken-vs-expected summary; `dailyCompliance` is the per-day grid for the history glyph track. The graded raw→week→month→year series used elsewhere for AI prompts does NOT apply here — this response is never downsampled.",
"Adherence read for a single medication. A scheduled medication with no local schedule returns applicable=false and NO_LOCAL_SCHEDULE rather than a vacuous 100 percent. Scheduled medications that have a local schedule keep the existing cadence-aware arithmetic, including windows with no doses due. PRN behaviour is unchanged.",
});
export const medicationComplianceSummaryEntry = z
.object({
medicationId: z.string(),
compliance7: complianceResult,
compliance30: complianceResult,
complianceDisplay,
applicable: z
.boolean()
.describe(
"Whether this medication has a local expected-dose grid and therefore a displayable adherence percentage.",
),
notApplicableReason: z.literal("NO_LOCAL_SCHEDULE").nullable(),
compliance7: complianceResult.describe(
"Seven-day adherence summary, or an all-zero compatibility placeholder when applicable is false.",
),
compliance30: complianceResult.describe(
"Thirty-day adherence summary, or an all-zero compatibility placeholder when applicable is false.",
),
complianceDisplay: complianceDisplay.nullable(),
})
.meta({
id: "MedicationComplianceSummaryEntry",
description:
"Compact per-medication adherence row for the batched card read: the 7-/30-day summaries plus the cadence-scaled display block. The per-day `dailyCompliance` grid is NOT on this shape — read the per-medication `/compliance` endpoint for the history glyph track.",
"Compact per-medication adherence row for the batched card read. Zero-local-schedule scheduled medications remain present with applicable=false so clients render a settled not-applicable state instead of a loading skeleton.",
});
// v1.16.5 — schedule-era management (the Zeitplan-tab history timeline).
@@ -15,9 +15,11 @@ import type { ComplianceDisplay } from "@/lib/analytics/compliance";
*/
export interface MedicationComplianceSummaryEntry {
medicationId: string;
applicable: boolean;
notApplicableReason: "NO_LOCAL_SCHEDULE" | null;
compliance7: { rate: number; streak: number };
compliance30: { rate: number };
complianceDisplay?: ComplianceDisplay;
complianceDisplay: ComplianceDisplay | null;
}
/**
@@ -228,7 +228,10 @@ export const createMedicationSchema = z
path: ["asNeeded"],
})
.refine(
(b) => b.asNeeded === true || (!!b.schedules && b.schedules.length >= 1),
(b) =>
(b.externalSource === "APPLE_HEALTH" && b.externalId !== undefined) ||
b.asNeeded === true ||
(!!b.schedules && b.schedules.length >= 1),
{
message: "Mindestens ein Zeitfenster",
path: ["schedules"],