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

+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}`;
}