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")}
+
+ )}
+
+
+
+
+
+
+
+
+
onEdit(medication)}
+ aria-label={t("common.edit")}
+ >
+
+
+
+
+
+
+
+ {/* 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 && (
+
+ recordIntake(false)}
+ disabled={!!intakeLoading}
+ >
+ {intakeLoading === "take" ? (
+
+ ) : (
+
+ )}
+ {t("medications.taken")}
+
+ recordIntake(true)}
+ disabled={!!intakeLoading}
+ >
+ {intakeLoading === "skip" ? (
+
+ ) : (
+
+ )}
+ {t("medications.skipped")}
+
+ {onLogSideEffect && (
+ onLogSideEffect(medication)}
+ >
+
+ {t("medications.glp1LogSideEffect")}
+
+ )}
+
+ )}
+
+
+ );
+}
+
+/**
+ * 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}`;
+}