mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
share one card body across both medication card variants
Extract a single MedicationCardBody that owns the entire card shell, header, status line, next/last slot, compliance bars, cycle line and action row. Both the generic and the GLP-1 card render it and pass only the value content of their next/last lines, so structure, section order, spacing tokens and labels are identical by construction and cannot drift again. This also pins the two cards to the same CardContent spacing (flex h-full flex-col space-y-3.5) and the same mt-auto action wrapper, removing the bottom-spacing mismatch between the two cards. Drive the card's state from the new dose status: highlight the card green when the dose is in its take-window, and show a top status line that escalates from 'overdue' to 'very overdue' as a dose passes its miss cutoff. Upcoming and taken states stay calm.
This commit is contained in:
1 parent
939a732aa6
commit
5b47aae8b1
3 files changed
+371
-320
No files matched your search
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
|
||||
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
|
||||
import {
|
||||
MedicationComplianceBars,
|
||||
MedicationComplianceSkeleton,
|
||||
} from "@/components/medications/card-parts/medication-compliance-bars";
|
||||
import { MedicationCycleStatus } from "@/components/medications/card-parts/medication-cycle-status";
|
||||
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
|
||||
import { MedicationNextLastSlot } from "@/components/medications/card-parts/medication-next-last-slot";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { CurrentCycle, DoseStatus } from "@/lib/analytics/compliance";
|
||||
import type { MedicationWindowStatus } from "@/lib/medications/window-status";
|
||||
|
||||
/**
|
||||
* v1.15.9 — the ONE shared medication-card body.
|
||||
*
|
||||
* Both the generic `<MedicationCard>` and the `<Glp1MedicationCard>` render
|
||||
* THIS component for their entire card shell + body, so structure, vertical
|
||||
* rhythm, spacing tokens, section order and labels are byte-identical by
|
||||
* construction — they cannot drift. The variants own only the data and the
|
||||
* VALUE content of the next/last lines (a daily med shows a clock time, a
|
||||
* weekly GLP-1 shows the liked "Samstag 13.7. (in 7 Tagen)" relative-day
|
||||
* phrasing); everything structural lives here.
|
||||
*
|
||||
* This closes the recurring "the two cards look subtly different" failure.
|
||||
* The Ramipril-vs-Mounjaro bottom-spacing mismatch in particular is fixed by
|
||||
* sharing this single `CardContent` (`flex h-full flex-col space-y-3.5`) and
|
||||
* the single `mt-auto pt-0` action wrapper — there is no per-variant spacing
|
||||
* left to diverge.
|
||||
*
|
||||
* State-driven presentation (like the iOS app):
|
||||
* - `on_time_window` → the card highlights green (`ring` + tinted bg): the
|
||||
* actionable "take it now" state stands out at a glance.
|
||||
* - `overdue` → a calm top status line; `missed` (at / past the miss
|
||||
* cutoff) escalates to "Stark überfällig" in the destructive tone.
|
||||
* - `upcoming` / `taken_*` / `skipped` → no escalation; the card stays calm.
|
||||
*/
|
||||
export interface MedicationCardBodyProps {
|
||||
/** Medication name shown on the header's line 1. */
|
||||
name: string;
|
||||
/** Dose shown beside the name on line 1. */
|
||||
dose: string;
|
||||
/** Localised category label for the header badge. */
|
||||
categoryLabel: string;
|
||||
/** Whether the medication is active (greys out + hides the action row). */
|
||||
active: boolean;
|
||||
/** Detail-page link target for the header region. */
|
||||
href: string;
|
||||
/** Accessible label for the navigating header link. */
|
||||
linkLabel: string;
|
||||
/** State badges (without-notification / paused) for the header. */
|
||||
stateBadges: ReactNode;
|
||||
/** Overflow kebab for the header. */
|
||||
headerActions: ReactNode;
|
||||
|
||||
/** Current take-window status pill props, or null when no window is open. */
|
||||
windowStatus: {
|
||||
status: Exclude<MedicationWindowStatus, null>;
|
||||
windowStart: string;
|
||||
windowEnd: string;
|
||||
} | null;
|
||||
|
||||
/**
|
||||
* The open dose's server-derived {@link DoseStatus}. Drives the green
|
||||
* take-window highlight and the overdue / heavily-overdue top line.
|
||||
*/
|
||||
doseStatus: DoseStatus;
|
||||
|
||||
/** Upcoming-intake line value, or null when there is nothing to show. */
|
||||
nextLine: ReactNode | null;
|
||||
/** Last-intake line value, or null when the med has never been taken. */
|
||||
lastLine: ReactNode | null;
|
||||
|
||||
/**
|
||||
* The resolved compliance block, or null while the per-card query is in
|
||||
* flight (the body then renders the constant-height skeleton).
|
||||
*/
|
||||
compliance: {
|
||||
rate7: number;
|
||||
rate30: number;
|
||||
streak: number;
|
||||
shortDays: number;
|
||||
longDays: number;
|
||||
} | null;
|
||||
|
||||
/** The open-cycle descriptor, or null when the display block is absent. */
|
||||
currentCycle: CurrentCycle | null;
|
||||
|
||||
/** "take" | "skip" while the matching request is in flight, else null. */
|
||||
intakeLoading: string | null;
|
||||
/** Record the displayed dose (the card binds the slot instant). */
|
||||
onRecordIntake: (skipped: boolean) => void;
|
||||
|
||||
/** GLP-1 variant mounts its post-dose injection-site dialog here. */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function MedicationCardBody({
|
||||
name,
|
||||
dose,
|
||||
categoryLabel,
|
||||
active,
|
||||
href,
|
||||
linkLabel,
|
||||
stateBadges,
|
||||
headerActions,
|
||||
windowStatus,
|
||||
doseStatus,
|
||||
nextLine,
|
||||
lastLine,
|
||||
compliance,
|
||||
currentCycle,
|
||||
intakeLoading,
|
||||
onRecordIntake,
|
||||
children,
|
||||
}: MedicationCardBodyProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// Green take-window highlight — the actionable "take it now" state, mirrored
|
||||
// from the iOS card. Suppressed on an inactive med (it carries no actions).
|
||||
const inTakeWindow = active && doseStatus === "on_time_window";
|
||||
|
||||
// Overdue escalation line: a dose past its on-time window. `missed` (at /
|
||||
// past the clinical miss cutoff) reads "Stark überfällig"; the still-takeable
|
||||
// `overdue` tail reads the calmer "Überfällig". Both use the destructive
|
||||
// tone so the urgency is unmistakable; suppressed on an inactive med.
|
||||
const overdueLabel =
|
||||
active && doseStatus === "missed"
|
||||
? t("medications.veryOverdue")
|
||||
: active && doseStatus === "overdue"
|
||||
? t("medications.overdue")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"h-full",
|
||||
active ? "" : "opacity-60",
|
||||
inTakeWindow && "ring-success/40 bg-success/5 ring-1",
|
||||
)}
|
||||
>
|
||||
<MedicationCardHeader
|
||||
name={name}
|
||||
dose={dose}
|
||||
categoryLabel={categoryLabel}
|
||||
stateBadges={stateBadges}
|
||||
actions={headerActions}
|
||||
href={href}
|
||||
linkLabel={linkLabel}
|
||||
/>
|
||||
|
||||
<CardContent className="flex h-full flex-col space-y-3.5">
|
||||
{/* Top status line. The in-window take-now pill (success) and the
|
||||
overdue escalation are mutually exclusive — a dose is either still
|
||||
in its take-window or past it. The overdue line wins when present
|
||||
so a heavily-overdue dose reads loud at the top of the card. */}
|
||||
{overdueLabel ? (
|
||||
<p className="text-destructive flex items-center gap-1 text-sm font-medium">
|
||||
<AlertTriangle className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{overdueLabel}
|
||||
</p>
|
||||
) : windowStatus ? (
|
||||
<MedicationStatusPill
|
||||
status={windowStatus.status}
|
||||
windowStart={windowStatus.windowStart}
|
||||
windowEnd={windowStatus.windowEnd}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Next / last intake — the two decisive lines, each shown once. */}
|
||||
<MedicationNextLastSlot next={nextLine} last={lastLine} />
|
||||
|
||||
{/* Compliance bars — always two rows; constant-height skeleton holds
|
||||
the slot while the query is in flight so the grid row stays even. */}
|
||||
{active &&
|
||||
(compliance ? (
|
||||
<MedicationComplianceBars
|
||||
rate7={compliance.rate7}
|
||||
rate30={compliance.rate30}
|
||||
streak={compliance.streak}
|
||||
shortDays={compliance.shortDays}
|
||||
longDays={compliance.longDays}
|
||||
/>
|
||||
) : (
|
||||
<MedicationComplianceSkeleton />
|
||||
))}
|
||||
|
||||
{/* Open-cycle status line — calm, rate-decoupled. */}
|
||||
{active && currentCycle && (
|
||||
<MedicationCycleStatus cycle={currentCycle} />
|
||||
)}
|
||||
|
||||
{/* Quick actions — bottom-pinned so the action rows align across a
|
||||
grid row regardless of how much content sits above. */}
|
||||
{active && (
|
||||
<div className="mt-auto pt-0">
|
||||
<MedicationIntakeActions
|
||||
intakeLoading={intakeLoading}
|
||||
onRecordIntake={onRecordIntake}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -4,23 +4,11 @@ import { useEffect, useReducer, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
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,
|
||||
MedicationComplianceSkeleton,
|
||||
} from "@/components/medications/card-parts/medication-compliance-bars";
|
||||
import { MedicationCycleStatus } from "@/components/medications/card-parts/medication-cycle-status";
|
||||
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
|
||||
import { MedicationCardBody } from "@/components/medications/card-parts/medication-card-body";
|
||||
import { useMedicationIntake } from "@/components/medications/use-medication-intake";
|
||||
import {
|
||||
MedicationNextLastSlot,
|
||||
useWeekdayLabel,
|
||||
} from "@/components/medications/card-parts/medication-next-last-slot";
|
||||
import { useWeekdayLabel } from "@/components/medications/card-parts/medication-next-last-slot";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
invalidateKeys,
|
||||
@@ -285,13 +273,10 @@ export function Glp1MedicationCard({
|
||||
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.15.8 — taken-of-expected counts, shown after each percentage so two
|
||||
// identical rates stay distinguishable (a weekly GLP-1 reading 100% on
|
||||
// both windows shows e.g. `100% · 4 / 4` vs `100% · 13 / 13`).
|
||||
const takenShort = display?.short.taken;
|
||||
const expectedShort = display?.expectedShort;
|
||||
const takenLong = display?.long.taken;
|
||||
const expectedLong = display?.expectedLong;
|
||||
// v1.15.9 — the open dose's server-derived status drives the green
|
||||
// take-window highlight + the overdue / heavily-overdue top line, shared
|
||||
// verbatim with the generic card. Defaults to "upcoming" (calm).
|
||||
const doseStatus = display?.currentDose.status ?? "upcoming";
|
||||
|
||||
// v1.4.37 W4b — symmetric take-now / overdue pill with the generic
|
||||
// card. Sort schedules by `windowStart` so the most-actionable
|
||||
@@ -325,9 +310,12 @@ export function Glp1MedicationCard({
|
||||
now,
|
||||
});
|
||||
|
||||
// v1.15.9 — the recent-injection list now feeds ONLY the post-dose
|
||||
// injection-site dialog's rotation history; the card surface no longer
|
||||
// shows where the last shot landed. The operator: "where I injected
|
||||
// doesn't interest me." Site TRACKING / logging is unchanged everywhere
|
||||
// else (the picker, the detail history) — only the card line drops it.
|
||||
const recentInjections = details?.recentIntakes ?? [];
|
||||
const lastSite =
|
||||
recentInjections.find((i) => i.injectionSite)?.injectionSite ?? null;
|
||||
|
||||
function lastInjectionLabel(): string | null {
|
||||
if (!medication.lastTakenAt) return null;
|
||||
@@ -382,129 +370,58 @@ export function Glp1MedicationCard({
|
||||
|
||||
const categoryLabel = getMedicationCategoryLabel(medication.category, t);
|
||||
|
||||
// The upcoming-injection line value. The card owns this VALUE content —
|
||||
// the liked relative-day phrasing ("Samstag 13.7. (in 7 Tagen)") — while
|
||||
// the structure / labels live in the shared body. The purple dose accent
|
||||
// is byte-equivalent with the generic card's.
|
||||
const nextLine =
|
||||
next && currentWindowStatus.status !== "in_window" ? (
|
||||
<>
|
||||
{nextInjectionLabel()}
|
||||
{schedule?.dose && (
|
||||
<span className="text-dose-accent hidden font-medium sm:inline">
|
||||
{" "}
|
||||
— {schedule.dose}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : null;
|
||||
|
||||
// v1.15.9 — the last-injection line drops the injection-site display; it
|
||||
// now reads exactly like the generic card's last line (relative-day only).
|
||||
const lastLine = medication.lastTakenAt
|
||||
? t("medications.glp1LastInjection", { label: lastInjectionLabel() ?? "—" })
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card className={cn("h-full", medication.active ? "" : "opacity-60")}>
|
||||
<MedicationCardHeader
|
||||
name={medication.name}
|
||||
dose={medication.dose}
|
||||
categoryLabel={categoryLabel}
|
||||
stateBadges={stateBadges}
|
||||
actions={headerActions}
|
||||
href={`/medications/${medication.id}`}
|
||||
linkLabel={t("medications.openDetailPage")}
|
||||
/>
|
||||
|
||||
<CardContent className="flex h-full flex-col space-y-3.5">
|
||||
{/* 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 && (
|
||||
<MedicationStatusPill
|
||||
status={currentWindowStatus.status}
|
||||
windowStart={currentWindowStatus.schedule!.windowStart}
|
||||
windowEnd={currentWindowStatus.schedule!.windowEnd}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Injection state — next + last, rendered through the SAME shared
|
||||
<MedicationNextLastSlot> the generic card uses, so the order
|
||||
(next-then-last), labels ("Next intake" / "Last intake"), colour,
|
||||
spacing, gating, reserved min-height and the label-left /
|
||||
value-right two-column layout are identical across the two card
|
||||
types. v1.15.8 dropped the GLP-1-specific appointment-phrased
|
||||
label override so both cards name the concept identically; the
|
||||
relative-day value phrasing ("in N days" / "today") stays the
|
||||
GLP-1 card's own and is preserved. */}
|
||||
<MedicationNextLastSlot
|
||||
next={
|
||||
next && currentWindowStatus.status !== "in_window" ? (
|
||||
<>
|
||||
{nextInjectionLabel()}
|
||||
{/* Purple dose accent on the upcoming schedule dose,
|
||||
byte-equivalent with the generic card. Schedule.dose can
|
||||
override the medication-level dose during titration.
|
||||
Hidden below sm: to keep the narrow viewport row tight. */}
|
||||
{schedule?.dose && (
|
||||
<span className="text-dose-accent hidden font-medium sm:inline">
|
||||
{" "}
|
||||
— {schedule.dose}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
last={
|
||||
medication.lastTakenAt
|
||||
? lastSite
|
||||
? t("medications.glp1LastInjectionWithSite", {
|
||||
label: lastInjectionLabel() ?? "—",
|
||||
site: t(`medications.site${siteSuffix(lastSite)}`),
|
||||
})
|
||||
: t("medications.glp1LastInjection", {
|
||||
label: lastInjectionLabel() ?? "—",
|
||||
})
|
||||
: null
|
||||
}
|
||||
/>
|
||||
|
||||
{/* v1.4.28 — the "Bestand" (inventory) surface retired from
|
||||
the GLP-1 card: both the inline pens-remaining summary line
|
||||
and the per-pen disclosure are gone. The iOS-consumed
|
||||
Glp1InventoryDTO slot on /api/medications/[id]/glp1 stays
|
||||
in the response shape; only the web mounts are gone. */}
|
||||
|
||||
{/* Compliance bars — always two rows, shared with the generic
|
||||
card so the page grid stays harmonious. A constant-height
|
||||
skeleton holds the slot while the per-card compliance query is
|
||||
in flight (or returns null) so the card body keeps a fixed
|
||||
footprint and the action row pins to the same baseline as its
|
||||
grid-row sibling. */}
|
||||
{medication.active &&
|
||||
(compliance ? (
|
||||
<MedicationComplianceBars
|
||||
rate7={rate7}
|
||||
rate30={rate30}
|
||||
streak={streak}
|
||||
shortDays={shortDays}
|
||||
longDays={longDays}
|
||||
takenShort={takenShort}
|
||||
expectedShort={expectedShort}
|
||||
takenLong={takenLong}
|
||||
expectedLong={expectedLong}
|
||||
/>
|
||||
) : (
|
||||
<MedicationComplianceSkeleton />
|
||||
))}
|
||||
|
||||
{/* Open-cycle status line — the real win for a weekly GLP-1: between
|
||||
injections the percentage rows are vacuous, so this calm,
|
||||
rate-decoupled line reports "next dose in N days" / "due today" /
|
||||
"overdue" (or a neutral "no closed cycles yet") instead of a 0%
|
||||
that misreads a perfectly on-schedule med. `state: "none"`
|
||||
collapses to nothing inside the part. */}
|
||||
{medication.active && display?.currentCycle && (
|
||||
<MedicationCycleStatus cycle={display.currentCycle} />
|
||||
)}
|
||||
|
||||
{/* 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. The content
|
||||
above reserves constant-height slots, so the card bodies in a
|
||||
grid row are equal height and `mt-auto` pins the action row to
|
||||
the same baseline without opening a void. */}
|
||||
{medication.active && (
|
||||
<div className="mt-auto pt-0">
|
||||
<MedicationIntakeActions
|
||||
intakeLoading={intakeLoading}
|
||||
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<MedicationCardBody
|
||||
name={medication.name}
|
||||
dose={medication.dose}
|
||||
categoryLabel={categoryLabel}
|
||||
active={medication.active}
|
||||
href={`/medications/${medication.id}`}
|
||||
linkLabel={t("medications.openDetailPage")}
|
||||
stateBadges={stateBadges}
|
||||
headerActions={headerActions}
|
||||
windowStatus={
|
||||
currentWindowStatus.status
|
||||
? {
|
||||
status: currentWindowStatus.status,
|
||||
windowStart: currentWindowStatus.schedule!.windowStart,
|
||||
windowEnd: currentWindowStatus.schedule!.windowEnd,
|
||||
}
|
||||
: null
|
||||
}
|
||||
doseStatus={doseStatus}
|
||||
nextLine={nextLine}
|
||||
lastLine={lastLine}
|
||||
compliance={
|
||||
compliance ? { rate7, rate30, streak, shortDays, longDays } : null
|
||||
}
|
||||
currentCycle={display?.currentCycle ?? null}
|
||||
intakeLoading={intakeLoading}
|
||||
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
|
||||
>
|
||||
{/* v1.8.5 — post-dose injection-site capture (optional, skippable). */}
|
||||
{tracksInjection && (
|
||||
<LogInjectionSiteDialog
|
||||
@@ -523,18 +440,6 @@ export function Glp1MedicationCard({
|
||||
onSkip={() => setSiteIntakeId(null)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</MedicationCardBody>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map enum value → i18n suffix. ABDOMEN_LEFT → "AbdomenLeft" so we can
|
||||
* lookup `medications.siteAbdomenLeft` etc. without a switch table.
|
||||
*/
|
||||
function siteSuffix(site: InjectionSiteKey): string {
|
||||
return site
|
||||
.toLowerCase()
|
||||
.split("_")
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join("");
|
||||
}
|
||||
@@ -3,23 +3,11 @@
|
||||
import { useState, useEffect, useReducer } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useMedicationIntake } from "@/components/medications/use-medication-intake";
|
||||
import { cn } from "@/lib/utils";
|
||||
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,
|
||||
MedicationComplianceSkeleton,
|
||||
} from "@/components/medications/card-parts/medication-compliance-bars";
|
||||
import { MedicationCycleStatus } from "@/components/medications/card-parts/medication-cycle-status";
|
||||
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
|
||||
import {
|
||||
MedicationNextLastSlot,
|
||||
useWeekdayLabel,
|
||||
} from "@/components/medications/card-parts/medication-next-last-slot";
|
||||
import { MedicationCardBody } from "@/components/medications/card-parts/medication-card-body";
|
||||
import { useWeekdayLabel } from "@/components/medications/card-parts/medication-next-last-slot";
|
||||
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
||||
import { formatDateTime, formatTime } from "@/lib/format";
|
||||
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
|
||||
@@ -85,10 +73,6 @@ interface Medication {
|
||||
|
||||
interface ComplianceData {
|
||||
compliance7: {
|
||||
totalExpected: number;
|
||||
taken: number;
|
||||
skipped: number;
|
||||
missed: number;
|
||||
rate: number;
|
||||
streak: number;
|
||||
};
|
||||
@@ -98,9 +82,10 @@ interface ComplianceData {
|
||||
/**
|
||||
* 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.
|
||||
* windows and `short` / `long` carry their rates. v1.15.9 also carries the
|
||||
* open dose's `currentDose.status` for the green / overdue presentation.
|
||||
* Additive — older mocks omit it, in which case the card falls back to the
|
||||
* static 7-/30-day `compliance7` / `compliance30` fields and a calm state.
|
||||
*/
|
||||
complianceDisplay?: ComplianceDisplay;
|
||||
}
|
||||
@@ -211,14 +196,10 @@ export function MedicationCard({
|
||||
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.15.8 — taken-of-expected counts, shown after each percentage so two
|
||||
// identical rates stay distinguishable. Falls back to the static 7-day
|
||||
// `taken` when the cadence-scaled display block is absent.
|
||||
const takenShort = display?.short.taken ?? compliance?.compliance7?.taken;
|
||||
const expectedShort =
|
||||
display?.expectedShort ?? compliance?.compliance7?.totalExpected;
|
||||
const takenLong = display?.long.taken;
|
||||
const expectedLong = display?.expectedLong;
|
||||
// v1.15.9 — the open dose's server-derived status drives the card's green
|
||||
// take-window highlight + the overdue / heavily-overdue top line. Defaults
|
||||
// to "upcoming" (calm) when the display block is absent (older mocks).
|
||||
const doseStatus = display?.currentDose.status ?? "upcoming";
|
||||
const categoryLabel = getMedicationCategoryLabel(medication.category, t);
|
||||
const sortedSchedules = [...medication.schedules].sort(
|
||||
(a, b) =>
|
||||
@@ -307,139 +288,89 @@ export function MedicationCard({
|
||||
/>
|
||||
);
|
||||
|
||||
// The upcoming-intake line value — a day label + window range + optional
|
||||
// dose accent. The card owns this VALUE content (a daily med reads as a
|
||||
// clock-time window); the structure / labels live in the shared body.
|
||||
const nextLine =
|
||||
nextSchedule && currentWindowStatus.status !== "in_window"
|
||||
? (() => {
|
||||
const s = nextSchedule;
|
||||
|
||||
// Format day label relative to today
|
||||
let dayLabel = "";
|
||||
if (nextAt) {
|
||||
const nextDate = toBerlinDate(new Date(nextAt));
|
||||
const todayStr = `${nowBerlin.getFullYear()}-${nowBerlin.getMonth()}-${nowBerlin.getDate()}`;
|
||||
const nextStr = `${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`;
|
||||
const tomorrow = new Date(nowBerlin);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const tomorrowStr = `${tomorrow.getFullYear()}-${tomorrow.getMonth()}-${tomorrow.getDate()}`;
|
||||
|
||||
const diffDays = Math.round(
|
||||
(nextDate.getTime() - nowBerlin.getTime()) /
|
||||
(24 * 60 * 60 * 1000),
|
||||
);
|
||||
|
||||
if (nextStr === todayStr) {
|
||||
dayLabel = t("medications.today");
|
||||
} else if (nextStr === tomorrowStr) {
|
||||
dayLabel = t("medications.tomorrow");
|
||||
} else if (diffDays <= 5) {
|
||||
dayLabel = weekdayLabel(nextDate.getDay());
|
||||
} else {
|
||||
dayLabel = fmt.dateWithWeekday(nextDate);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{dayLabel && `${dayLabel}, `}
|
||||
{formatTimeWindowRange(s.windowStart, s.windowEnd, locale)}
|
||||
{s.label && (
|
||||
<span className="hidden sm:inline"> ({s.label})</span>
|
||||
)}
|
||||
{s.dose && (
|
||||
<span className="text-dose-accent hidden font-medium sm:inline">
|
||||
{" "}
|
||||
— {s.dose}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card className={cn("h-full", medication.active ? "" : "opacity-60")}>
|
||||
<MedicationCardHeader
|
||||
name={medication.name}
|
||||
dose={medication.dose}
|
||||
categoryLabel={categoryLabel}
|
||||
stateBadges={stateBadges}
|
||||
actions={headerActions}
|
||||
href={`/medications/${medication.id}`}
|
||||
linkLabel={t("medications.openDetailPage")}
|
||||
/>
|
||||
|
||||
<CardContent className="flex h-full flex-col space-y-3.5">
|
||||
{/* Status, last & next intake info */}
|
||||
{currentWindowStatus.status && (
|
||||
<MedicationStatusPill
|
||||
status={currentWindowStatus.status}
|
||||
windowStart={currentWindowStatus.schedule!.windowStart}
|
||||
windowEnd={currentWindowStatus.schedule!.windowEnd}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Next / last intake — rendered through the shared
|
||||
<MedicationNextLastSlot> so the generic and GLP-1 cards keep an
|
||||
identical slot (order, colour, spacing, gating, reserved
|
||||
min-height). The card owns only the *content* of each line; the
|
||||
wrapper + "Next / Last intake" labels live in the shared part. */}
|
||||
<MedicationNextLastSlot
|
||||
next={
|
||||
nextSchedule && currentWindowStatus.status !== "in_window"
|
||||
? (() => {
|
||||
const s = nextSchedule;
|
||||
|
||||
// Format day label relative to today
|
||||
let dayLabel = "";
|
||||
if (nextAt) {
|
||||
const nextDate = toBerlinDate(new Date(nextAt));
|
||||
const todayStr = `${nowBerlin.getFullYear()}-${nowBerlin.getMonth()}-${nowBerlin.getDate()}`;
|
||||
const nextStr = `${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`;
|
||||
const tomorrow = new Date(nowBerlin);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const tomorrowStr = `${tomorrow.getFullYear()}-${tomorrow.getMonth()}-${tomorrow.getDate()}`;
|
||||
|
||||
const diffDays = Math.round(
|
||||
(nextDate.getTime() - nowBerlin.getTime()) /
|
||||
(24 * 60 * 60 * 1000),
|
||||
);
|
||||
|
||||
if (nextStr === todayStr) {
|
||||
dayLabel = t("medications.today");
|
||||
} else if (nextStr === tomorrowStr) {
|
||||
dayLabel = t("medications.tomorrow");
|
||||
} else if (diffDays <= 5) {
|
||||
dayLabel = weekdayLabel(nextDate.getDay());
|
||||
} else {
|
||||
dayLabel = fmt.dateWithWeekday(nextDate);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{dayLabel && `${dayLabel}, `}
|
||||
{formatTimeWindowRange(s.windowStart, s.windowEnd, locale)}
|
||||
{s.label && (
|
||||
<span className="hidden sm:inline"> ({s.label})</span>
|
||||
)}
|
||||
{s.dose && (
|
||||
<span className="text-dose-accent hidden font-medium sm:inline">
|
||||
{" "}
|
||||
— {s.dose}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()
|
||||
: null
|
||||
}
|
||||
last={
|
||||
medication.lastTakenAt
|
||||
? formatLastTakenAt(medication.lastTakenAt)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 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. A constant-height skeleton holds the slot while
|
||||
the per-card compliance query is in flight (or returns null) so
|
||||
the card body keeps a fixed footprint and the action row pins to
|
||||
the same baseline as its grid-row sibling. */}
|
||||
{medication.active &&
|
||||
(compliance ? (
|
||||
<MedicationComplianceBars
|
||||
rate7={rate7}
|
||||
rate30={rate30}
|
||||
streak={streak}
|
||||
shortDays={shortDays}
|
||||
longDays={longDays}
|
||||
takenShort={takenShort}
|
||||
expectedShort={expectedShort}
|
||||
takenLong={takenLong}
|
||||
expectedLong={expectedLong}
|
||||
/>
|
||||
) : (
|
||||
<MedicationComplianceSkeleton />
|
||||
))}
|
||||
|
||||
{/* Open-cycle status line — calm, rate-decoupled. A sparse weekly /
|
||||
rolling med between doses reports "next dose in N days" / "due
|
||||
today" / "overdue" (or a neutral "no closed cycles yet") instead of
|
||||
leaning on a percentage that reads as a scary 0% for a med that is
|
||||
actually on schedule. Rendered only when the server supplies the
|
||||
descriptor; `state: "none"` collapses to nothing inside the part. */}
|
||||
{medication.active && display?.currentCycle && (
|
||||
<MedicationCycleStatus cycle={display.currentCycle} />
|
||||
)}
|
||||
|
||||
{/* Quick actions — primary buttons of the medication card. The
|
||||
content above reserves constant-height slots, so the card bodies
|
||||
in a grid row are equal height and `mt-auto` pins the action row
|
||||
to the same baseline without opening the void that an unequal-
|
||||
height pin produced before. */}
|
||||
{medication.active && (
|
||||
<div className="mt-auto pt-0">
|
||||
<MedicationIntakeActions
|
||||
intakeLoading={intakeLoading}
|
||||
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<MedicationCardBody
|
||||
name={medication.name}
|
||||
dose={medication.dose}
|
||||
categoryLabel={categoryLabel}
|
||||
active={medication.active}
|
||||
href={`/medications/${medication.id}`}
|
||||
linkLabel={t("medications.openDetailPage")}
|
||||
stateBadges={stateBadges}
|
||||
headerActions={headerActions}
|
||||
windowStatus={
|
||||
currentWindowStatus.status
|
||||
? {
|
||||
status: currentWindowStatus.status,
|
||||
windowStart: currentWindowStatus.schedule!.windowStart,
|
||||
windowEnd: currentWindowStatus.schedule!.windowEnd,
|
||||
}
|
||||
: null
|
||||
}
|
||||
doseStatus={doseStatus}
|
||||
nextLine={nextLine}
|
||||
lastLine={
|
||||
medication.lastTakenAt ? formatLastTakenAt(medication.lastTakenAt) : null
|
||||
}
|
||||
compliance={
|
||||
compliance ? { rate7, rate30, streak, shortDays, longDays } : null
|
||||
}
|
||||
currentCycle={display?.currentCycle ?? null}
|
||||
intakeLoading={intakeLoading}
|
||||
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
|
||||
>
|
||||
{/* v1.8.5 — post-dose injection-site capture (optional, skippable). */}
|
||||
{tracksInjection && (
|
||||
<LogInjectionSiteDialog
|
||||
@@ -454,6 +385,6 @@ export function MedicationCard({
|
||||
onSkip={() => setSiteIntakeId(null)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</MedicationCardBody>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user