diff --git a/messages/de.json b/messages/de.json index 8041ed5e8..bf7c1201e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index f23dd7fb4..b9c17df04 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 64bf97303..48c28a592 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -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 && { diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 132eb028c..561a453d5 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -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, diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index 7433ddc58..cb1a7a2aa 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -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) => ({ diff --git a/src/components/medications/medication-form.tsx b/src/components/medications/medication-form.tsx index 32b31300c..52eb36459 100644 --- a/src/components/medications/medication-form.tsx +++ b/src/components/medications/medication-form.tsx @@ -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( + 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({ + {/* 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. */} +
+ + +

+ {t("medications.formTreatmentClassHelp")} +

+
+
@@ -537,6 +630,75 @@ export function MedicationForm({ ))} + {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. */} +
+
+ +

+ {t("medications.glp1WeeklyPresetHelp")} +

+
+
+ {getDayLabelsShort(t).map((label, dayIndex) => { + const isSelected = + schedules[0]?.daysOfWeek.length === 1 && + schedules[0]?.daysOfWeek[0] === dayIndex; + return ( + + ); + })} +
+ {!schedules.some((s) => s.daysOfWeek.length === 1) && ( + + )} +
+
+ + setDosesPerUnit(e.target.value)} + placeholder={t("medications.glp1DosesPerUnitPlaceholder")} + /> +

+ {t("medications.glp1DosesPerUnitHelp")} +

+
+ + )} +