fix: mark mirror adherence not applicable

This commit is contained in:
muhdusama committed 2026-09-20 21:48:20 +10:00
1 parent c223b6d77d
commit 41d47c3184
24 files changed
+416 -69

No files matched your search

@@ -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 } | null;
compliance30: { rate: number } | null;
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">
{(
[