feat(medications): once-weekly cadence preset for GLP1 category

Phase 3 of W4d GLP-1 full integration. The form gains a small
"Treatment class" selector (GENERIC | GLP-1 injection) sitting under
the existing clinical-category dropdown. Picking GLP-1 reveals two
helpers that stay hidden for every other medication:

- "Once weekly on …" preset — a single-tap weekday row that writes
  daysOfWeek=[N] + intervalWeeks=1 into the first schedule and reveals
  the advanced block so the user can still tune the window. The last
  weekday picked persists in localStorage so a follow-up med starts on
  the same day.
- "Doses per pen / vial" optional input that powers the inventory math
  (Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose
  pen = 1). Leaving it blank keeps inventory tracking off.

The wire schema gains optional `treatmentClass` and `dosesPerUnit`
fields. Both default sensibly (GENERIC + null) so the v1.4.24 client
keeps working untouched.

Marc directive 2026-05-14: GLP-1 must integrate "harmonisch", not
crowd the form. The new affordances live behind the treatment-class
toggle — for every other med the form is byte-identical to v1.4.24.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 13:33:18 +02:00
1 parent c7eb226969
commit 94cc7f571d
7 files changed
+279 -3

No files matched your search

+33
View File
@@ -516,6 +516,39 @@
"formDose": "Dosis",
"formUnit": "Einheit",
"formSchedule": "Zeitfenster",
"formTreatmentClass": "Behandlungsklasse",
"formTreatmentClassHelp": "Wähle „GLP-1-Injektion“ für wöchentliche Spritzen wie Mounjaro, Ozempic, Wegovy, Zepbound. Schaltet die Körperkarte, die Dosis-Historie und den Pen-Bestand unten frei.",
"treatmentClassGeneric": "Standard-Medikament",
"treatmentClassGlp1": "GLP-1-Injektion",
"glp1WeeklyPresetTitle": "Einmal wöchentlich am …",
"glp1WeeklyPresetHelp": "Die meisten GLP-1-Rezeptoragonisten werden einmal pro Woche injiziert. Tippe einen Tag, um den Rhythmus zu setzen.",
"glp1WeeklyPresetSuggest": "Letzten gewählten Wochentag übernehmen",
"glp1DosesPerUnit": "Dosen pro Pen / Ampulle",
"glp1DosesPerUnitPlaceholder": "z. B. 4 für einen Mounjaro KwikPen",
"glp1DosesPerUnitHelp": "Optional — setzt die Reichweite-Anzeige im Dashboard frei. Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy Einmal-Pen = 1. Leer lassen, um die Bestandsverfolgung auszuschalten.",
"glp1Headline": "{name} · {dose}",
"glp1LastInjection": "Letzter Termin: {label}",
"glp1LastInjectionWithSite": "Letzter Termin: {label} · {site}",
"glp1NextInjection": "Nächster Termin: {label}",
"glp1NextInjectionDays": "Nächster Termin: {label} (in {days} Tagen)",
"glp1NextInjectionToday": "Nächster Termin: heute",
"glp1NextInjectionTomorrow": "Nächster Termin: morgen",
"glp1DoseHistory": "Dosis-Historie",
"glp1DoseHistoryEmpty": "Noch keine Dosis-Änderung erfasst.",
"glp1DoseSince": "seit {date}",
"glp1RotationLast": "Letzte Stelle: {site}",
"glp1RotationSuggested": "Empfohlen: {site}",
"glp1Inventory": "{pens} Pens übrig · ~{weeks} Wochen Reichweite",
"glp1InventoryLow": "Niedriger Bestand — bald nachbestellen",
"glp1LogSideEffect": "Nebenwirkung loggen",
"siteAbdomenLeft": "Bauch, unten links",
"siteAbdomenRight": "Bauch, unten rechts",
"siteAbdomenUpperLeft": "Bauch, oben links",
"siteAbdomenUpperRight": "Bauch, oben rechts",
"siteThighLeft": "Linker Oberschenkel",
"siteThighRight": "Rechter Oberschenkel",
"siteUpperArmLeft": "Linker Oberarm",
"siteUpperArmRight": "Rechter Oberarm",
"newSchedule": "Neues Zeitfenster",
"removeSchedule": "Zeitfenster entfernen",
"advancedEditing": "Erweiterte Bearbeitung",
+33
View File
@@ -516,6 +516,39 @@
"formDose": "Dose",
"formUnit": "Unit",
"formSchedule": "Schedule",
"formTreatmentClass": "Treatment class",
"formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker, dose-history view, and pen inventory below.",
"treatmentClassGeneric": "Standard medication",
"treatmentClassGlp1": "GLP-1 injection",
"glp1WeeklyPresetTitle": "Once weekly on …",
"glp1WeeklyPresetHelp": "Most GLP-1 receptor agonists are once-weekly. Tap a day to set the cadence.",
"glp1WeeklyPresetSuggest": "Use my last weekday choice",
"glp1DosesPerUnit": "Doses per pen / vial",
"glp1DosesPerUnitPlaceholder": "e.g. 4 for a Mounjaro KwikPen",
"glp1DosesPerUnitHelp": "Optional — set to enable the low-stock dashboard caption. Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank to disable inventory tracking.",
"glp1Headline": "{name} · {dose}",
"glp1LastInjection": "Last: {label}",
"glp1LastInjectionWithSite": "Last: {label} · {site}",
"glp1NextInjection": "Next: {label}",
"glp1NextInjectionDays": "Next: {label} (in {days} days)",
"glp1NextInjectionToday": "Next: today",
"glp1NextInjectionTomorrow": "Next: tomorrow",
"glp1DoseHistory": "Dose history",
"glp1DoseHistoryEmpty": "No dose changes logged yet.",
"glp1DoseSince": "since {date}",
"glp1RotationLast": "Last site: {site}",
"glp1RotationSuggested": "Recommended next: {site}",
"glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply",
"glp1InventoryLow": "Low stock — refill soon",
"glp1LogSideEffect": "Log side effect",
"siteAbdomenLeft": "Abdomen, lower left",
"siteAbdomenRight": "Abdomen, lower right",
"siteAbdomenUpperLeft": "Abdomen, upper left",
"siteAbdomenUpperRight": "Abdomen, upper right",
"siteThighLeft": "Left thigh",
"siteThighRight": "Right thigh",
"siteUpperArmLeft": "Left upper arm",
"siteUpperArmRight": "Right upper arm",
"newSchedule": "New schedule",
"removeSchedule": "Remove schedule",
"advancedEditing": "Advanced editing",
+12 -2
View File
@@ -71,8 +71,16 @@ export const PUT = apiHandler(
return apiError(parsed.error.issues[0].message, 422);
}
const { name, dose, category, active, notificationsEnabled, schedules } =
parsed.data;
const {
name,
dose,
category,
treatmentClass,
dosesPerUnit,
active,
notificationsEnabled,
schedules,
} = parsed.data;
const pausedAtPatch =
active === undefined
@@ -93,6 +101,8 @@ export const PUT = apiHandler(
const baseUpdateData = {
...(name !== undefined && { name }),
...(dose !== undefined && { dose }),
...(treatmentClass !== undefined && { treatmentClass }),
...(dosesPerUnit !== undefined && { dosesPerUnit }),
...(active !== undefined && { active }),
...(notificationsEnabled !== undefined && { notificationsEnabled }),
...(schedules && {
+11 -1
View File
@@ -83,6 +83,11 @@ export const GET = apiHandler(async () => {
return apiSuccess(
medications.map((m) => ({
...m,
// v1.4.25 W4d — `category` is the existing clinical taxonomy
// (BLOOD_PRESSURE / VITAMIN / ...) from the side-table; the
// Prisma model has its own `treatmentClass` (GENERIC | GLP1) that
// spreads through `...m` and unlocks the GLP-1 surfaces. Two
// orthogonal concepts, both exposed.
category: categoryMap[m.id] ?? "OTHER",
lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null,
todayEventCount: todayEventCountByMedId[m.id] ?? 0,
@@ -101,13 +106,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError(parsed.error.issues[0].message, 422);
}
const { name, dose, category, schedules } = parsed.data;
const { name, dose, category, treatmentClass, dosesPerUnit, schedules } =
parsed.data;
const medication = await prisma.medication.create({
data: {
userId: user.id,
name,
dose,
// v1.4.25 W4d — treatmentClass + dosesPerUnit are optional in the
// wire schema; Prisma fills the default GENERIC when omitted.
...(treatmentClass !== undefined && { treatmentClass }),
...(dosesPerUnit !== undefined && { dosesPerUnit }),
schedules: {
create: schedules.map((s) => ({
windowStart: s.windowStart,
+10
View File
@@ -56,6 +56,10 @@ interface Medication {
name: string;
dose: string;
category: string;
/** v1.4.25 W4d — Prisma treatment class (GENERIC | GLP1). */
treatmentClass?: string;
/** v1.4.25 W4d — doses per pen/vial for inventory math. */
dosesPerUnit?: number | null;
active: boolean;
notificationsEnabled: boolean;
pausedAt: string | null;
@@ -253,6 +257,12 @@ export default function MedicationsPage() {
name: editingMed.name,
dose: editingMed.dose,
category: editingMed.category,
// v1.4.25 W4d — pass through the Prisma treatment-class
// discriminator and the optional dosesPerUnit so the
// form preselects the GLP-1 surfaces correctly when
// editing an existing GLP-1 row.
treatmentClass: editingMed.treatmentClass,
dosesPerUnit: editingMed.dosesPerUnit ?? null,
active: editingMed.active,
notificationsEnabled: editingMed.notificationsEnabled,
schedules: editingMed.schedules.map((s) => ({
@@ -168,6 +168,10 @@ interface MedicationFormProps {
name: string;
dose: string;
category: string;
/** v1.4.25 W4d — Prisma treatment class. Defaults to GENERIC. */
treatmentClass?: string;
/** v1.4.25 W4d — only relevant for inventory-tracked meds. */
dosesPerUnit?: number | null;
active: boolean;
notificationsEnabled?: boolean;
schedules: Array<{
@@ -257,6 +261,16 @@ export function MedicationForm({
const [doseUnit, setDoseUnit] = useState(initialDose.unit);
const [category, setCategory] = useState(initial?.category ?? "OTHER");
// v1.4.25 W4d — treatment class. GLP1 unlocks the weekly-cadence
// preset below and the GLP-1 medication-card variant on the medications
// page. The default GENERIC keeps every existing medication rendering
// exactly as before.
const [treatmentClass, setTreatmentClass] = useState(
initial?.treatmentClass ?? "GENERIC",
);
const [dosesPerUnit, setDosesPerUnit] = useState<string>(
initial?.dosesPerUnit ? String(initial.dosesPerUnit) : "",
);
const [active, setActive] = useState(initial?.active ?? true);
const [notificationsEnabled, setNotificationsEnabled] = useState(
initial?.notificationsEnabled ?? true,
@@ -341,12 +355,58 @@ export function MedicationForm({
setDoseAmount("");
setDoseUnit("");
setCategory("OTHER");
setTreatmentClass("GENERIC");
setDosesPerUnit("");
setActive(true);
setNotificationsEnabled(true);
setSchedules([{ ...DEFAULT_SCHEDULE }]);
setError(null);
}
/**
* v1.4.25 W4d — "Once weekly on …" preset. Marc directive
* 2026-05-14: GLP-1 receptor agonists are weekly meds, and the
* existing form buries that behind two advanced toggles. The preset
* collapses the picker to a single weekday button row that writes
* MedicationSchedule.daysOfWeek correctly and remembers the choice
* locally so the next weekly med starts on the same weekday.
*
* Persisted to localStorage as a one-row preference so the convenience
* survives across sessions — non-invasive (key is namespaced) and
* web-only (the iOS surface uses its own picker).
*/
function applyWeeklyPreset(weekday: number) {
try {
localStorage.setItem("medication-form:last-weekly-weekday", String(weekday));
} catch {
/* private mode — silent */
}
setSchedules((prev) =>
sortSchedules(
prev.map((s, i) =>
i === 0
? {
...s,
daysOfWeek: [weekday],
intervalWeeks: 1,
showAdvanced: true,
}
: s,
),
),
);
}
function readLastWeeklyWeekday(): number {
try {
const raw = localStorage.getItem("medication-form:last-weekly-weekday");
const n = raw === null ? NaN : Number(raw);
return Number.isInteger(n) && n >= 0 && n <= 6 ? n : 3; // Wednesday default
} catch {
return 3;
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
@@ -363,6 +423,12 @@ export function MedicationForm({
name,
dose,
category,
treatmentClass,
...(dosesPerUnit
? { dosesPerUnit: Number(dosesPerUnit) }
: isEdit
? { dosesPerUnit: null }
: {}),
...(isEdit
? {
active,
@@ -504,6 +570,33 @@ export function MedicationForm({
</Select>
</div>
{/* v1.4.25 W4d — treatment-class selector. Surfaces the GLP-1
option without crowding the existing clinical-category
dropdown (those are orthogonal taxonomies). GENERIC keeps the
form rendering exactly as v1.4.24; GLP1 opens the
weekly-cadence preset + pen-inventory inputs below. */}
<div className="space-y-1.5">
<Label htmlFor="med-treatment-class">
{t("medications.formTreatmentClass")}
</Label>
<Select value={treatmentClass} onValueChange={setTreatmentClass}>
<SelectTrigger id="med-treatment-class" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GENERIC">
{t("medications.treatmentClassGeneric")}
</SelectItem>
<SelectItem value="GLP1">
{t("medications.treatmentClassGlp1")}
</SelectItem>
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs leading-4">
{t("medications.formTreatmentClassHelp")}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="med-dose-amount">{t("medications.formDose")}</Label>
@@ -537,6 +630,75 @@ export function MedicationForm({
))}
</datalist>
{treatmentClass === "GLP1" && (
<>
{/* v1.4.25 W4d — once-weekly preset. One tap on a weekday
writes daysOfWeek=[N] + intervalWeeks=1 into the first
schedule and reveals the advanced section so the user can
still tune the window. Persists the last pick across
sessions for convenience. */}
<div className="border-border/70 bg-card/60 space-y-2.5 rounded-xl border p-3.5">
<div className="space-y-1">
<Label className="text-sm leading-none">
{t("medications.glp1WeeklyPresetTitle")}
</Label>
<p className="text-muted-foreground text-xs leading-4">
{t("medications.glp1WeeklyPresetHelp")}
</p>
</div>
<div className="flex w-full gap-1">
{getDayLabelsShort(t).map((label, dayIndex) => {
const isSelected =
schedules[0]?.daysOfWeek.length === 1 &&
schedules[0]?.daysOfWeek[0] === dayIndex;
return (
<button
key={dayIndex}
type="button"
onClick={() => applyWeeklyPreset(dayIndex)}
className={`h-9 flex-1 rounded-md border text-xs font-medium transition-colors ${
isSelected
? "border-primary bg-primary text-primary-foreground"
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
}`}
aria-pressed={isSelected}
>
{label}
</button>
);
})}
</div>
{!schedules.some((s) => s.daysOfWeek.length === 1) && (
<button
type="button"
onClick={() => applyWeeklyPreset(readLastWeeklyWeekday())}
className="text-primary text-xs underline-offset-2 hover:underline"
>
{t("medications.glp1WeeklyPresetSuggest")}
</button>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="med-doses-per-unit">
{t("medications.glp1DosesPerUnit")}
</Label>
<Input
id="med-doses-per-unit"
type="number"
inputMode="numeric"
min={1}
max={100}
value={dosesPerUnit}
onChange={(e) => setDosesPerUnit(e.target.value)}
placeholder={t("medications.glp1DosesPerUnitPlaceholder")}
/>
<p className="text-muted-foreground text-xs leading-4">
{t("medications.glp1DosesPerUnitHelp")}
</p>
</div>
</>
)}
<div className="space-y-2">
<div className="flex h-8 items-center justify-between">
<Label className="text-sm leading-none">
+18
View File
@@ -15,6 +15,18 @@ export const MEDICATION_CATEGORY_VALUES = [
"OTHER",
] as const;
/**
* v1.4.25 W4d — Prisma-level treatment class. Orthogonal to
* `MEDICATION_CATEGORY_VALUES` (the clinical taxonomy that lives in the
* `medication_categories` side-table). GLP1 turns on the GLP-1
* specialist surfaces — injection-site picker, titration history, pen
* inventory, GLP-1-aware Coach replies. Future treatment classes drop
* into this list (INSULIN, BIOLOGIC, FERTILITY, …).
*/
export const MEDICATION_TREATMENT_CLASS_VALUES = ["GENERIC", "GLP1"] as const;
export type MedicationTreatmentClass =
(typeof MEDICATION_TREATMENT_CLASS_VALUES)[number];
export const scheduleSchema = z.object({
windowStart: z.string().regex(timeRegex, "Format: HH:mm"),
windowEnd: z.string().regex(timeRegex, "Format: HH:mm"),
@@ -28,6 +40,10 @@ export const createMedicationSchema = z.object({
name: z.string().min(1).max(100),
dose: z.string().min(1).max(50),
category: z.enum(MEDICATION_CATEGORY_VALUES).optional(),
/** v1.4.25 W4d — treatment-class discriminator (GENERIC | GLP1). */
treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(),
/** v1.4.25 W4d — doses per pen/vial for inventory tracking. */
dosesPerUnit: z.number().int().min(1).max(100).optional(),
schedules: z.array(scheduleSchema).min(1, "Mindestens ein Zeitfenster"),
});
@@ -35,6 +51,8 @@ export const updateMedicationSchema = z.object({
name: z.string().min(1).max(100).optional(),
dose: z.string().min(1).max(50).optional(),
category: z.enum(MEDICATION_CATEGORY_VALUES).optional(),
treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(),
dosesPerUnit: z.number().int().min(1).max(100).nullable().optional(),
active: z.boolean().optional(),
notificationsEnabled: z.boolean().optional(),
schedules: z.array(scheduleSchema).optional(),