From b8a1c1833b97e7d19d7ed5fa2533e93f352679cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 13:36:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(medications):=20GLP1=20medication-card=20v?= =?UTF-8?q?ariant=20=E2=80=94=20text-rich,=20no=20chart=20per=20Marc=20dir?= =?UTF-8?q?ective?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/api/medications/[id]/glp1/route.ts | 172 ++++++ src/app/medications/page.tsx | 45 +- .../medications/glp1-medication-card.tsx | 490 ++++++++++++++++++ .../medications/medication-card.tsx | 8 + src/lib/medications/injection-sites.ts | 112 ++++ 5 files changed, 813 insertions(+), 14 deletions(-) create mode 100644 src/app/api/medications/[id]/glp1/route.ts create mode 100644 src/components/medications/glp1-medication-card.tsx create mode 100644 src/lib/medications/injection-sites.ts diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts new file mode 100644 index 000000000..d213f416d --- /dev/null +++ b/src/app/api/medications/[id]/glp1/route.ts @@ -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); + }, +); diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index cb1a7a2aa..9128f9d43 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -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 && (
- {activeMeds.map((med) => ( - - ))} + {activeMeds.map((med) => + med.treatmentClass === "GLP1" ? ( + + ) : ( + + ), + )}
)} @@ -217,13 +226,21 @@ export default function MedicationsPage() { {t("common.inactive")} ({inactiveMeds.length})
- {inactiveMeds.map((med) => ( - - ))} + {inactiveMeds.map((med) => + med.treatmentClass === "GLP1" ? ( + + ) : ( + + ), + )}
)} diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx new file mode 100644 index 000000000..acc49476e --- /dev/null +++ b/src/components/medications/glp1-medication-card.tsx @@ -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(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 ( + + +
+
+ + + {medication.name} + + · {medication.dose} + + +
+ + {t("medications.treatmentClassGlp1")} + + {!medication.notificationsEnabled && ( + + {t("medications.withoutNotification")} + + )} + {!medication.active && ( + + {medication.pausedAt + ? `${t("medications.pausedSince")} ${formatDateTime(medication.pausedAt)}` + : t("medications.inactive")} + + )} +
+
+
+ + +
+
+
+ + + {/* Injection state — last + next */} +
+ {medication.lastTakenAt && ( +

+ {lastSite + ? t("medications.glp1LastInjectionWithSite", { + label: lastInjectionLabel() ?? "—", + site: t(`medications.site${siteSuffix(lastSite)}`), + }) + : t("medications.glp1LastInjection", { + label: lastInjectionLabel() ?? "—", + })} +

+ )} +

{nextInjectionLabel()}

+
+ + {/* 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 && ( +
+

+ {t("medications.glp1RotationLast", { + site: t(`medications.site${siteSuffix(lastSite)}`), + })} +

+

+ {t("medications.glp1RotationSuggested", { + site: t(`medications.site${siteSuffix(recommendedNextSite)}`), + })} +

+
+ )} + + {/* Inventory line — only when dosesPerUnit + at least one + inventory event recorded. Low-stock gets a warning badge. */} + {inventory && inventory.pensRemaining !== null && ( +
+ + {t("medications.glp1Inventory", { + pens: inventory.pensRemaining, + weeks: inventory.weeksOfSupply ?? "—", + })} + + {inventory.lowStock && ( + + {t("medications.glp1InventoryLow")} + + )} +
+ )} + + {/* Dose-history disclosure — collapsible row. Stays closed by + default so the card height matches a generic card on first + paint. */} +
setShowHistory((e.target as HTMLDetailsElement).open)} + > + + {t("medications.glp1DoseHistory")} + + +
+ {doseChanges.length === 0 && ( +

+ {t("medications.glp1DoseHistoryEmpty")} +

+ )} + {doseChanges + .slice() + .reverse() + .map((change) => ( +
+ + {change.doseValue} {change.doseUnit} + + + {t("medications.glp1DoseSince", { + date: fmt.dateShort(new Date(change.effectiveFrom)), + })} + +
+ ))} +
+
+ + {/* Compliance bars — identical to the generic card so the page + grid stays harmonious. */} + {medication.active && compliance && ( +
+
+
+ + {t("medications.compliance7d")} + + {rate7}% +
+ +
+
+
+ + {t("medications.compliance30d")} + + {rate30}% +
+ +
+ {streak > 0 && ( +
+ + + {streak} {t("medications.dayStreak")} + +
+ )} +
+ )} + + {/* 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 && ( +
+ + + {onLogSideEffect && ( + + )} +
+ )} +
+
+ ); +} + +/** + * 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 }; diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 1ddcd4c1a..b8f8f2045 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -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; diff --git a/src/lib/medications/injection-sites.ts b/src/lib/medications/injection-sites.ts new file mode 100644 index 000000000..29c937fb4 --- /dev/null +++ b/src/lib/medications/injection-sites.ts @@ -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 = { + 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, + 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(); + 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}`; +}