mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
fix(medications): restore the two-row compliance display, scaled to cadence
v1.8.5 swapped the long-standing 7-/30-day compliance rows for a per-dose adherence timeline on sparse-cadence medications. The timeline breaks the card layout and reads as restless, so every medication is back to two percentage rows. The two windows are no longer hardcoded — they scale with the dosing cadence. selectComplianceWindows walks a 7/30 -> 30/90 -> 90/365 ladder and returns the densest rung whose both windows clear MIN_STABLE_DOSES realised expected doses, so a daily med stays on 7/30, a weekly med steps up to 30/90, and a rolling 35-day injection lands on the widest 90/365 rung. When even the top rung can't clear the floor (a brand-new or very rare med) the widest rung is returned so the card still shows two honest rows. The row labels follow the chosen windows via a parametrised complianceWindow string. The compliance percentage math is unchanged: each row is the existing cadence-aware calculateCompliance over the selected window. compliance7 / compliance30 stay on the wire verbatim for iOS and the Health Score. - compliance.ts: replace the timeline display with selectComplianceWindows + the window ladder; buildComplianceDisplay now returns the two scaled rows - remove the dose-adherence-timeline component and its now-dead strings - medication-compliance-bars: parametrise the row labels on the day-counts - switch the standard and GLP-1 cards back to the always-two-row bars
This commit is contained in:
1 parent
6c44d4a2c1
commit
e5b873833a
15 files changed
+376
-627
No files matched your search
@@ -56,6 +56,23 @@ describe("medication card-parts — shared presentational components", () => {
|
||||
expect(html).not.toContain("text-orange-400");
|
||||
});
|
||||
|
||||
it("compliance bars scale the row labels to the chosen windows", () => {
|
||||
// v1.8.6 — a sparse cadence steps both windows up; the labels follow.
|
||||
const html = render(
|
||||
<MedicationComplianceBars
|
||||
rate7={75}
|
||||
rate30={80}
|
||||
streak={0}
|
||||
shortDays={90}
|
||||
longDays={365}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("90-day compliance");
|
||||
expect(html).toContain("365-day compliance");
|
||||
expect(html).not.toContain("7-day compliance");
|
||||
expect(html).not.toContain("30-day compliance");
|
||||
});
|
||||
|
||||
it("compliance bars hide the streak flame when streak is zero", () => {
|
||||
const html = render(
|
||||
<MedicationComplianceBars rate7={90} rate30={88} streak={0} />,
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Flame } from "lucide-react";
|
||||
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.8.5 — uptime-style per-dose adherence strip for sparse cadences.
|
||||
*
|
||||
* The medication cards keep the 7-/30-day percentage bars for dense
|
||||
* cadences (daily / multi-daily / weekday meds) where the denominator is
|
||||
* large enough for the percentage to be stable. For sparse cadences
|
||||
* (weekly, bi-/tri-weekly, rolling 35-day injections) a rolling percentage
|
||||
* swings violently off one sample and tells the user nothing; the server
|
||||
* flips `complianceDisplay.mode` to `"timeline"` and this strip takes over.
|
||||
*
|
||||
* Each cell is one EXPECTED dose (not a calendar day), oldest → newest:
|
||||
* taken=green / missed=red / skipped=grey / upcoming=outline. The color
|
||||
* tokens and the tap-to-pin tooltip mirror the 90-day compliance heatmap
|
||||
* (`src/components/charts/compliance-heatmap.tsx`) so the two surfaces read
|
||||
* the same. Plain flex divs, not Recharts — a fixed-size status-cell row
|
||||
* needs no chart container, axis, or tooltip plumbing.
|
||||
*/
|
||||
|
||||
export type DoseStatus = "taken" | "missed" | "skipped" | "upcoming";
|
||||
export type DoseTiming = "early" | "on_time" | "late" | "very_late" | "missed";
|
||||
|
||||
export interface DoseAdherenceCell {
|
||||
scheduledFor: string;
|
||||
status: DoseStatus;
|
||||
takenAt: string | null;
|
||||
timing: DoseTiming | null;
|
||||
site: string | null;
|
||||
}
|
||||
|
||||
interface DoseAdherenceTimelineProps {
|
||||
doses: DoseAdherenceCell[];
|
||||
summary: { taken: number; total: number; doseStreak: number };
|
||||
}
|
||||
|
||||
// v1.4.27 MB7 / CF-10 precedent — clear the 14 px touch floor (with the
|
||||
// gap the row stays tap-friendly on narrow viewports). Mirrors the
|
||||
// heatmap's `CELL_FLOOR_PX`.
|
||||
const CELL_PX = 14;
|
||||
|
||||
/** Token mirror of the heatmap's status palette. */
|
||||
function cellColor(status: DoseStatus, timing: DoseTiming | null): string {
|
||||
if (status === "upcoming") return "transparent";
|
||||
if (status === "skipped") return "var(--secondary)";
|
||||
if (status === "missed") return "var(--dracula-red)";
|
||||
// taken — shade by punctuality, same ladder the heatmap uses.
|
||||
if (timing === "very_late") return "var(--dracula-orange)";
|
||||
if (timing === "late") return "var(--dracula-yellow)";
|
||||
return "var(--dracula-green)";
|
||||
}
|
||||
|
||||
export function DoseAdherenceTimeline({
|
||||
doses,
|
||||
summary,
|
||||
}: DoseAdherenceTimelineProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [tooltip, setTooltip] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
pinned?: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// Mirror the heatmap: an outside tap clears a pinned tooltip so a touch
|
||||
// user can dismiss the per-cell detail. Gated on `pinned` so the hover
|
||||
// flow never pays the indirection.
|
||||
useEffect(() => {
|
||||
if (!tooltip?.pinned) return;
|
||||
const handlePointer = (event: PointerEvent) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
if (!container.contains(event.target as Node)) setTooltip(null);
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointer, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointer, true);
|
||||
};
|
||||
}, [tooltip?.pinned]);
|
||||
|
||||
function statusLabel(status: DoseStatus): string {
|
||||
if (status === "taken") return t("medications.doseTaken");
|
||||
if (status === "missed") return t("medications.doseMissed");
|
||||
if (status === "skipped") return t("medications.doseSkipped");
|
||||
return t("medications.doseUpcoming");
|
||||
}
|
||||
|
||||
function cellText(cell: DoseAdherenceCell): string {
|
||||
const when = fmt.dateTime(cell.scheduledFor);
|
||||
return `${when} — ${statusLabel(cell.status)}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2" ref={containerRef}>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="flex flex-wrap gap-1"
|
||||
role="img"
|
||||
aria-label={t("medications.doseTimelineAriaLabel")}
|
||||
>
|
||||
{doses.map((cell, i) => {
|
||||
const isUpcoming = cell.status === "upcoming";
|
||||
return (
|
||||
<div
|
||||
key={`${cell.scheduledFor}-${i}`}
|
||||
className={`shrink-0 cursor-pointer rounded-sm ${
|
||||
isUpcoming ? "border-border border border-dashed" : ""
|
||||
}`}
|
||||
style={{
|
||||
width: CELL_PX,
|
||||
height: CELL_PX,
|
||||
backgroundColor: cellColor(cell.status, cell.timing),
|
||||
}}
|
||||
onPointerEnter={(e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
setTooltip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
text: cellText(cell),
|
||||
});
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
setTooltip((prev) => (prev?.pinned ? prev : null));
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.pointerType !== "touch") return;
|
||||
setTooltip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
text: cellText(cell),
|
||||
pinned: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{tooltip && (
|
||||
<div
|
||||
className="bg-popover text-popover-foreground border-border pointer-events-none fixed z-50 rounded-md border px-2 py-1 text-xs shadow-md"
|
||||
style={{ left: tooltip.x + 10, top: tooltip.y - 30 }}
|
||||
>
|
||||
{tooltip.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground flex items-center gap-3 text-xs">
|
||||
<span>
|
||||
{t("medications.lastNDoses", {
|
||||
total: summary.total,
|
||||
taken: summary.taken,
|
||||
})}
|
||||
</span>
|
||||
{summary.doseStreak > 0 && (
|
||||
<span className="text-dracula-orange flex items-center gap-1 font-medium">
|
||||
<Flame className="h-3.5 w-3.5" />
|
||||
{t("medications.doseStreak", { count: summary.doseStreak })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,13 +7,28 @@ interface MedicationComplianceBarsProps {
|
||||
rate7: number;
|
||||
rate30: number;
|
||||
streak: number;
|
||||
/**
|
||||
* v1.8.6 — the span of the short row in days. The server scales the two
|
||||
* windows to the dosing cadence (7 / 30 for dense meds, stepping up to
|
||||
* 90 / 365 for sparse ones), so the labels follow the chosen windows
|
||||
* instead of a hardcoded 7 / 30. Defaults to 7 / 30 so older callers and
|
||||
* fixtures keep their prior labels.
|
||||
*/
|
||||
shortDays?: number;
|
||||
/** v1.8.6 — the span of the long row in days. */
|
||||
longDays?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared 7-day / 30-day compliance bars plus the day-streak flame for the
|
||||
* Shared two-row compliance bars plus the day-streak flame for the
|
||||
* medication cards. Extracted from the generic and GLP-1 cards so the bars
|
||||
* are structurally identical rather than hand-synced.
|
||||
*
|
||||
* v1.8.6 — the two windows scale with the dosing cadence. A daily med shows
|
||||
* 7-day / 30-day; a weekly med 30-day / 90-day; a rare injection up to a
|
||||
* 365-day long window. The labels are parametrised on the chosen day-counts
|
||||
* so each row names the window it actually covers.
|
||||
*
|
||||
* The streak flame uses the canonical `text-dracula-orange` warning/streak
|
||||
* token (globals.css). The generic card historically drifted onto Tailwind
|
||||
* stock `text-orange-400`; unifying here closes that token gap.
|
||||
@@ -22,38 +37,31 @@ export function MedicationComplianceBars({
|
||||
rate7,
|
||||
rate30,
|
||||
streak,
|
||||
shortDays = 7,
|
||||
longDays = 30,
|
||||
}: MedicationComplianceBarsProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const shortLabel = t("medications.complianceWindow", { days: shortDays });
|
||||
const longLabel = t("medications.complianceWindow", { days: longDays });
|
||||
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t("medications.compliance7d")}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{shortLabel}</span>
|
||||
<span className="font-medium">{rate7}%</span>
|
||||
</div>
|
||||
{/* aria-label so the bar has an accessible name. */}
|
||||
<Progress
|
||||
value={rate7}
|
||||
className="h-2"
|
||||
aria-label={t("medications.compliance7d")}
|
||||
/>
|
||||
<Progress value={rate7} className="h-2" aria-label={shortLabel} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t("medications.compliance30d")}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{longLabel}</span>
|
||||
<span className="font-medium">{rate30}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={rate30}
|
||||
className="h-2"
|
||||
aria-label={t("medications.compliance30d")}
|
||||
/>
|
||||
<Progress value={rate30} className="h-2" aria-label={longLabel} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
|
||||
@@ -10,10 +10,6 @@ import { MedicationCardMenu } from "@/components/medications/medication-card-men
|
||||
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
|
||||
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
|
||||
import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars";
|
||||
import {
|
||||
DoseAdherenceTimeline,
|
||||
type DoseAdherenceCell,
|
||||
} from "@/components/medications/card-parts/dose-adherence-timeline";
|
||||
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
@@ -104,21 +100,24 @@ export interface Glp1Medication {
|
||||
}
|
||||
|
||||
interface ComplianceDisplay {
|
||||
mode: "percent" | "timeline";
|
||||
expected7: number;
|
||||
expected30: number;
|
||||
shortDays: number;
|
||||
longDays: number;
|
||||
expectedShort: number;
|
||||
expectedLong: number;
|
||||
minStableDoses: number;
|
||||
doseTimeline: DoseAdherenceCell[];
|
||||
recentDoseSummary: { taken: number; total: number; doseStreak: number };
|
||||
short: { rate: number; streak: number };
|
||||
long: { rate: number };
|
||||
}
|
||||
|
||||
interface ComplianceData {
|
||||
compliance7: { rate: number; streak: number };
|
||||
compliance30: { rate: number };
|
||||
/**
|
||||
* v1.8.5 — server-decided render mode. GLP-1 injections are commonly
|
||||
* weekly, so this card flips to the per-dose timeline more often than
|
||||
* the generic one. Additive — older mocks omit it.
|
||||
* v1.8.6 — the two compliance windows scaled to the dosing cadence. GLP-1
|
||||
* injections are commonly weekly, so this card's windows step up to the
|
||||
* 30-/90-day rung where the bars stay meaningful. Additive — older mocks
|
||||
* omit it, in which case the card falls back to the static 7-/30-day
|
||||
* fields.
|
||||
*/
|
||||
complianceDisplay?: ComplianceDisplay;
|
||||
}
|
||||
@@ -309,9 +308,15 @@ export function Glp1MedicationCard({
|
||||
const schedule = medication.schedules[0] ?? null;
|
||||
const now = new Date();
|
||||
const next = nextInjectionFromServer(medication.nextDueAt, now);
|
||||
const rate7 = compliance?.compliance7?.rate ?? 0;
|
||||
const rate30 = compliance?.compliance30?.rate ?? 0;
|
||||
const streak = compliance?.compliance7?.streak ?? 0;
|
||||
// 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 shortDays = display?.shortDays ?? 7;
|
||||
const longDays = display?.longDays ?? 30;
|
||||
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;
|
||||
|
||||
// v1.4.37 W4b — symmetric take-now / overdue pill with the generic
|
||||
// card. Sort schedules by `windowStart` so the most-actionable
|
||||
@@ -480,24 +485,19 @@ export function Glp1MedicationCard({
|
||||
Glp1InventoryDTO slot on /api/medications/[id]/glp1 stays
|
||||
in the response shape; only the web mounts are gone. */}
|
||||
|
||||
{/* Compliance region — shared decision with the generic card.
|
||||
Dense cadences keep the bars; sparse cadences (most weekly
|
||||
GLP-1 injections) swap to the per-dose uptime strip. Falls back
|
||||
to the bars when the older payload omits `complianceDisplay`. */}
|
||||
{medication.active &&
|
||||
compliance &&
|
||||
(compliance.complianceDisplay?.mode === "timeline" ? (
|
||||
<DoseAdherenceTimeline
|
||||
doses={compliance.complianceDisplay.doseTimeline}
|
||||
summary={compliance.complianceDisplay.recentDoseSummary}
|
||||
/>
|
||||
) : (
|
||||
<MedicationComplianceBars
|
||||
rate7={rate7}
|
||||
rate30={rate30}
|
||||
streak={streak}
|
||||
/>
|
||||
))}
|
||||
{/* Compliance bars — always two rows, shared with the generic
|
||||
card so the page grid stays harmonious. The server scales the
|
||||
two windows to the dosing cadence; most weekly GLP-1 injections
|
||||
land on the 30-/90-day rung where the bars stay meaningful. */}
|
||||
{medication.active && compliance && (
|
||||
<MedicationComplianceBars
|
||||
rate7={rate7}
|
||||
rate30={rate30}
|
||||
streak={streak}
|
||||
shortDays={shortDays}
|
||||
longDays={longDays}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Primary actions row — shared with the generic medication
|
||||
card. The GLP-1-specific side-effect quick-log lives in the
|
||||
|
||||
@@ -9,10 +9,6 @@ import { MedicationCardMenu } from "@/components/medications/medication-card-men
|
||||
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
|
||||
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
|
||||
import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars";
|
||||
import {
|
||||
DoseAdherenceTimeline,
|
||||
type DoseAdherenceCell,
|
||||
} from "@/components/medications/card-parts/dose-adherence-timeline";
|
||||
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
|
||||
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
||||
import { formatDateTime, formatTime } from "@/lib/format";
|
||||
@@ -76,12 +72,13 @@ interface Medication {
|
||||
}
|
||||
|
||||
interface ComplianceDisplay {
|
||||
mode: "percent" | "timeline";
|
||||
expected7: number;
|
||||
expected30: number;
|
||||
shortDays: number;
|
||||
longDays: number;
|
||||
expectedShort: number;
|
||||
expectedLong: number;
|
||||
minStableDoses: number;
|
||||
doseTimeline: DoseAdherenceCell[];
|
||||
recentDoseSummary: { taken: number; total: number; doseStreak: number };
|
||||
short: { rate: number; streak: number };
|
||||
long: { rate: number };
|
||||
}
|
||||
|
||||
interface ComplianceData {
|
||||
@@ -97,9 +94,11 @@ interface ComplianceData {
|
||||
rate: number;
|
||||
};
|
||||
/**
|
||||
* v1.8.5 — server-decided render mode for the compliance region. Dense
|
||||
* cadences keep the 7-/30-day bars (`"percent"`); sparse cadences swap to
|
||||
* the per-dose uptime strip (`"timeline"`). Additive — older mocks omit it.
|
||||
* v1.8.6 — the two compliance windows scaled to the dosing cadence. The
|
||||
* card always shows two percentage rows; `shortDays` / `longDays` name the
|
||||
* windows and `short` / `long` carry their rates. Additive — older mocks
|
||||
* omit it, in which case the card falls back to the static 7-/30-day
|
||||
* `compliance7` / `compliance30` fields.
|
||||
*/
|
||||
complianceDisplay?: ComplianceDisplay;
|
||||
}
|
||||
@@ -222,9 +221,15 @@ export function MedicationCard({
|
||||
}
|
||||
}
|
||||
|
||||
const rate7 = compliance?.compliance7?.rate ?? 0;
|
||||
const rate30 = compliance?.compliance30?.rate ?? 0;
|
||||
const streak = compliance?.compliance7?.streak ?? 0;
|
||||
// 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 shortDays = display?.shortDays ?? 7;
|
||||
const longDays = display?.longDays ?? 30;
|
||||
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;
|
||||
const categoryLabel = getMedicationCategoryLabel(medication.category, t);
|
||||
const sortedSchedules = [...medication.schedules].sort(
|
||||
(a, b) =>
|
||||
@@ -391,24 +396,19 @@ export function MedicationCard({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Compliance region — the server decides whether the percentage
|
||||
bars stay (dense cadences) or the per-dose uptime strip takes
|
||||
over (sparse cadences). Falls back to the bars when the older
|
||||
payload omits `complianceDisplay`. */}
|
||||
{medication.active &&
|
||||
compliance &&
|
||||
(compliance.complianceDisplay?.mode === "timeline" ? (
|
||||
<DoseAdherenceTimeline
|
||||
doses={compliance.complianceDisplay.doseTimeline}
|
||||
summary={compliance.complianceDisplay.recentDoseSummary}
|
||||
/>
|
||||
) : (
|
||||
<MedicationComplianceBars
|
||||
rate7={rate7}
|
||||
rate30={rate30}
|
||||
streak={streak}
|
||||
/>
|
||||
))}
|
||||
{/* Compliance bars — always two rows. The server scales the two
|
||||
windows to the dosing cadence (7 / 30 days for dense meds,
|
||||
stepping up to 90 / 365 for sparse ones); the labels follow the
|
||||
chosen windows. */}
|
||||
{medication.active && compliance && (
|
||||
<MedicationComplianceBars
|
||||
rate7={rate7}
|
||||
rate30={rate30}
|
||||
streak={streak}
|
||||
shortDays={shortDays}
|
||||
longDays={longDays}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Quick actions — primary buttons of the medication card. */}
|
||||
{medication.active && (
|
||||
|
||||
Reference in new issue
Block a user