mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
refactor(meds): extract shared card parts for structural symmetry
The generic medication card and the GLP-1 variant carried ~120 lines of hand-synced JSX for the status pill, compliance bars, primary intake actions, and state badges. Lift these into single-purpose presentational components under card-parts/ consumed by both cards, so symmetry is structural rather than maintained by hand. Unify the day-streak flame onto the canonical text-dracula-orange warning/streak token. The generic card had drifted onto Tailwind-stock text-orange-400; both cards now render the same token. No visual or behavioural change beyond the streak-token convergence.
This commit is contained in:
1 parent
44e83d22df
commit
3a5e157d41
7 files changed
+499
-289
No files matched your search
@@ -0,0 +1,208 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { MedicationCard } from "@/components/medications/medication-card";
|
||||
import {
|
||||
Glp1MedicationCard,
|
||||
type Glp1Medication,
|
||||
} from "@/components/medications/glp1-medication-card";
|
||||
import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars";
|
||||
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
|
||||
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
|
||||
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
|
||||
|
||||
/**
|
||||
* v1.7.2 — the medication-card status pill, compliance bars, intake-action
|
||||
* row, and state badges are shared presentational components consumed by
|
||||
* both the generic `<MedicationCard>` and the `<Glp1MedicationCard>`. The
|
||||
* symmetry between the two variants is now structural (one component, two
|
||||
* call sites) rather than two hand-synced JSX blocks.
|
||||
*
|
||||
* These tests pin the load-bearing seams of the extracted parts and the
|
||||
* unified streak/warning token so a regression can't reopen the drift.
|
||||
*/
|
||||
|
||||
function makeClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0, staleTime: Infinity },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode, client?: QueryClient) {
|
||||
const tree = client ? (
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
) : (
|
||||
node
|
||||
);
|
||||
return renderToStaticMarkup(<I18nProvider initialLocale="en">{tree}</I18nProvider>);
|
||||
}
|
||||
|
||||
describe("medication card-parts — shared presentational components", () => {
|
||||
it("compliance bars render the 7d / 30d labels and the day-streak flame", () => {
|
||||
const html = render(
|
||||
<MedicationComplianceBars rate7={90} rate30={88} streak={5} />,
|
||||
);
|
||||
expect(html).toContain("7-day compliance");
|
||||
expect(html).toContain("30-day compliance");
|
||||
expect(html).toContain("90%");
|
||||
expect(html).toContain("88%");
|
||||
expect(html).toContain("lucide-flame");
|
||||
// Canonical streak/warning token — NOT the Tailwind-stock drift.
|
||||
expect(html).toContain("text-dracula-orange");
|
||||
expect(html).not.toContain("text-orange-400");
|
||||
});
|
||||
|
||||
it("compliance bars hide the streak flame when streak is zero", () => {
|
||||
const html = render(
|
||||
<MedicationComplianceBars rate7={90} rate30={88} streak={0} />,
|
||||
);
|
||||
expect(html).not.toContain("lucide-flame");
|
||||
});
|
||||
|
||||
it("status pill stamps the success token + take-now glyph in window", () => {
|
||||
const html = render(
|
||||
<MedicationStatusPill
|
||||
status="in_window"
|
||||
windowStart="08:00"
|
||||
windowEnd="20:00"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Take now");
|
||||
expect(html).toContain("text-success");
|
||||
expect(html).toContain("lucide-circle-check");
|
||||
});
|
||||
|
||||
it("status pill stamps the warning token + glyph when very late", () => {
|
||||
const html = render(
|
||||
<MedicationStatusPill
|
||||
status="very_late"
|
||||
windowStart="08:00"
|
||||
windowEnd="20:00"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("text-warning");
|
||||
expect(html).toContain("lucide-triangle-alert");
|
||||
});
|
||||
|
||||
it("intake actions row carries exactly the take + skip buttons", () => {
|
||||
const html = render(
|
||||
<MedicationIntakeActions intakeLoading={null} onRecordIntake={() => {}} />,
|
||||
);
|
||||
expect(html).toContain("lucide-check");
|
||||
expect(html).toContain("lucide-skip-forward");
|
||||
expect(html).toContain("min-h-11");
|
||||
});
|
||||
|
||||
it("state badges surface the without-notification + paused labels", () => {
|
||||
const html = render(
|
||||
<MedicationStateBadges
|
||||
notificationsEnabled={false}
|
||||
active={false}
|
||||
pausedAt="2026-05-01T08:00:00.000Z"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Without notification");
|
||||
expect(html).toContain("Paused since");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Cross-variant streak-token parity: with a positive streak seeded, the
|
||||
* flame on BOTH the generic and the GLP-1 card resolves to the canonical
|
||||
* `text-dracula-orange`, and neither carries the legacy `text-orange-400`.
|
||||
*/
|
||||
describe("streak-token parity — generic vs GLP-1 card", () => {
|
||||
const ramipril = {
|
||||
id: "med-ramipril-streak",
|
||||
name: "Ramipril",
|
||||
dose: "5 mg",
|
||||
category: "BLOOD_PRESSURE",
|
||||
treatmentClass: undefined as string | undefined,
|
||||
active: true,
|
||||
notificationsEnabled: true,
|
||||
pausedAt: null,
|
||||
lastTakenAt: null,
|
||||
todayEventCount: 0,
|
||||
schedules: [
|
||||
{
|
||||
id: "s-ramipril-streak",
|
||||
windowStart: "00:00",
|
||||
windowEnd: "23:59",
|
||||
label: null,
|
||||
daysOfWeek: null,
|
||||
dose: "5 mg",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mounjaro: Glp1Medication = {
|
||||
id: "med-mounjaro-streak",
|
||||
name: "Mounjaro",
|
||||
dose: "7.5 mg",
|
||||
category: "HORMONE",
|
||||
treatmentClass: "GLP1",
|
||||
dosesPerUnit: 4,
|
||||
active: true,
|
||||
notificationsEnabled: true,
|
||||
pausedAt: null,
|
||||
lastTakenAt: null,
|
||||
todayEventCount: 0,
|
||||
schedules: [
|
||||
{
|
||||
id: "s-mounjaro-streak",
|
||||
windowStart: "00:00",
|
||||
windowEnd: "23:59",
|
||||
label: null,
|
||||
daysOfWeek: null,
|
||||
dose: "7.5 mg",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function seedStreak(client: QueryClient, medId: string) {
|
||||
client.setQueryData(["medications", medId, "compliance"], {
|
||||
compliance7: { rate: 90, streak: 4, totalExpected: 7, taken: 6 },
|
||||
compliance30: { rate: 88 },
|
||||
});
|
||||
}
|
||||
|
||||
it("both cards render the flame with the canonical token, never the drift", () => {
|
||||
const client = makeClient();
|
||||
seedStreak(client, ramipril.id);
|
||||
seedStreak(client, mounjaro.id);
|
||||
client.setQueryData(["medications", mounjaro.id, "glp1-details"], {
|
||||
doseChanges: [],
|
||||
recentIntakes: [],
|
||||
inventory: null,
|
||||
});
|
||||
|
||||
const ramiprilHtml = render(
|
||||
<MedicationCard
|
||||
medication={ramipril}
|
||||
onEdit={() => {}}
|
||||
onOpenHistory={() => {}}
|
||||
onOpenAdvanced={() => {}}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
const mounjaroHtml = render(
|
||||
<Glp1MedicationCard
|
||||
medication={mounjaro}
|
||||
onEdit={() => {}}
|
||||
onOpenHistory={() => {}}
|
||||
onOpenAdvanced={() => {}}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
|
||||
for (const html of [ramiprilHtml, mounjaroHtml]) {
|
||||
expect(html).toContain("lucide-flame");
|
||||
expect(html).toContain("text-dracula-orange");
|
||||
expect(html).not.toContain("text-orange-400");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Flame } from "lucide-react";
|
||||
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
interface MedicationComplianceBarsProps {
|
||||
rate7: number;
|
||||
rate30: number;
|
||||
streak: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared 7-day / 30-day 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export function MedicationComplianceBars({
|
||||
rate7,
|
||||
rate30,
|
||||
streak,
|
||||
}: MedicationComplianceBarsProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
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="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")}
|
||||
/>
|
||||
</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="font-medium">{rate30}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={rate30}
|
||||
className="h-2"
|
||||
aria-label={t("medications.compliance30d")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
{streak > 0 && (
|
||||
<span className="text-dracula-orange flex items-center gap-1 font-medium">
|
||||
<Flame className="h-3.5 w-3.5" />
|
||||
{streak} {t("medications.dayStreak")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Check, Loader2, SkipForward } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
interface MedicationIntakeActionsProps {
|
||||
/** "take" | "skip" while the matching request is in flight, else null. */
|
||||
intakeLoading: string | null;
|
||||
onRecordIntake: (skipped: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared primary take / skip action row for the medication cards. These are
|
||||
* the most-tapped controls in HealthLog, so they keep the full default
|
||||
* button height (`min-h-11`) to clear the WCAG 44-px tap-target floor.
|
||||
*
|
||||
* Extracted from the generic and GLP-1 cards so the action row is shared
|
||||
* structurally rather than kept byte-equivalent by hand.
|
||||
*/
|
||||
export function MedicationIntakeActions({
|
||||
intakeLoading,
|
||||
onRecordIntake,
|
||||
}: MedicationIntakeActionsProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="min-h-11 flex-1"
|
||||
onClick={() => onRecordIntake(false)}
|
||||
disabled={!!intakeLoading}
|
||||
>
|
||||
{intakeLoading === "take" ? (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Check className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{t("medications.taken")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11"
|
||||
onClick={() => onRecordIntake(true)}
|
||||
disabled={!!intakeLoading}
|
||||
>
|
||||
{intakeLoading === "skip" ? (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<SkipForward className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{t("medications.skipped")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
interface MedicationStateBadgesProps {
|
||||
notificationsEnabled: boolean;
|
||||
active: boolean;
|
||||
pausedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared "without notification" / "inactive" / "paused since …" badge pair
|
||||
* rendered in the medication-card header. Extracted from the generic and
|
||||
* GLP-1 cards so the two variants stay structurally symmetric instead of
|
||||
* hand-synced.
|
||||
*/
|
||||
export function MedicationStateBadges({
|
||||
notificationsEnabled,
|
||||
active,
|
||||
pausedAt,
|
||||
}: MedicationStateBadgesProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
{!notificationsEnabled && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("medications.withoutNotification")}
|
||||
</Badge>
|
||||
)}
|
||||
{!active && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{pausedAt
|
||||
? `${t("medications.pausedSince")} ${formatDateTime(pausedAt)}`
|
||||
: t("medications.inactive")}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { AlertCircle, AlertTriangle, CircleCheck } from "lucide-react";
|
||||
|
||||
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { MedicationWindowStatus } from "@/lib/medications/window-status";
|
||||
|
||||
interface MedicationStatusPillProps {
|
||||
/** Non-null window status; the caller guards `status &&` before rendering. */
|
||||
status: Exclude<MedicationWindowStatus, null>;
|
||||
windowStart: string;
|
||||
windowEnd: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared take-now / overdue / very-overdue status pill for the medication
|
||||
* cards. Pairs the colour token with a Lucide glyph so colour-blind users
|
||||
* (red-green) can disambiguate the three tiers — WCAG 1.4.1 (Use of Color).
|
||||
*
|
||||
* Extracted from the generic and GLP-1 cards so the pill markup is shared
|
||||
* structurally rather than kept byte-equivalent by hand.
|
||||
*/
|
||||
export function MedicationStatusPill({
|
||||
status,
|
||||
windowStart,
|
||||
windowEnd,
|
||||
}: MedicationStatusPillProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
|
||||
return (
|
||||
<p className="text-sm">
|
||||
<span
|
||||
className={
|
||||
"inline-flex items-center gap-1 font-medium " +
|
||||
(status === "in_window"
|
||||
? "text-success"
|
||||
: status === "late"
|
||||
? "text-dracula-yellow"
|
||||
: "text-warning")
|
||||
}
|
||||
>
|
||||
{status === "in_window" ? (
|
||||
<CircleCheck className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
) : status === "late" ? (
|
||||
<AlertCircle className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
) : (
|
||||
<AlertTriangle className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
{status === "in_window"
|
||||
? t("medications.takeNow")
|
||||
: status === "late"
|
||||
? t("medications.overdue")
|
||||
: t("medications.veryOverdue")}
|
||||
</span>
|
||||
<span className="text-muted-foreground hidden sm:inline">
|
||||
{" "}
|
||||
— {formatTimeWindowRange(windowStart, windowEnd, locale)}
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -2,22 +2,14 @@
|
||||
|
||||
import { useEffect, useReducer, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
CircleCheck,
|
||||
Flame,
|
||||
Loader2,
|
||||
SkipForward,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
|
||||
import { MedicationCardMenu } from "@/components/medications/medication-card-menu";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
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 { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
invalidateKeys,
|
||||
@@ -25,7 +17,6 @@ import {
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { formatDateTime, formatTime } from "@/lib/format";
|
||||
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
||||
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
|
||||
import {
|
||||
nextInjectionSite,
|
||||
@@ -180,7 +171,7 @@ export function Glp1MedicationCard({
|
||||
onLogSideEffect,
|
||||
}: Glp1MedicationCardProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { t, locale } = useTranslations();
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const [intakeLoading, setIntakeLoading] = useState<string | null>(null);
|
||||
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
|
||||
@@ -312,28 +303,22 @@ export function Glp1MedicationCard({
|
||||
}
|
||||
|
||||
const stateBadges = (
|
||||
<>
|
||||
{!medication.notificationsEnabled && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("medications.withoutNotification")}
|
||||
</Badge>
|
||||
)}
|
||||
{!medication.active && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{medication.pausedAt
|
||||
? `${t("medications.pausedSince")} ${formatDateTime(medication.pausedAt)}`
|
||||
: t("medications.inactive")}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
<MedicationStateBadges
|
||||
notificationsEnabled={medication.notificationsEnabled}
|
||||
active={medication.active}
|
||||
pausedAt={medication.pausedAt}
|
||||
/>
|
||||
);
|
||||
|
||||
// v1.7.2 W3 — the four former header icon-buttons (open / edit /
|
||||
// history / advanced) plus the GLP-1 side-effect kebab collapse into a
|
||||
// SINGLE overflow menu so the GLP-1 card stays byte-symmetric with the
|
||||
// generic medication card. The card header itself links to the detail
|
||||
// page (the former chevron target). The side-effect quick-log folds in
|
||||
// as a last menu item when `onLogSideEffect` is wired.
|
||||
// The four former header icon-buttons (open / edit / history /
|
||||
// advanced) collapse into a SINGLE overflow menu so the GLP-1 card
|
||||
// stays symmetric with the generic medication card. The card header
|
||||
// itself links to the detail page (the former chevron target). The
|
||||
// optional side-effect quick-log would fold in as a last menu item
|
||||
// when `onLogSideEffect` is wired, but the medications list page does
|
||||
// not wire it — side-effect logging lives on the detail page's
|
||||
// SideEffectsSection — so the menu renders the same items as the
|
||||
// generic card.
|
||||
const headerActions = (
|
||||
<MedicationCardMenu
|
||||
onEdit={() => onEdit(medication)}
|
||||
@@ -360,54 +345,17 @@ export function Glp1MedicationCard({
|
||||
/>
|
||||
|
||||
<CardContent className="space-y-3.5">
|
||||
{/* v1.4.37 W4b — take-now / overdue / very-overdue pill,
|
||||
byte-equivalent with the generic medication card. The
|
||||
GLP-1 card historically omitted this row, which made
|
||||
Mounjaro feel different from Ramipril on the medications
|
||||
grid even though the underlying schedule contract is
|
||||
the same shape. */}
|
||||
{/* Take-now / overdue / very-overdue pill, shared with the
|
||||
generic medication card. The GLP-1 card historically
|
||||
omitted this row, which made Mounjaro feel different from
|
||||
Ramipril on the medications grid even though the underlying
|
||||
schedule contract is the same shape. */}
|
||||
{currentWindowStatus.status && (
|
||||
<p className="text-sm">
|
||||
<span
|
||||
className={
|
||||
"inline-flex items-center gap-1 font-medium " +
|
||||
(currentWindowStatus.status === "in_window"
|
||||
? "text-success"
|
||||
: currentWindowStatus.status === "late"
|
||||
? "text-dracula-yellow"
|
||||
: "text-warning")
|
||||
}
|
||||
>
|
||||
{/* v1.4.38 W-D P2-3 — pair the colour with a Lucide
|
||||
glyph so colour-blind users (red-green) can
|
||||
disambiguate take-now from late from very-late.
|
||||
WCAG 1.4.1 (Use of Color). */}
|
||||
{currentWindowStatus.status === "in_window" ? (
|
||||
<CircleCheck className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
) : currentWindowStatus.status === "late" ? (
|
||||
<AlertCircle className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
) : (
|
||||
<AlertTriangle
|
||||
className="size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{currentWindowStatus.status === "in_window"
|
||||
? t("medications.takeNow")
|
||||
: currentWindowStatus.status === "late"
|
||||
? t("medications.overdue")
|
||||
: t("medications.veryOverdue")}
|
||||
</span>
|
||||
<span className="text-muted-foreground hidden sm:inline">
|
||||
{" "}
|
||||
—{" "}
|
||||
{formatTimeWindowRange(
|
||||
currentWindowStatus.schedule!.windowStart,
|
||||
currentWindowStatus.schedule!.windowEnd,
|
||||
locale,
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
<MedicationStatusPill
|
||||
status={currentWindowStatus.status}
|
||||
windowStart={currentWindowStatus.schedule!.windowStart}
|
||||
windowEnd={currentWindowStatus.schedule!.windowEnd}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Injection state — last + next */}
|
||||
@@ -467,81 +415,25 @@ export function Glp1MedicationCard({
|
||||
Glp1InventoryDTO slot on /api/medications/[id]/glp1 stays
|
||||
in the response shape; only the web mounts are gone. */}
|
||||
|
||||
{/* Compliance bars — identical to the generic card so the page
|
||||
{/* Compliance bars — shared with the generic card so the page
|
||||
grid stays harmonious. */}
|
||||
{medication.active && compliance && (
|
||||
<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="font-medium">{rate7}%</span>
|
||||
</div>
|
||||
{/* v1.4.33 IW9 — aria-label so the bar has an accessible name. */}
|
||||
<Progress
|
||||
value={rate7}
|
||||
className="h-2"
|
||||
aria-label={t("medications.compliance7d")}
|
||||
/>
|
||||
</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="font-medium">{rate30}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={rate30}
|
||||
className="h-2"
|
||||
aria-label={t("medications.compliance30d")}
|
||||
/>
|
||||
</div>
|
||||
{streak > 0 && (
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<span className="text-dracula-orange flex items-center gap-1 font-medium">
|
||||
<Flame className="h-3.5 w-3.5" />
|
||||
{streak} {t("medications.dayStreak")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<MedicationComplianceBars
|
||||
rate7={rate7}
|
||||
rate30={rate30}
|
||||
streak={streak}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Primary actions row — byte-equivalent with the generic
|
||||
medication card. v1.4.37 W4b moved the GLP-1-specific
|
||||
side-effect quick-log out of this row and into the
|
||||
header-actions overflow (kebab) so Mounjaro and Ramipril
|
||||
share the canonical two-button primary row. */}
|
||||
{/* Primary actions row — shared with the generic medication
|
||||
card. The GLP-1-specific side-effect quick-log lives in the
|
||||
header-actions overflow (kebab), not this row, so Mounjaro
|
||||
and Ramipril share the canonical two-button primary row. */}
|
||||
{medication.active && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="min-h-11 flex-1"
|
||||
onClick={() => recordIntake(false)}
|
||||
disabled={!!intakeLoading}
|
||||
>
|
||||
{intakeLoading === "take" ? (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Check className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{t("medications.taken")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11"
|
||||
onClick={() => recordIntake(true)}
|
||||
disabled={!!intakeLoading}
|
||||
>
|
||||
{intakeLoading === "skip" ? (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<SkipForward className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{t("medications.skipped")}
|
||||
</Button>
|
||||
</div>
|
||||
<MedicationIntakeActions
|
||||
intakeLoading={intakeLoading}
|
||||
onRecordIntake={recordIntake}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
import { useState, useEffect, useReducer } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
|
||||
import { MedicationCardMenu } from "@/components/medications/medication-card-menu";
|
||||
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 { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
||||
import { formatDateTime, formatTime } from "@/lib/format";
|
||||
@@ -17,15 +18,6 @@ import {
|
||||
reduceCurrentWindowStatus,
|
||||
toBerlinDate,
|
||||
} from "@/lib/medications/window-status";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
CircleCheck,
|
||||
SkipForward,
|
||||
Flame,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
invalidateKeys,
|
||||
@@ -255,20 +247,11 @@ export function MedicationCard({
|
||||
}
|
||||
|
||||
const stateBadges = (
|
||||
<>
|
||||
{!medication.notificationsEnabled && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("medications.withoutNotification")}
|
||||
</Badge>
|
||||
)}
|
||||
{!medication.active && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{medication.pausedAt
|
||||
? `${t("medications.pausedSince")} ${formatDateTime(medication.pausedAt)}`
|
||||
: t("medications.inactive")}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
<MedicationStateBadges
|
||||
notificationsEnabled={medication.notificationsEnabled}
|
||||
active={medication.active}
|
||||
pausedAt={medication.pausedAt}
|
||||
/>
|
||||
);
|
||||
|
||||
// v1.7.2 W3 — the four former header icon-buttons (open / edit /
|
||||
@@ -297,47 +280,11 @@ export function MedicationCard({
|
||||
<CardContent className="space-y-3.5">
|
||||
{/* Status, last & next intake info */}
|
||||
{currentWindowStatus.status && (
|
||||
<p className="text-sm">
|
||||
<span
|
||||
className={
|
||||
"inline-flex items-center gap-1 font-medium " +
|
||||
(currentWindowStatus.status === "in_window"
|
||||
? "text-success"
|
||||
: currentWindowStatus.status === "late"
|
||||
? "text-dracula-yellow"
|
||||
: "text-warning")
|
||||
}
|
||||
>
|
||||
{/* v1.4.38 W-D P2-3 — pair the colour with a Lucide
|
||||
glyph so colour-blind users (red-green) can
|
||||
disambiguate take-now from late from very-late.
|
||||
WCAG 1.4.1 (Use of Color). */}
|
||||
{currentWindowStatus.status === "in_window" ? (
|
||||
<CircleCheck className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
) : currentWindowStatus.status === "late" ? (
|
||||
<AlertCircle className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
) : (
|
||||
<AlertTriangle
|
||||
className="size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{currentWindowStatus.status === "in_window"
|
||||
? t("medications.takeNow")
|
||||
: currentWindowStatus.status === "late"
|
||||
? t("medications.overdue")
|
||||
: t("medications.veryOverdue")}
|
||||
</span>
|
||||
<span className="text-muted-foreground hidden sm:inline">
|
||||
{" "}
|
||||
—{" "}
|
||||
{formatTimeWindowRange(
|
||||
currentWindowStatus.schedule!.windowStart,
|
||||
currentWindowStatus.schedule!.windowEnd,
|
||||
locale,
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
<MedicationStatusPill
|
||||
status={currentWindowStatus.status}
|
||||
windowStart={currentWindowStatus.schedule!.windowStart}
|
||||
windowEnd={currentWindowStatus.schedule!.windowEnd}
|
||||
/>
|
||||
)}
|
||||
|
||||
{nextSchedule &&
|
||||
@@ -410,80 +357,19 @@ export function MedicationCard({
|
||||
|
||||
{/* Compliance bar */}
|
||||
{medication.active && compliance && (
|
||||
<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="font-medium">{rate7}%</span>
|
||||
</div>
|
||||
{/* v1.4.33 IW9 — aria-label so the bar has an accessible name. */}
|
||||
<Progress
|
||||
value={rate7}
|
||||
className="h-2"
|
||||
aria-label={t("medications.compliance7d")}
|
||||
/>
|
||||
</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="font-medium">{rate30}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={rate30}
|
||||
className="h-2"
|
||||
aria-label={t("medications.compliance30d")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
{streak > 0 && (
|
||||
<span className="flex items-center gap-1 font-medium text-orange-400">
|
||||
<Flame className="h-3.5 w-3.5" />
|
||||
{streak} {t("medications.dayStreak")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<MedicationComplianceBars
|
||||
rate7={rate7}
|
||||
rate30={rate30}
|
||||
streak={streak}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Quick actions — primary buttons of the medication card.
|
||||
Phase A5 mobile audit flagged the previous size="sm" / 32-px
|
||||
height as below the WCAG 44-px minimum. These are the most-
|
||||
tapped controls in HealthLog, so they get the full default
|
||||
button height. */}
|
||||
{/* Quick actions — primary buttons of the medication card. */}
|
||||
{medication.active && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="min-h-11 flex-1"
|
||||
onClick={() => recordIntake(false)}
|
||||
disabled={!!intakeLoading}
|
||||
>
|
||||
{intakeLoading === "take" ? (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Check className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{t("medications.taken")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11"
|
||||
onClick={() => recordIntake(true)}
|
||||
disabled={!!intakeLoading}
|
||||
>
|
||||
{intakeLoading === "skip" ? (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<SkipForward className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{t("medications.skipped")}
|
||||
</Button>
|
||||
</div>
|
||||
<MedicationIntakeActions
|
||||
intakeLoading={intakeLoading}
|
||||
onRecordIntake={recordIntake}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in new issue
Block a user