From 2a9ca39c240c3f5dd08b7c3496519c3d269042d4 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 21:29:25 +0100 Subject: [PATCH] Allow medications with only a generic name (no commercial name required) (#311) * Initial plan * feat: allow generic name only for medications (frontend changes) - Add getMedDisplayName() helper for consistent name display - Update validation to require either commercial or generic name - Update all display locations to use display name fallback - Add i18n keys for nameOrGenericRequired in en.json and de.json - Remove required attribute from commercial name field - Update FIELD_LIMITS.name.min from 1 to 0 Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com> * feat: allow generic name only for medications (backend changes) - Update Zod schema to allow empty name with cross-field refinement - Update reminder scheduler to use name || genericName for display - Update planner routes to match medications by display name - Update existing tests to match new validation behavior Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com> * fix: update placeholder text and fix FIELD_LIMITS test - Remove "(optional)" from generic name placeholder in en/de - Update types.test.ts to expect FIELD_LIMITS.name.min = 0 Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com> --- backend/package-lock.json | 4 +- backend/src/routes/medications.ts | 6 ++- backend/src/routes/planner.ts | 8 ++-- .../src/services/intake-reminder-scheduler.ts | 10 +++-- backend/src/services/reminder-scheduler.ts | 4 +- frontend/package-lock.json | 4 +- frontend/src/components/MedDetailModal.tsx | 16 ++++---- frontend/src/components/MobileEditModal.tsx | 15 ++++--- frontend/src/components/ReportModal.tsx | 34 +++++++-------- frontend/src/components/SharedSchedule.tsx | 28 ++++++------- frontend/src/components/UserFilterModal.tsx | 10 ++--- frontend/src/hooks/useMedicationForm.ts | 13 ++++-- frontend/src/i18n/de.json | 3 +- frontend/src/i18n/en.json | 3 +- frontend/src/pages/DashboardPage.tsx | 21 +++++----- frontend/src/pages/MedicationsPage.tsx | 41 +++++++++++-------- frontend/src/pages/PlannerPage.tsx | 3 +- frontend/src/pages/SchedulePage.tsx | 7 ++-- .../src/test/hooks/useMedicationForm.test.ts | 5 ++- frontend/src/test/types.test.ts | 2 +- frontend/src/types/index.ts | 7 +++- frontend/src/utils/ics.ts | 12 +++--- frontend/src/utils/schedule.ts | 9 ++-- 23 files changed, 151 insertions(+), 114 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 1b7dc45..2601107 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "medassist-ng-backend", - "version": "1.15.1", + "version": "1.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "medassist-ng-backend", - "version": "1.15.1", + "version": "1.16.0", "dependencies": { "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.2.0", diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts index 7d62f02..24e7f42 100644 --- a/backend/src/routes/medications.ts +++ b/backend/src/routes/medications.ts @@ -42,7 +42,7 @@ const medicationStartDateSchema = z const medicationSchema = z .object({ - name: z.string().trim().min(1).max(100), + name: z.string().trim().max(100).default(""), genericName: z.string().trim().max(100).nullable().optional(), takenBy: z.array(z.string().trim().max(100)).default([]), // Medication-level takenBy (fallback) packageType: packageTypeSchema, @@ -66,6 +66,10 @@ const medicationSchema = z intakes: z.array(intakeSchema).min(1).max(12).optional(), blisters: z.array(blisterSchema).min(1).max(12).optional(), // Legacy format }) + .refine( + (data) => (data.name && data.name.length > 0) || (data.genericName && data.genericName.length > 0), + { message: "Either 'name' or 'genericName' must be provided", path: ["name"] } + ) .refine((data) => data.intakes || data.blisters, { message: "Either 'intakes' or 'blisters' must be provided" }) .refine( (data) => { diff --git a/backend/src/routes/planner.ts b/backend/src/routes/planner.ts index 6876cf8..6be7d23 100644 --- a/backend/src/routes/planner.ts +++ b/backend/src/routes/planner.ts @@ -371,10 +371,10 @@ ${getFooterPlain(language)}`; // Load user settings const userId = await getUserId(request); const activeMeds = await db - .select({ name: medications.name }) + .select({ name: medications.name, genericName: medications.genericName }) .from(medications) .where(and(eq(medications.userId, userId), eq(medications.isObsolete, false))); - const activeMedNames = new Set(activeMeds.map((med) => med.name)); + const activeMedNames = new Set(activeMeds.map((med) => med.name || med.genericName || "")); const filteredLowStock = lowStock.filter((item) => activeMedNames.has(item.name)); if (filteredLowStock.length === 0) { return reply.status(400).send({ error: "No active medications to notify" }); @@ -641,10 +641,10 @@ ${getFooterPlain(language)}`; const userId = await getUserId(request); const activeMeds = await db - .select({ name: medications.name }) + .select({ name: medications.name, genericName: medications.genericName }) .from(medications) .where(and(eq(medications.userId, userId), eq(medications.isObsolete, false))); - const activeMedNames = new Set(activeMeds.map((med) => med.name)); + const activeMedNames = new Set(activeMeds.map((med) => med.name || med.genericName || "")); const filteredPrescriptionLow = prescriptionLow.filter((item) => activeMedNames.has(item.name)); if (filteredPrescriptionLow.length === 0) { return reply.status(400).send({ error: "No active medications to notify" }); diff --git a/backend/src/services/intake-reminder-scheduler.ts b/backend/src/services/intake-reminder-scheduler.ts index 7d8deaf..df06b78 100644 --- a/backend/src/services/intake-reminder-scheduler.ts +++ b/backend/src/services/intake-reminder-scheduler.ts @@ -106,8 +106,9 @@ async function autoMarkDueIntakesAsTaken( } const medicationTakenBy = parseTakenByJson(med.takenByJson); + const medDisplayName = med.name || med.genericName || ""; const todaysIntakes = getTodaysIntakes( - med.name, + medDisplayName, intakes, medicationTakenBy, med.pillWeightMg, @@ -415,9 +416,10 @@ async function checkAndSendIntakeRemindersForUser( ); // Medication-level takenBy (for fallback/display purposes) const medicationTakenBy = parseTakenByJson(med.takenByJson); + const medDisplayName = med.name || med.genericName || ""; logger.debug( - `[IntakeReminder] User ${settings.userId}: Processing medication "${med.name}" with ${intakes.length} intakes` + `[IntakeReminder] User ${settings.userId}: Processing medication "${medDisplayName}" with ${intakes.length} intakes` ); // Filter intakes that have reminders enabled (per-intake setting or medication-level) @@ -438,7 +440,7 @@ async function checkAndSendIntakeRemindersForUser( // Always get upcoming intakes (15 min before) for first reminders const upcomingIntakes = getUpcomingIntakes( - med.name, + medDisplayName, [intake], REMINDER_MINUTES_BEFORE, medicationTakenBy, @@ -465,7 +467,7 @@ async function checkAndSendIntakeRemindersForUser( // If repeat reminders enabled, also check for missed intakes (past the intake time) if (settings.repeatRemindersEnabled) { const allTodaysIntakes = getTodaysIntakes( - med.name, + medDisplayName, [intake], medicationTakenBy, med.pillWeightMg, diff --git a/backend/src/services/reminder-scheduler.ts b/backend/src/services/reminder-scheduler.ts index 32eee03..0a7dbb7 100644 --- a/backend/src/services/reminder-scheduler.ts +++ b/backend/src/services/reminder-scheduler.ts @@ -353,7 +353,7 @@ async function getMedicationsNeedingReminder( if (isCritical || isLow) { lowStock.push({ - name: row.name, + name: row.name || row.genericName || "", medsLeft: currentPills, daysLeft, depletionDate, @@ -379,7 +379,7 @@ async function getMedicationsNeedingPrescriptionReminder(userId: number): Promis (row.prescriptionRemainingRefills ?? 0) <= (row.prescriptionLowRefillThreshold ?? 1) ) .map((row) => ({ - name: row.name, + name: row.name || row.genericName || "", remainingRefills: row.prescriptionRemainingRefills ?? 0, lowThreshold: row.prescriptionLowRefillThreshold ?? 1, expiryDate: row.prescriptionExpiryDate ?? null, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 13a60e8..69dc297 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "medassist-ng-frontend", - "version": "1.15.1", + "version": "1.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "medassist-ng-frontend", - "version": "1.15.1", + "version": "1.16.0", "dependencies": { "i18next": "^25.8.13", "i18next-browser-languagedetector": "^8.2.1", diff --git a/frontend/src/components/MedDetailModal.tsx b/frontend/src/components/MedDetailModal.tsx index af8c08e..8639f21 100644 --- a/frontend/src/components/MedDetailModal.tsx +++ b/frontend/src/components/MedDetailModal.tsx @@ -15,7 +15,7 @@ import { useTranslation } from "react-i18next"; import { Lightbox, MedicationAvatar } from "../components"; import { useEscapeKey } from "../hooks"; import type { Coverage, Medication, RefillEntry, StockThresholds } from "../types"; -import { getMedTotal, getPackageSize } from "../types"; +import { getMedDisplayName, getMedTotal, getPackageSize } from "../types"; import { formatNumber, generateICS, getExpiryClass, getSystemLocale } from "../utils"; import { getStockStatus } from "../utils/schedule"; import { splitCurrentBlisterStock } from "../utils/stock"; @@ -193,7 +193,7 @@ export function MedDetailModal({ if (!selectedMed) return null; - const medCoverage = coverage.all.find((c) => c.name === selectedMed.name); + const medCoverage = coverage.all.find((c) => c.name === getMedDisplayName(selectedMed)); const packageSize = getPackageSize(selectedMed); // Structural max = sealed package capacity only (excludes pre-existing looseTablets). const structuralMax = @@ -380,7 +380,7 @@ export function MedDetailModal({