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:
Marc-André Bombeck committed 2026-05-14 13:36:30 +02:00
1 parent b50ebccf0b
commit b8a1c1833b
5 files changed
+813 -14

No files matched your search

+172
View File
@@ -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);
},
);
+31 -14
View File
@@ -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;
+112
View File
@@ -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}`;
}