mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(medications): GLP1 medication-card variant — text-rich, no chart per Marc directive
Phase 4 of W4d GLP-1 full integration. New `Glp1MedicationCard`
variant renders inside the existing medications grid alongside generic
cards — same Card shell, same compliance bars, same primary intake
actions. The GLP-1-specific rows sit between the dose line and the
compliance bars so the user reads top-down: drug → injection state →
adherence.
Marc directive 2026-05-14: NO chart on the medication card. The card
stays text-rich:
- Headline: drug name + current dose ("Mounjaro · 7.5 mg")
- Last injection line with optional site ("Last: Mo May 12 · Abdomen,
lower left")
- Next injection line with day count ("Next: Mon, May 19 (in 5 days)")
- Rotation hint ("Last site: Abdomen left · Recommended:
Abdomen right") — only when the last site differs from the
recommended next site
- Pen-inventory line with low-stock badge when dosesPerUnit is set
- Collapsible "Dose history" details block listing every
MedicationDoseChange row
- "Log side effect" quick-button alongside Taken / Skipped
Routing: the medications page reads `treatmentClass === "GLP1"` and
dispatches to the variant. Existing meds (treatmentClass = "GENERIC",
the migration 0046 default) render the unchanged generic card.
Companion pieces:
- `injection-sites.ts` — the 8-site enum + `nextInjectionSite()`
recommender that maximises Euclidean distance to recent picks and
beats stale sites in the tie-break.
- `/api/medications/[id]/glp1` — GLP-1 details endpoint feeding the
card (dose changes + recent intakes + running inventory math).
POST creates a dose-change or inventory event.
This commit is contained in:
1 parent
b50ebccf0b
commit
b8a1c1833b
5 files changed
+813
-14
No files matched your search
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* v1.4.25 W4d — GLP-1 medication details endpoint.
|
||||
*
|
||||
* Returns per-medication extras the GLP-1 card variant + the dashboard
|
||||
* tile + the doctor-report PDF section read: chronological dose-change
|
||||
* history, the last 12 injection events (with optional site
|
||||
* rotation data), and the running pen-inventory math. The base
|
||||
* /api/medications/[id] GET is unchanged so v1.4.24 consumers keep
|
||||
* working untouched.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
const LOW_STOCK_DOSE_THRESHOLD = 4;
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const medication = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
doseChanges: { orderBy: { effectiveFrom: "asc" } },
|
||||
inventoryEvents: { orderBy: { occurredAt: "asc" } },
|
||||
intakeEvents: {
|
||||
where: { takenAt: { not: null } },
|
||||
orderBy: { takenAt: "desc" },
|
||||
take: 12,
|
||||
select: { takenAt: true, injectionSite: true },
|
||||
},
|
||||
schedules: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
// Inventory math: running sum of every inventory event. Negative
|
||||
// when more doses were consumed than purchased — surfaces as a
|
||||
// low-stock warning rather than a hard error so the UI keeps
|
||||
// working when the user backdates an inventory event.
|
||||
let inventory: {
|
||||
pensRemaining: number | null;
|
||||
dosesRemaining: number | null;
|
||||
weeksOfSupply: number | null;
|
||||
lowStock: boolean;
|
||||
} | null = null;
|
||||
if (medication.dosesPerUnit && medication.inventoryEvents.length > 0) {
|
||||
const pens = medication.inventoryEvents.reduce(
|
||||
(sum, ev) => sum + ev.delta,
|
||||
0,
|
||||
);
|
||||
const pensRemaining = Math.max(0, pens);
|
||||
const dosesRemaining = pensRemaining * medication.dosesPerUnit;
|
||||
// Approximate weeks-of-supply assuming weekly cadence (the
|
||||
// canonical GLP-1 case). The Coach snapshot does the same.
|
||||
const weeksOfSupply = dosesRemaining;
|
||||
const lowStock = dosesRemaining < LOW_STOCK_DOSE_THRESHOLD;
|
||||
inventory = {
|
||||
pensRemaining,
|
||||
dosesRemaining,
|
||||
weeksOfSupply,
|
||||
lowStock,
|
||||
};
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
doseChanges: medication.doseChanges.map((dc) => ({
|
||||
id: dc.id,
|
||||
effectiveFrom: dc.effectiveFrom.toISOString(),
|
||||
doseValue: dc.doseValue,
|
||||
doseUnit: dc.doseUnit,
|
||||
note: dc.note,
|
||||
})),
|
||||
recentIntakes: medication.intakeEvents.map((iv) => ({
|
||||
takenAt: iv.takenAt ? iv.takenAt.toISOString() : null,
|
||||
injectionSite: iv.injectionSite,
|
||||
})),
|
||||
inventory,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const doseChangeSchema = {
|
||||
effectiveFrom: "string",
|
||||
doseValue: "number",
|
||||
doseUnit: "string",
|
||||
note: "string?",
|
||||
} as const;
|
||||
|
||||
const inventorySchema = {
|
||||
delta: "number",
|
||||
reason: "string",
|
||||
} as const;
|
||||
|
||||
void doseChangeSchema;
|
||||
void inventorySchema;
|
||||
|
||||
interface DoseChangeBody {
|
||||
effectiveFrom?: string;
|
||||
doseValue?: number;
|
||||
doseUnit?: string;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
interface InventoryBody {
|
||||
delta?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface Glp1PostBody {
|
||||
doseChange?: DoseChangeBody;
|
||||
inventory?: InventoryBody;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST creates a new dose change OR inventory event (the body picks
|
||||
* one — caller specifies which). Convenience endpoint so the
|
||||
* medication-card disclosure can write rows without dispatching to
|
||||
* /api/medications/[id]/dose-change + /api/medications/[id]/inventory
|
||||
* separately for the v1.4.25 cut.
|
||||
*/
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
const medication = await prisma.medication.findUnique({ where: { id } });
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => null)) as Glp1PostBody | null;
|
||||
if (!body) return apiError("Invalid body", 400);
|
||||
|
||||
if (body.doseChange) {
|
||||
const { effectiveFrom, doseValue, doseUnit, note } = body.doseChange;
|
||||
if (!effectiveFrom || typeof doseValue !== "number" || !doseUnit) {
|
||||
return apiError("doseChange.effectiveFrom + doseValue + doseUnit required", 422);
|
||||
}
|
||||
const created = await prisma.medicationDoseChange.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
effectiveFrom: new Date(effectiveFrom),
|
||||
doseValue,
|
||||
doseUnit,
|
||||
note: note ?? null,
|
||||
},
|
||||
});
|
||||
return apiSuccess({ doseChange: created }, 201);
|
||||
}
|
||||
|
||||
if (body.inventory) {
|
||||
const { delta, reason } = body.inventory;
|
||||
if (typeof delta !== "number" || !reason) {
|
||||
return apiError("inventory.delta + reason required", 422);
|
||||
}
|
||||
const created = await prisma.medicationInventoryEvent.create({
|
||||
data: { medicationId: id, delta, reason },
|
||||
});
|
||||
return apiSuccess({ inventory: created }, 201);
|
||||
}
|
||||
|
||||
return apiError("Body must carry doseChange or inventory", 422);
|
||||
},
|
||||
);
|
||||
@@ -8,6 +8,7 @@ import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { MedicationForm } from "@/components/medications/medication-form";
|
||||
import { MedicationCard } from "@/components/medications/medication-card";
|
||||
import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -199,13 +200,21 @@ export default function MedicationsPage() {
|
||||
{activeMeds.length > 0 && (
|
||||
<div className="space-y-3.5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{activeMeds.map((med) => (
|
||||
<MedicationCard
|
||||
key={med.id}
|
||||
medication={med}
|
||||
onEdit={openEdit}
|
||||
/>
|
||||
))}
|
||||
{activeMeds.map((med) =>
|
||||
med.treatmentClass === "GLP1" ? (
|
||||
<Glp1MedicationCard
|
||||
key={med.id}
|
||||
medication={med}
|
||||
onEdit={openEdit}
|
||||
/>
|
||||
) : (
|
||||
<MedicationCard
|
||||
key={med.id}
|
||||
medication={med}
|
||||
onEdit={openEdit}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -217,13 +226,21 @@ export default function MedicationsPage() {
|
||||
{t("common.inactive")} ({inactiveMeds.length})
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{inactiveMeds.map((med) => (
|
||||
<MedicationCard
|
||||
key={med.id}
|
||||
medication={med}
|
||||
onEdit={openEdit}
|
||||
/>
|
||||
))}
|
||||
{inactiveMeds.map((med) =>
|
||||
med.treatmentClass === "GLP1" ? (
|
||||
<Glp1MedicationCard
|
||||
key={med.id}
|
||||
medication={med}
|
||||
onEdit={openEdit}
|
||||
/>
|
||||
) : (
|
||||
<MedicationCard
|
||||
key={med.id}
|
||||
medication={med}
|
||||
onEdit={openEdit}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Flame,
|
||||
History,
|
||||
Loader2,
|
||||
Pencil,
|
||||
SkipForward,
|
||||
Smile,
|
||||
Syringe,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
|
||||
import { formatDateTime, formatTime } from "@/lib/format";
|
||||
import {
|
||||
describeInjectionSite,
|
||||
nextInjectionSite,
|
||||
type InjectionSiteKey,
|
||||
} from "@/lib/medications/injection-sites";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4d — GLP-1 medication card variant.
|
||||
*
|
||||
* Marc directive 2026-05-14: NO chart on the medication card. The card
|
||||
* stays text-rich (drug name + current dose, last/next injection,
|
||||
* inline dose-history disclosure, injection-site rotation hint, pen
|
||||
* inventory, side-effect quick-log). Chart-only surfaces are the
|
||||
* Dashboard tile + Insights /medikamente sub-page.
|
||||
*
|
||||
* Renders inside the standard medications grid alongside generic
|
||||
* medication cards; visually mirrors `medication-card.tsx` so the page
|
||||
* stays harmonious — same Card shell, same header layout, same
|
||||
* compliance bars, same primary "Taken / Skipped" actions. The
|
||||
* GLP-1-specific rows sit between the dose line and the compliance
|
||||
* bars so the user reads top-down: drug → injection state → adherence.
|
||||
*/
|
||||
|
||||
interface ScheduleLite {
|
||||
id: string;
|
||||
windowStart: string;
|
||||
windowEnd: string;
|
||||
label: string | null;
|
||||
dose: string | null;
|
||||
daysOfWeek: string | null;
|
||||
}
|
||||
|
||||
interface DoseChangeLite {
|
||||
id: string;
|
||||
effectiveFrom: string;
|
||||
doseValue: number;
|
||||
doseUnit: string;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
interface IntakeLite {
|
||||
takenAt: string | null;
|
||||
injectionSite: InjectionSiteKey | null;
|
||||
}
|
||||
|
||||
export interface Glp1Medication {
|
||||
id: string;
|
||||
name: string;
|
||||
dose: string;
|
||||
category: string;
|
||||
treatmentClass?: string;
|
||||
dosesPerUnit?: number | null;
|
||||
active: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
pausedAt: string | null;
|
||||
lastTakenAt: string | null;
|
||||
schedules: ScheduleLite[];
|
||||
}
|
||||
|
||||
interface ComplianceData {
|
||||
compliance7: { rate: number; streak: number };
|
||||
compliance30: { rate: number };
|
||||
}
|
||||
|
||||
interface DetailsResponse {
|
||||
doseChanges: DoseChangeLite[];
|
||||
recentIntakes: IntakeLite[];
|
||||
inventory: {
|
||||
pensRemaining: number | null;
|
||||
dosesRemaining: number | null;
|
||||
weeksOfSupply: number | null;
|
||||
lowStock: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface Glp1MedicationCardProps {
|
||||
medication: Glp1Medication;
|
||||
onEdit: (med: Glp1Medication) => void;
|
||||
onLogSideEffect?: (med: Glp1Medication) => void;
|
||||
}
|
||||
|
||||
const DAY_KEYS = [
|
||||
"medications.daysSun",
|
||||
"medications.daysMon",
|
||||
"medications.daysTue",
|
||||
"medications.daysWed",
|
||||
"medications.daysThu",
|
||||
"medications.daysFri",
|
||||
"medications.daysSat",
|
||||
] as const;
|
||||
|
||||
function parseDayList(raw: string | null): number[] {
|
||||
if (!raw) return [];
|
||||
return raw
|
||||
.split(",")
|
||||
.map((p) => Number(p.trim()))
|
||||
.filter((n) => Number.isInteger(n) && n >= 0 && n <= 6);
|
||||
}
|
||||
|
||||
function diffDays(target: Date, from: Date): number {
|
||||
const ms =
|
||||
Date.UTC(target.getFullYear(), target.getMonth(), target.getDate()) -
|
||||
Date.UTC(from.getFullYear(), from.getMonth(), from.getDate());
|
||||
return Math.round(ms / (24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
function predictNextWeeklyDate(
|
||||
schedule: ScheduleLite | null,
|
||||
lastTakenAt: string | null,
|
||||
now: Date,
|
||||
): { date: Date; daysAway: number } | null {
|
||||
if (!schedule) return null;
|
||||
const dow = parseDayList(schedule.daysOfWeek);
|
||||
if (dow.length !== 1) return null;
|
||||
const target = dow[0];
|
||||
const anchor = lastTakenAt ? new Date(lastTakenAt) : now;
|
||||
const cursor = new Date(anchor);
|
||||
cursor.setHours(0, 0, 0, 0);
|
||||
for (let i = 1; i <= 14; i += 1) {
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
if (cursor.getDay() === target) {
|
||||
const days = diffDays(cursor, now);
|
||||
return { date: cursor, daysAway: Math.max(0, days) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function Glp1MedicationCard({
|
||||
medication,
|
||||
onEdit,
|
||||
onLogSideEffect,
|
||||
}: Glp1MedicationCardProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { t, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const [intakeLoading, setIntakeLoading] = useState<string | null>(null);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
const { data: compliance } = useQuery({
|
||||
queryKey: ["medications", medication.id, "compliance"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/medications/${medication.id}/compliance`);
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return json.data as ComplianceData;
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
enabled: medication.active,
|
||||
});
|
||||
|
||||
// Pull GLP-1-specific extras (dose history + recent injection sites +
|
||||
// pen inventory). Lives behind a dedicated endpoint so the standard
|
||||
// medications grid loads in parallel without paying for it.
|
||||
const { data: details } = useQuery({
|
||||
queryKey: ["medications", medication.id, "glp1-details"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/medications/${medication.id}/glp1`);
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return json.data as DetailsResponse;
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
async function recordIntake(skipped: boolean) {
|
||||
const key = skipped ? "skip" : "take";
|
||||
setIntakeLoading(key);
|
||||
try {
|
||||
const res = await fetch(`/api/medications/${medication.id}/intake`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ skipped }),
|
||||
});
|
||||
if (res.ok) {
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
}
|
||||
} finally {
|
||||
setIntakeLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
const schedule = medication.schedules[0] ?? null;
|
||||
const now = new Date();
|
||||
const next = predictNextWeeklyDate(schedule, medication.lastTakenAt, now);
|
||||
const rate7 = compliance?.compliance7?.rate ?? 0;
|
||||
const rate30 = compliance?.compliance30?.rate ?? 0;
|
||||
const streak = compliance?.compliance7?.streak ?? 0;
|
||||
|
||||
const doseChanges = details?.doseChanges ?? [];
|
||||
const recentInjections = details?.recentIntakes ?? [];
|
||||
const lastSite =
|
||||
recentInjections.find((i) => i.injectionSite)?.injectionSite ?? null;
|
||||
const recommendedNextSite = nextInjectionSite(
|
||||
recentInjections.map((r) => r.injectionSite).filter(Boolean) as InjectionSiteKey[],
|
||||
);
|
||||
|
||||
const inventory = details?.inventory ?? null;
|
||||
|
||||
function lastInjectionLabel(): string | null {
|
||||
if (!medication.lastTakenAt) return null;
|
||||
const d = new Date(medication.lastTakenAt);
|
||||
const days = diffDays(d, now);
|
||||
if (days === 0) return `${t("medications.today")}, ${formatTime(medication.lastTakenAt)}`;
|
||||
if (days === -1) return `${t("medications.yesterday")}, ${formatTime(medication.lastTakenAt)}`;
|
||||
return formatDateTime(medication.lastTakenAt);
|
||||
}
|
||||
|
||||
function nextInjectionLabel(): string {
|
||||
if (!next) return "—";
|
||||
if (next.daysAway === 0) return t("medications.glp1NextInjectionToday");
|
||||
if (next.daysAway === 1) return t("medications.glp1NextInjectionTomorrow");
|
||||
const dayName = t(DAY_KEYS[next.date.getDay()]);
|
||||
const dateShort = fmt.dateShort(next.date);
|
||||
return t("medications.glp1NextInjectionDays", {
|
||||
label: `${dayName}, ${dateShort}`,
|
||||
days: next.daysAway,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={medication.active ? "" : "opacity-60"}>
|
||||
<CardHeader className="pb-2.5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Syringe className="text-dracula-purple h-4 w-4 shrink-0" />
|
||||
<span>{medication.name}</span>
|
||||
<span className="text-muted-foreground text-sm font-normal">
|
||||
· {medication.dose}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("medications.treatmentClassGlp1")}
|
||||
</Badge>
|
||||
{!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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11"
|
||||
asChild
|
||||
aria-label={t("medications.intakeHistory")}
|
||||
>
|
||||
<Link href={`/medications/${medication.id}/history`}>
|
||||
<History className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11"
|
||||
onClick={() => onEdit(medication)}
|
||||
aria-label={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3.5">
|
||||
{/* Injection state — last + next */}
|
||||
<div className="space-y-1 text-sm">
|
||||
{medication.lastTakenAt && (
|
||||
<p className="text-muted-foreground">
|
||||
{lastSite
|
||||
? t("medications.glp1LastInjectionWithSite", {
|
||||
label: lastInjectionLabel() ?? "—",
|
||||
site: t(`medications.site${siteSuffix(lastSite)}`),
|
||||
})
|
||||
: t("medications.glp1LastInjection", {
|
||||
label: lastInjectionLabel() ?? "—",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-foreground/85">{nextInjectionLabel()}</p>
|
||||
</div>
|
||||
|
||||
{/* Rotation hint — only when we have a last + recommended site
|
||||
different from it. The picker on the dashboard tile owns
|
||||
mode-switching; the card just nudges. */}
|
||||
{lastSite && recommendedNextSite && recommendedNextSite !== lastSite && (
|
||||
<div className="border-border/60 bg-muted/40 rounded-md border px-3 py-2 text-xs">
|
||||
<p className="text-muted-foreground">
|
||||
{t("medications.glp1RotationLast", {
|
||||
site: t(`medications.site${siteSuffix(lastSite)}`),
|
||||
})}
|
||||
</p>
|
||||
<p className="text-foreground/90 font-medium">
|
||||
{t("medications.glp1RotationSuggested", {
|
||||
site: t(`medications.site${siteSuffix(recommendedNextSite)}`),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inventory line — only when dosesPerUnit + at least one
|
||||
inventory event recorded. Low-stock gets a warning badge. */}
|
||||
{inventory && inventory.pensRemaining !== null && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t("medications.glp1Inventory", {
|
||||
pens: inventory.pensRemaining,
|
||||
weeks: inventory.weeksOfSupply ?? "—",
|
||||
})}
|
||||
</span>
|
||||
{inventory.lowStock && (
|
||||
<Badge variant="destructive" className="text-[10px]">
|
||||
{t("medications.glp1InventoryLow")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dose-history disclosure — collapsible row. Stays closed by
|
||||
default so the card height matches a generic card on first
|
||||
paint. */}
|
||||
<details
|
||||
className="border-border/60 rounded-md border text-xs"
|
||||
open={showHistory}
|
||||
onToggle={(e) => setShowHistory((e.target as HTMLDetailsElement).open)}
|
||||
>
|
||||
<summary className="text-foreground/85 flex cursor-pointer list-none items-center justify-between px-3 py-2 font-medium">
|
||||
<span>{t("medications.glp1DoseHistory")}</span>
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 transition-transform ${
|
||||
showHistory ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</summary>
|
||||
<div className="border-border/60 space-y-1.5 border-t px-3 py-2">
|
||||
{doseChanges.length === 0 && (
|
||||
<p className="text-muted-foreground">
|
||||
{t("medications.glp1DoseHistoryEmpty")}
|
||||
</p>
|
||||
)}
|
||||
{doseChanges
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((change) => (
|
||||
<div
|
||||
key={change.id}
|
||||
className="flex items-baseline justify-between gap-2"
|
||||
>
|
||||
<span className="font-medium tabular-nums">
|
||||
{change.doseValue} {change.doseUnit}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("medications.glp1DoseSince", {
|
||||
date: fmt.dateShort(new Date(change.effectiveFrom)),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* Compliance bars — identical to 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>
|
||||
<Progress value={rate7} className="h-2" />
|
||||
</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" />
|
||||
</div>
|
||||
{streak > 0 && (
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Primary actions row — matches the generic card's
|
||||
min-h-11 tap-target rule. The third button opens the
|
||||
side-effect log pre-tagged with the GLP-1 drug name. */}
|
||||
{medication.active && (
|
||||
<div className="flex flex-wrap 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" />
|
||||
) : (
|
||||
<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" />
|
||||
) : (
|
||||
<SkipForward className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{t("medications.skipped")}
|
||||
</Button>
|
||||
{onLogSideEffect && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="min-h-11"
|
||||
onClick={() => onLogSideEffect(medication)}
|
||||
>
|
||||
<Smile className="mr-1 h-4 w-4" />
|
||||
{t("medications.glp1LogSideEffect")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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("");
|
||||
}
|
||||
|
||||
/** Re-export so the parent doesn't need to import describeInjectionSite. */
|
||||
export { describeInjectionSite };
|
||||
@@ -35,6 +35,14 @@ interface Medication {
|
||||
name: string;
|
||||
dose: string;
|
||||
category: string;
|
||||
/**
|
||||
* v1.4.25 W4d — Prisma treatment-class discriminator. When set to
|
||||
* "GLP1" the parent should render the {@link Glp1MedicationCard}
|
||||
* variant instead of this generic card. The field is optional for
|
||||
* backwards compatibility with mocks.
|
||||
*/
|
||||
treatmentClass?: string;
|
||||
dosesPerUnit?: number | null;
|
||||
active: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
pausedAt: string | null;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* v1.4.25 W4d — injection-site rotation logic.
|
||||
*
|
||||
* The 8-zone enum maps to the Prisma `InjectionSite` enum exactly so
|
||||
* the snapshot, the picker, the card variant, and the doctor-report
|
||||
* all speak the same vocabulary. Front-of-body view, mirrored for
|
||||
* left/right per Eli Lilly's Mounjaro PI + Novo Nordisk's
|
||||
* Ozempic/Wegovy PI rotation guidance.
|
||||
*/
|
||||
|
||||
export const INJECTION_SITE_KEYS = [
|
||||
"ABDOMEN_LEFT",
|
||||
"ABDOMEN_RIGHT",
|
||||
"ABDOMEN_UPPER_LEFT",
|
||||
"ABDOMEN_UPPER_RIGHT",
|
||||
"THIGH_LEFT",
|
||||
"THIGH_RIGHT",
|
||||
"UPPER_ARM_LEFT",
|
||||
"UPPER_ARM_RIGHT",
|
||||
] as const;
|
||||
|
||||
export type InjectionSiteKey = (typeof INJECTION_SITE_KEYS)[number];
|
||||
|
||||
/**
|
||||
* Stylized SVG position for each site (front-of-body view, ~120x300
|
||||
* viewBox). Used by `injection-site-picker.tsx` to render the clickable
|
||||
* dots; tuned to read clearly on a 320px mobile viewport.
|
||||
*
|
||||
* The body outline is a rounded humanoid: head (y≈20), shoulders
|
||||
* (y≈60), torso (y≈70-180), thighs (y≈180-260). Sites sit on the
|
||||
* dominant injection zones — abdomen quadrants centred on (60, 130),
|
||||
* thighs at (40, 215) / (80, 215), upper arms at (20, 80) / (100, 80).
|
||||
*/
|
||||
export const SITE_COORDS: Record<InjectionSiteKey, { x: number; y: number }> = {
|
||||
ABDOMEN_UPPER_LEFT: { x: 48, y: 110 },
|
||||
ABDOMEN_UPPER_RIGHT: { x: 72, y: 110 },
|
||||
ABDOMEN_LEFT: { x: 48, y: 140 },
|
||||
ABDOMEN_RIGHT: { x: 72, y: 140 },
|
||||
THIGH_LEFT: { x: 42, y: 215 },
|
||||
THIGH_RIGHT: { x: 78, y: 215 },
|
||||
UPPER_ARM_LEFT: { x: 18, y: 82 },
|
||||
UPPER_ARM_RIGHT: { x: 102, y: 82 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Recommend the next injection site given the recent rotation
|
||||
* history. Strategy: maximise the average Euclidean distance from the
|
||||
* candidate to each of the last N sites (N defaults to 4), tie-break
|
||||
* by least-recently-used (an unseen site beats a stale one).
|
||||
*
|
||||
* Returns `null` only when the input history is empty (no
|
||||
* recommendation to make — the picker shows all sites as available).
|
||||
*/
|
||||
export function nextInjectionSite(
|
||||
history: ReadonlyArray<InjectionSiteKey>,
|
||||
windowSize = 4,
|
||||
): InjectionSiteKey | null {
|
||||
if (history.length === 0) {
|
||||
// First-time user — recommend a sensible default (abdomen left)
|
||||
// rather than null. The card needs SOMETHING to point to.
|
||||
return "ABDOMEN_LEFT";
|
||||
}
|
||||
|
||||
const recent = history.slice(0, windowSize);
|
||||
// Site → most recent index in the recent window (lower = more
|
||||
// recent). Sites missing from the window get a large rank so they
|
||||
// beat any recently-used site in the tie-break.
|
||||
const lastSeenRank = new Map<InjectionSiteKey, number>();
|
||||
for (let i = 0; i < recent.length; i += 1) {
|
||||
if (!lastSeenRank.has(recent[i])) {
|
||||
lastSeenRank.set(recent[i], i);
|
||||
}
|
||||
}
|
||||
|
||||
let bestSite: InjectionSiteKey = INJECTION_SITE_KEYS[0];
|
||||
let bestScore = -Infinity;
|
||||
|
||||
for (const candidate of INJECTION_SITE_KEYS) {
|
||||
if (candidate === recent[0]) continue; // never recommend the most-recent site
|
||||
const candidateCoord = SITE_COORDS[candidate];
|
||||
let dist = 0;
|
||||
for (const used of recent) {
|
||||
const usedCoord = SITE_COORDS[used];
|
||||
const dx = candidateCoord.x - usedCoord.x;
|
||||
const dy = candidateCoord.y - usedCoord.y;
|
||||
dist += Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
// Recency penalty — sites used more recently score lower.
|
||||
const rank = lastSeenRank.get(candidate);
|
||||
const recencyBonus = rank === undefined ? recent.length * 50 : -rank * 10;
|
||||
const score = dist + recencyBonus;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestSite = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return bestSite;
|
||||
}
|
||||
|
||||
/**
|
||||
* i18n-friendly human-readable label key for a site. Caller passes
|
||||
* it to `t()` to render the localised string.
|
||||
*/
|
||||
export function describeInjectionSite(site: InjectionSiteKey): string {
|
||||
const suffix = site
|
||||
.toLowerCase()
|
||||
.split("_")
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join("");
|
||||
return `medications.site${suffix}`;
|
||||
}
|
||||
Reference in new issue
Block a user