mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +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);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user