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:
@@ -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" ? (
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user