mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #1007 from muhdusama/medfix/apple-health-scheduled-mirror
fix: allow scheduled Apple Health medication mirrors
This commit is contained in:
28 files changed
+531
-62
No files matched your search
@@ -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">
|
||||
{(
|
||||
[
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() }),
|
||||
},
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in new issue
Block a user