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
+8 -8
View File
@@ -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({
<X size={18} aria-hidden="true" />
</button>
<h2>{t("editStock.title")}</h2>
<p className="edit-stock-med-name">{selectedMed.name}</p>
<p className="edit-stock-med-name">{getMedDisplayName(selectedMed)}</p>
<p className="edit-stock-hint">{t("editStock.hint")}</p>
{selectedMed.packageType === "blister" && (
<p className="edit-stock-cap-info edit-stock-live-breakdown">
@@ -667,12 +667,12 @@ export function MedDetailModal({
}
}}
>
<MedicationAvatar name={selectedMed.name} imageUrl={selectedMed.imageUrl} size="lg" />
<MedicationAvatar name={getMedDisplayName(selectedMed)} imageUrl={selectedMed.imageUrl} size="lg" />
{selectedMed.imageUrl && <span className="expand-icon">🔍</span>}
</div>
<div className="med-detail-titles">
<h2>{selectedMed.name}</h2>
{selectedMed.genericName && <span className="med-generic-name">{selectedMed.genericName}</span>}
<h2>{getMedDisplayName(selectedMed)}</h2>
{selectedMed.name && selectedMed.genericName && <span className="med-generic-name">{selectedMed.genericName}</span>}
{selectedMed.takenBy && (selectedMed.takenBy || []).length > 0 && (
<span className="med-taken-by">
{t("modal.for")}{" "}
@@ -1017,7 +1017,7 @@ export function MedDetailModal({
{/* Image Lightbox */}
{showImageLightbox && selectedMed.imageUrl && (
<Lightbox src={`/api/images/${selectedMed.imageUrl}`} alt={selectedMed.name} onClose={onCloseImageLightbox} />
<Lightbox src={`/api/images/${selectedMed.imageUrl}`} alt={getMedDisplayName(selectedMed)} onClose={onCloseImageLightbox} />
)}
{/* Refill Modal */}
@@ -1049,7 +1049,7 @@ export function MedDetailModal({
<X size={18} aria-hidden="true" />
</button>
<h2>{t("refill.title")}</h2>
<p className="refill-med-name">{selectedMed.name}</p>
<p className="refill-med-name">{getMedDisplayName(selectedMed)}</p>
<div className="refill-form">
{selectedMed.packageType === "blister" ? (
+10 -5
View File
@@ -253,7 +253,7 @@ export function MobileEditModal({
const mobileTitle = (() => {
if (!editingId) return t("form.newEntry");
if (readOnlyMode) return t("form.viewEntry");
const medicationName = currentMed?.name?.trim() || form.name.trim();
const medicationName = (currentMed ? (currentMed.name?.trim() || currentMed.genericName?.trim()) : null) || form.name.trim() || form.genericName.trim();
if (!medicationName) return t("form.editEntry");
return t("form.editEntryWithName", { name: medicationName });
})();
@@ -361,21 +361,26 @@ export function MobileEditModal({
onBlur={() => setShowNameValidation(true)}
placeholder={t("form.placeholders.commercial")}
maxLength={FIELD_LIMITS.name.max}
required={!readOnlyMode}
/>
{!readOnlyMode && showNameValidation && fieldErrors.name && (
<span className="field-error">{fieldErrors.name}</span>
)}
</label>
<label className={`full ${fieldErrors.genericName ? "has-error" : ""}`}>
<label className={`full ${!readOnlyMode && showNameValidation && fieldErrors.genericName ? "has-error" : ""}`}>
{t("form.genericName")}
<input
value={form.genericName}
onChange={(e) => onFormChange({ ...form, genericName: e.target.value })}
onChange={(e) => {
setShowNameValidation(true);
onFormChange({ ...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>}
{!readOnlyMode && showNameValidation && fieldErrors.genericName && (
<span className="field-error">{fieldErrors.genericName}</span>
)}
</label>
<label className="full">
{t("form.medicationStartDate")}
+18 -16
View File
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { useEscapeKey } from "../hooks/useEscapeKey";
import { useScrollLock } from "../hooks/useScrollLock";
import type { Medication } from "../types";
import { getPackageSize } from "../types";
import { getMedDisplayName, getPackageSize } from "../types";
import { MedicationAvatar } from "./MedicationAvatar";
type ReportFormat = "txt" | "md" | "pdf";
@@ -200,10 +200,10 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
{activeMeds.map((med) => (
<label key={med.id} className="report-med-item">
<input type="checkbox" checked={selectedIds.has(med.id)} onChange={() => toggleMed(med.id)} />
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
<MedicationAvatar name={getMedDisplayName(med)} imageUrl={med.imageUrl} size="sm" />
<span className="report-med-name">
{med.name}
{med.genericName && <span className="report-med-generic"> ({med.genericName})</span>}
{getMedDisplayName(med)}
{med.name && med.genericName && <span className="report-med-generic"> ({med.genericName})</span>}
</span>
</label>
))}
@@ -218,10 +218,10 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
{obsoleteMeds.map((med) => (
<label key={med.id} className="report-med-item">
<input type="checkbox" checked={selectedIds.has(med.id)} onChange={() => toggleMed(med.id)} />
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
<MedicationAvatar name={getMedDisplayName(med)} imageUrl={med.imageUrl} size="sm" />
<span className="report-med-name obsolete-name">
{med.name}
{med.genericName && <span className="report-med-generic"> ({med.genericName})</span>}
{getMedDisplayName(med)}
{med.name && med.genericName && <span className="report-med-generic"> ({med.genericName})</span>}
</span>
</label>
))}
@@ -320,13 +320,13 @@ function generateTextReport(
for (const med of meds) {
lines.push(sep);
lines.push("");
const title = med.isObsolete ? `${med.name} (${t("report.docStatusObsolete")})` : med.name;
const title = med.isObsolete ? `${getMedDisplayName(med)} (${t("report.docStatusObsolete")})` : getMedDisplayName(med);
lines.push(h2(title));
lines.push("");
// General
lines.push(h3(t("report.docGeneral")));
lines.push(item(t("report.docCommercialName"), med.name));
if (med.name) lines.push(item(t("report.docCommercialName"), med.name));
if (med.genericName) lines.push(item(t("report.docGenericName"), med.genericName));
if (med.takenBy?.length) lines.push(item(t("report.docTakenBy"), med.takenBy.join(", ")));
lines.push(
@@ -489,22 +489,24 @@ function buildPrintHtml(
for (const med of meds) {
const data = reportData[med.id];
const intakes = med.intakes ?? med.blisters;
const displayName = getMedDisplayName(med);
const title = med.isObsolete
? `${escHtml(med.name)} <span class="obsolete-badge">${escHtml(t("report.docStatusObsolete"))}</span>`
: escHtml(med.name);
? `${escHtml(displayName)} <span class="obsolete-badge">${escHtml(t("report.docStatusObsolete"))}</span>`
: escHtml(displayName);
let s = `<div class="med-section">`;
const imgDataUrl = imageMap[med.id];
// Title with generic name subtitle
s += `<h2>${title}</h2>`;
if (med.genericName) s += `<p class="generic-subtitle">${escHtml(med.genericName)}</p>`;
if (med.name && med.genericName) s += `<p class="generic-subtitle">${escHtml(med.genericName)}</p>`;
// Build general info table rows
const generalRows: string[] = [];
generalRows.push(
`<tr><td class="label">${escHtml(t("report.docCommercialName"))}</td><td>${escHtml(med.name)}</td></tr>`
);
if (med.name)
generalRows.push(
`<tr><td class="label">${escHtml(t("report.docCommercialName"))}</td><td>${escHtml(med.name)}</td></tr>`
);
if (med.genericName)
generalRows.push(
`<tr><td class="label">${escHtml(t("report.docGenericName"))}</td><td>${escHtml(med.genericName)}</td></tr>`
@@ -527,7 +529,7 @@ function buildPrintHtml(
const generalTable = `<h3>${escHtml(t("report.docGeneral"))}</h3><table><tbody>${generalRows.join("")}</tbody></table>`;
if (imgDataUrl) {
s += `<div class="med-overview"><img class="med-img" src="${imgDataUrl}" alt="${escHtml(med.name)}" /><div class="med-overview-info">${generalTable}</div></div>`;
s += `<div class="med-overview"><img class="med-img" src="${imgDataUrl}" alt="${escHtml(displayName)}" /><div class="med-overview-info">${generalTable}</div></div>`;
} else {
s += generalTable;
}
+14 -14
View File
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { useEscapeKey } from "../hooks";
import type { ExpiredLinkData, SharedScheduleData } from "../types";
import { getMedTotal } from "../types";
import { getMedDisplayName, getMedTotal } from "../types";
import { getSystemLocale } from "../utils/formatters";
import { isDoseDismissed } from "../utils/schedule";
import { loadCollapsedDaysFromStorage } from "../utils/storage";
@@ -343,7 +343,7 @@ export function SharedSchedule() {
doses.push({
id: doseId,
when: t,
medName: med.name,
medName: getMedDisplayName(med),
usage: intake.usage,
isPast,
takenBy: intake.takenBy, // Per-intake takenBy (string | null)
@@ -547,8 +547,8 @@ export function SharedSchedule() {
const daysLeft = rawDaysLeft !== null ? Math.max(0, Math.floor(rawDaysLeft)) : null;
const depletionMs = daysLeft !== null ? now + daysLeft * MS_PER_DAY : null;
coverage[med.name] = { daysLeft, medsLeft: Number(medsLeft.toFixed(1)), dailyUsage: dailyRate };
depletion[med.name] = depletionMs;
coverage[getMedDisplayName(med)] = { daysLeft, medsLeft: Number(medsLeft.toFixed(1)), dailyUsage: dailyRate };
depletion[getMedDisplayName(med)] = depletionMs;
}
return { coverageByMed: coverage, depletionByMed: depletion };
}, [data, takenDoses]);
@@ -746,7 +746,7 @@ export function SharedSchedule() {
// Count missed doses that are NOT dismissed (for warning icon)
const missedNotDismissedCount = day.meds.reduce((count, item) => {
const med = data.medications.find((m) => m.name === item.medName);
const med = data.medications.find((m) => getMedDisplayName(m) === item.medName);
const dismissedUntilDate = med?.dismissedUntil ?? undefined;
return (
count +
@@ -800,7 +800,7 @@ export function SharedSchedule() {
</div>
{!isCollapsed &&
day.meds.map((item) => {
const med = data.medications.find((m) => m.name === item.medName);
const med = data.medications.find((m) => getMedDisplayName(m) === item.medName);
const medCoverage = coverageByMed[item.medName];
const isEmpty = showStock && medCoverage ? medCoverage.medsLeft <= 0 : false;
const depletionTime = depletionByMed[item.medName];
@@ -825,10 +825,10 @@ export function SharedSchedule() {
<div className="med-name">
<div
className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, getMedDisplayName(med))}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med?.imageUrl) openLightbox(med.imageUrl, med.name);
if (med?.imageUrl) openLightbox(med.imageUrl, getMedDisplayName(med));
}
}}
>
@@ -984,7 +984,7 @@ export function SharedSchedule() {
</div>
{!isCollapsed &&
day.meds.map((item) => {
const med = data.medications.find((m) => m.name === item.medName);
const med = data.medications.find((m) => getMedDisplayName(m) === item.medName);
const medCoverage = coverageByMed[item.medName];
const isEmpty = showStock && medCoverage ? medCoverage.medsLeft <= 0 : false;
const depletionTime = depletionByMed[item.medName];
@@ -1008,10 +1008,10 @@ export function SharedSchedule() {
<div className="med-name">
<div
className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, getMedDisplayName(med))}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med?.imageUrl) openLightbox(med.imageUrl, med.name);
if (med?.imageUrl) openLightbox(med.imageUrl, getMedDisplayName(med));
}
}}
>
@@ -1161,7 +1161,7 @@ export function SharedSchedule() {
</div>
{!isCollapsed &&
day.meds.map((item) => {
const med = data.medications.find((m) => m.name === item.medName);
const med = data.medications.find((m) => getMedDisplayName(m) === item.medName);
const medCoverage = coverageByMed[item.medName];
const depletionTime = depletionByMed[item.medName];
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
@@ -1184,10 +1184,10 @@ export function SharedSchedule() {
<div className="med-name">
<div
className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, getMedDisplayName(med))}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med?.imageUrl) openLightbox(med.imageUrl, med.name);
if (med?.imageUrl) openLightbox(med.imageUrl, getMedDisplayName(med));
}
}}
>
+5 -5
View File
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import { MedicationAvatar } from "../components";
import { useEscapeKey } from "../hooks/useEscapeKey";
import type { Coverage, Medication, StockThresholds } from "../types";
import { getMedTotal, getPackageSize } from "../types";
import { getMedDisplayName, getMedTotal, getPackageSize } from "../types";
import { formatNumber } from "../utils";
import { getSystemLocale } from "../utils/formatters";
import { getStockStatus } from "../utils/schedule";
@@ -64,7 +64,7 @@ export function UserFilterModal({
<div className="user-meds-list">
{userMeds.map((med) => {
const medCoverage = coverage.all.find((c) => c.name === med.name);
const medCoverage = coverage.all.find((c) => c.name === getMedDisplayName(med));
// Fallback: if no coverage data (e.g. obsolete med), compute basic status from total pills
const status = medCoverage
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings)
@@ -97,10 +97,10 @@ export function UserFilterModal({
}
}}
>
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
<MedicationAvatar name={getMedDisplayName(med)} imageUrl={med.imageUrl} size="sm" />
<div className="user-med-info">
<span className="user-med-name">{med.name}</span>
{med.genericName && <span className="user-med-generic">{med.genericName}</span>}
<span className="user-med-name">{getMedDisplayName(med)}</span>
{med.name && med.genericName && <span className="user-med-generic">{med.genericName}</span>}
{personIntakes.length > 0 && (
<div className="user-med-intakes">
{personIntakes.map((intake) => {