mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
fix: mark mirror adherence not applicable
This commit is contained in:
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">
|
||||
{(
|
||||
[
|
||||
|
||||
Reference in new issue
Block a user