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>
This commit is contained in:
Copilot
2026-02-25 21:29:25 +01:00
committed by GitHub
parent 691550fb33
commit 2a9ca39c24
23 changed files with 151 additions and 114 deletions
+11 -10
View File
@@ -8,6 +8,7 @@ import { useAppContext } from "../context";
import { useModalHistory } from "../hooks";
import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters";
import { expandDoseIds, getStockStatus, isDoseDismissed } from "../utils/schedule";
import { getMedDisplayName } from "../types";
import {
formatFullBlisters,
formatOpenBlisterAndLoose,
@@ -118,7 +119,7 @@ export function DashboardPage() {
})
.map((med) => ({
id: med.id,
name: med.name,
name: getMedDisplayName(med),
remainingRefills: med.prescriptionRemainingRefills ?? 0,
threshold: med.prescriptionLowRefillThreshold ?? 1,
}))
@@ -250,7 +251,7 @@ export function DashboardPage() {
<span className="reminder-status-label">{t("dashboard.reminders.needsRefill")}:</span>
<span className="reminder-status-value">
{reminderData.lowStockMeds.map((med, idx) => {
const medication = meds.find((m) => m.name === med.name);
const medication = meds.find((m) => getMedDisplayName(m) === med.name);
const cov = coverage.all.find((c) => c.name === med.name);
const status = cov ? getStockStatus(cov.daysLeft, cov.medsLeft, stockThresholds) : null;
const textClass =
@@ -322,7 +323,7 @@ export function DashboardPage() {
(() => {
const names = reminderData.lastStockSent!.medNames!.split(", ");
return names.map((name, idx) => {
const medication = meds.find((m) => m.name === name);
const medication = meds.find((m) => getMedDisplayName(m) === name);
return (
<span key={name}>
{idx > 0 && ", "}
@@ -353,7 +354,7 @@ export function DashboardPage() {
<span className="reminder-status-value">
{reminderData.lastIntakeSent.medName &&
(() => {
const medication = meds.find((m) => m.name === reminderData.lastIntakeSent!.medName);
const medication = meds.find((m) => getMedDisplayName(m) === reminderData.lastIntakeSent!.medName);
return medication ? (
<span
className="med-link clickable"
@@ -428,7 +429,7 @@ export function DashboardPage() {
<p>
{t("dashboard.reorder.lowWarningPrefix")}{" "}
{lowStockMeds.map((c, idx) => {
const med = meds.find((m) => m.name === c.name);
const med = meds.find((m) => getMedDisplayName(m) === c.name);
const status = getStockStatus(c.daysLeft, c.medsLeft, stockThresholds);
const textClass =
status.className === "danger"
@@ -485,7 +486,7 @@ export function DashboardPage() {
</div>
{coverage.all.map((row) => {
const status = getStockStatus(row.daysLeft, row.medsLeft, stockThresholds);
const med = meds.find((m) => m.name === row.name);
const med = meds.find((m) => getMedDisplayName(m) === row.name);
const expiryClass = getExpiryClass(med?.expiryDate, settings.expiryWarningDays);
const textClass =
status.className === "danger"
@@ -673,7 +674,7 @@ export function DashboardPage() {
// Count missed doses that are NOT dismissed (for warning icon)
const missedNotDismissedCount = day.meds.reduce((count, item) => {
const med = meds.find((m) => m.name === item.medName);
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
const dismissedUntilDate = med?.dismissedUntil ?? undefined;
return (
count +
@@ -729,7 +730,7 @@ export function DashboardPage() {
</div>
{!isCollapsed &&
day.meds.map((item) => {
const med = meds.find((m) => m.name === item.medName);
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
const medCov = coverageByMed[item.medName];
const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
const status = medCov
@@ -986,7 +987,7 @@ export function DashboardPage() {
{!isCollapsed &&
day.meds.map((item) => {
const medCoverage = coverageByMed[item.medName];
const med = meds.find((m) => m.name === item.medName);
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
const depletionTime = depletionByMed[item.medName];
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
@@ -1217,7 +1218,7 @@ export function DashboardPage() {
{!isCollapsed &&
day.meds.map((item) => {
const medCoverage = coverageByMed[item.medName];
const med = meds.find((m) => m.name === item.medName);
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
const depletionTime = depletionByMed[item.medName];
const _isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
+23 -18
View File
@@ -18,7 +18,7 @@ import { useAuth } from "../components/Auth";
import { useAppContext, useUnsavedChanges } from "../context";
import { useMedicationForm, useModalHistory, useUnsavedChangesWarning } from "../hooks";
import type { DoseUnit, Medication } from "../types";
import { DOSE_UNITS, FIELD_LIMITS, getPackageSize } from "../types";
import { DOSE_UNITS, FIELD_LIMITS, getPackageSize, getMedDisplayName } from "../types";
import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters";
import { MAX_IMAGE_UPLOAD_BYTES, resolveImageUploadError } from "../utils/image-upload";
import { log } from "../utils/logger";
@@ -836,19 +836,19 @@ export function MedicationsPage() {
<span
className={med.imageUrl ? "med-avatar-clickable" : undefined}
onClick={() =>
med.imageUrl && setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name })
med.imageUrl && setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: getMedDisplayName(med) })
}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med.imageUrl) setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name });
if (med.imageUrl) setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: getMedDisplayName(med) });
}
}}
>
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="lg" />
<MedicationAvatar name={getMedDisplayName(med)} imageUrl={med.imageUrl} size="lg" />
</span>
<div className="med-name-block">
<div className="med-name">{med.name}</div>
{med.genericName && <div className="med-generic-name">{med.genericName}</div>}
<div className="med-name">{getMedDisplayName(med)}</div>
{med.name && med.genericName && <div className="med-generic-name">{med.genericName}</div>}
</div>
</div>
<div className="med-actions">
@@ -910,10 +910,10 @@ export function MedicationsPage() {
)}
<div className="med-total">
{t("medications.details.stock")}:{" "}
{coverageByMed[med.name] ? Math.round(coverageByMed[med.name].medsLeft) : getPackageSize(med)} /{" "}
{coverageByMed[getMedDisplayName(med)] ? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft) : getPackageSize(med)} /{" "}
{getPackageSize(med)} {getPackageSize(med) === 1 ? t("common.pill") : t("common.pills")}
{(coverageByMed[med.name]
? Math.round(coverageByMed[med.name].medsLeft)
{(coverageByMed[getMedDisplayName(med)]
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
: getPackageSize(med)) > getPackageSize(med) && (
<span
className="info-tooltip tooltip-align-left warning-text"
@@ -970,20 +970,20 @@ export function MedicationsPage() {
<span
className={med.imageUrl ? "med-avatar-clickable" : undefined}
onClick={() =>
med.imageUrl && setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name })
med.imageUrl && setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: getMedDisplayName(med) })
}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med.imageUrl)
setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name });
setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: getMedDisplayName(med) });
}
}}
>
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="lg" />
<MedicationAvatar name={getMedDisplayName(med)} imageUrl={med.imageUrl} size="lg" />
</span>
<div className="med-name-block">
<div className="med-name">{med.name}</div>
{med.genericName && <div className="med-generic-name">{med.genericName}</div>}
<div className="med-name">{getMedDisplayName(med)}</div>
{med.name && med.genericName && <div className="med-generic-name">{med.genericName}</div>}
</div>
</div>
<div className="med-actions">
@@ -1106,21 +1106,26 @@ export function MedicationsPage() {
onBlur={() => setShowNameValidation(true)}
placeholder={t("form.placeholders.commercial")}
maxLength={FIELD_LIMITS.name.max}
required={!readOnlyView}
/>
{!readOnlyView && showNameValidation && fieldErrors.name && (
<span className="field-error">{fieldErrors.name}</span>
)}
</label>
<label className={fieldErrors.genericName ? "has-error" : ""}>
<label className={!readOnlyView && showNameValidation && fieldErrors.genericName ? "has-error" : ""}>
{t("form.genericName")}
<input
value={form.genericName}
onChange={(e) => setForm({ ...form, genericName: e.target.value })}
onChange={(e) => {
setShowNameValidation(true);
setForm({ ...form, genericName: e.target.value });
}}
onBlur={() => setShowNameValidation(true)}
placeholder={t("form.placeholders.generic")}
maxLength={FIELD_LIMITS.genericName.max}
/>
{fieldErrors.genericName && <span className="field-error">{fieldErrors.genericName}</span>}
{!readOnlyView && showNameValidation && fieldErrors.genericName && (
<span className="field-error">{fieldErrors.genericName}</span>
)}
</label>
<label>
{t("form.medicationStartDate")}
+2 -1
View File
@@ -5,6 +5,7 @@ import { DateTimeInput, MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
import { useAppContext } from "../context";
import type { PlannerRow } from "../types";
import { getMedDisplayName } from "../types";
import { toInputValue } from "../utils/formatters";
// Date helpers
@@ -204,7 +205,7 @@ export function PlannerPage() {
</div>
{plannerRows.map((row) => {
const med =
meds.find((m) => m.id === row.medicationId) || meds.find((m) => m.name === row.medicationName);
meds.find((m) => m.id === row.medicationId) || meds.find((m) => getMedDisplayName(m) === row.medicationName);
const remainingRefills = med?.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? 0) : null;
return (
<div
+4 -3
View File
@@ -5,6 +5,7 @@ import { MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
import { useAppContext } from "../context";
import type { Coverage } from "../types";
import { getMedDisplayName } from "../types";
import { expandDoseIds, isDoseDismissed } from "../utils/schedule";
// Helper for user-specific localStorage keys
@@ -116,7 +117,7 @@ export function SchedulePage() {
// Count missed doses that are NOT dismissed (for warning icon)
const missedNotDismissedCount = day.meds.reduce((count, item) => {
const med = meds.find((m) => m.name === item.medName);
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
const dismissedUntilDate = med?.dismissedUntil ?? undefined;
return (
count +
@@ -171,7 +172,7 @@ export function SchedulePage() {
</div>
{!isCollapsed &&
day.meds.map((item) => {
const med = meds.find((m) => m.name === item.medName);
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
const medCov = coverageByMed[item.medName];
const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
const itemDoseIds = expandDoseIds(item.doses);
@@ -333,7 +334,7 @@ export function SchedulePage() {
{day.meds.map((item) => {
const medCoverage = coverageByMed[item.medName];
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
const med = meds.find((m) => m.name === item.medName);
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
const depletionTime = depletionByMed[item.medName];
// Check if this dose is scheduled after medication runs out
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;