Files
medassist-ng/frontend/src/pages/SchedulePage.tsx
T
Copilot 2a9ca39c24 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>
2026-02-25 21:29:25 +01:00

451 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* biome-ignore-all lint/style/noNestedTernary: schedule timeline branches are intentionally explicit */
import { Bell } from "lucide-react";
import { useTranslation } from "react-i18next";
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
function userStorageKey(userId: number | undefined, key: string): string {
return userId ? `user_${userId}_${key}` : key;
}
// Helper function to get stock status based on thresholds
function getStockStatus(
daysLeft: number | null,
medsLeft: number,
settings: { lowStockDays: number; normalStockDays: number; highStockDays: number; reminderDaysBefore: number }
) {
// Out of stock or completely depleted = danger (red)
if (medsLeft <= 0 || daysLeft === 0) return { className: "danger", label: "status.outOfStock" };
// No schedule, but has stock = normal
if (daysLeft === null) return { className: "success", label: "status.noSchedule" };
// Critical: at or below reminder threshold = danger (red)
if (daysLeft <= settings.reminderDaysBefore) return { className: "danger", label: "status.criticalStock" };
// Low: below low stock threshold = warning (yellow)
if (daysLeft < settings.lowStockDays) return { className: "warning", label: "status.lowStock" };
// High stock
if (daysLeft >= settings.highStockDays) return { className: "high", label: "status.highStock" };
// Normal stock
return { className: "success", label: "status.normal" };
}
// Helper function to get worst stock status for a day
function getDayStockStatus(
dayMeds: Array<{ medName: string }>,
coverageByMed: Record<string, Coverage>,
settings: { lowStockDays: number; normalStockDays: number; highStockDays: number; reminderDaysBefore: number }
): string {
let worstLevel = 3; // 3=success, 2=warning, 1=danger
for (const item of dayMeds) {
const cov = coverageByMed[item.medName];
if (!cov) continue;
const status = getStockStatus(cov.daysLeft, cov.medsLeft, settings);
if (status.className === "danger") worstLevel = Math.min(worstLevel, 1);
else if (status.className === "warning") worstLevel = Math.min(worstLevel, 2);
}
return worstLevel === 1 ? "danger" : worstLevel === 2 ? "warning" : "success";
}
// Helper to get dose ID (with or without person)
function getDoseId(baseId: string, person: string | null): string {
return person ? `${baseId}-${person}` : baseId;
}
export function SchedulePage() {
const { t } = useTranslation();
const { user } = useAuth();
const {
meds,
settings,
scheduleDays,
setScheduleDays,
showPastDays,
setShowPastDays,
pastDays,
futureDays,
takenDoses,
isDoseTakenAutomatically,
dismissedDoses,
markDoseTaken,
undoDoseTaken,
coverageByMed,
depletionByMed,
manuallyExpandedDays,
toggleDayCollapse,
openUserFilter,
missedPastDoseIds,
} = useAppContext();
return (
<section className="grid">
<article className="card schedule-full">
<div className="card-head">
<h2>{t("dashboard.schedules.title")}</h2>
<select
className="schedule-days-select"
value={scheduleDays}
onChange={(e) => {
const val = Number(e.target.value);
setScheduleDays(val);
if (user?.id) localStorage.setItem(userStorageKey(user.id, "scheduleDays"), String(val));
}}
>
<option value={30}>{t("dashboard.schedules.1month")}</option>
<option value={90}>{t("dashboard.schedules.3months")}</option>
<option value={180}>{t("dashboard.schedules.6months")}</option>
</select>
</div>
<div className="timeline">
{/* Past days (when expanded) — rendered above toggle */}
{showPastDays &&
pastDays.map((day) => {
// Get ALL dose IDs for this day (for total count and yellow styling)
const allDoseIds = day.meds.flatMap((item) =>
item.doses.flatMap((d) => {
const takenByArray = Array.isArray(d.takenBy) ? d.takenBy : [];
return takenByArray.length > 0 ? takenByArray.map((p) => `${d.id}-${p}`) : [d.id];
})
);
// Really taken = all doses marked as taken by human (for green "All taken")
const allReallyTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
// Count missed doses that are NOT dismissed (for warning icon)
const missedNotDismissedCount = day.meds.reduce((count, item) => {
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
const dismissedUntilDate = med?.dismissedUntil ?? undefined;
return (
count +
item.doses.reduce((doseCount, d) => {
if (isDoseDismissed(d.id, dismissedUntilDate)) return doseCount;
const takenByArray = Array.isArray(d.takenBy) ? d.takenBy : [];
const ids = takenByArray.length > 0 ? takenByArray.map((p) => `${d.id}-${p}`) : [d.id];
return doseCount + ids.filter((id) => !takenDoses.has(id) && !dismissedDoses.has(id)).length;
}, 0)
);
}, 0);
const hasRealMissed = missedNotDismissedCount > 0;
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded;
const _worstStatus = getDayStockStatus(day.meds, coverageByMed, settings);
return (
<div
key={day.dateStr}
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allReallyTaken ? "all-taken" : allDoseIds.length > 0 ? "past-missed" : ""}`}
>
<div
className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, true);
}}
title={isCollapsed ? t("common.expand") : t("common.collapse")}
>
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span>
<span className="day-summary">
{allReallyTaken ? (
<span className="day-complete"> {t("dashboard.schedules.allTaken")}</span>
) : (
<>
{hasRealMissed && (
<span
className="day-warning"
title={t("dashboard.schedules.missedDoses", { count: missedNotDismissedCount })}
>
</span>
)}
<span className="day-progress">
{takenCount}/{allDoseIds.length}
</span>
</>
)}
</span>
</div>
{!isCollapsed &&
day.meds.map((item) => {
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);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
<div className="time-main">
<div className="med-name">
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
<span className="med-name-text">{item.medName}</span>
</div>
<div className="tag-row">
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
</div>
</div>
<div className="doses-col">
{item.doses.map((dose) => {
// If no takenBy, show single checkbox; otherwise show one per person
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
return (
<div key={dose.id} className="dose-item past">
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
<span className="dose-usage-main">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
</span>
{med?.pillWeightMg && (
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
)}
</span>{" "}
{dose.intakeRemindersEnabled && (
<span
className="reminder-icon info-tooltip"
data-tooltip={t("tooltips.intakeReminders")}
>
<Bell size={14} aria-hidden="true" />
</span>
)}{" "}
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
{person && (
<span
className="person-name clickable"
onClick={() => openUserFilter(person)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") openUserFilter(person);
}}
>
{person}
</span>
)}
{isTaken ? (
<button
className="dose-btn undo"
onClick={() => undoDoseTaken(doseId)}
title={t("common.undo")}
>
{isAutomaticallyTaken && (
<span
className="info-tooltip"
data-tooltip={t("tooltips.automaticTaken")}
>
🤖
</span>
)}
</button>
) : (
<button
className="dose-btn take"
onClick={() => markDoseTaken(doseId)}
disabled={isEmpty}
title={t("dose.markAsTaken")}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true"></span>
</button>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
})}
{/* Past days toggle */}
{pastDays.length > 0 &&
(() => {
const missedCount = missedPastDoseIds.length;
return (
<div
className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedCount > 0 ? "has-missed" : ""}`}
onClick={() => {
const wasCollapsed = !showPastDays;
setShowPastDays(!showPastDays);
if (wasCollapsed) {
setTimeout(() => {
document
.querySelector(".day-block.today")
?.scrollIntoView({ behavior: "smooth", block: "center" });
}, 50);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
const wasCollapsed = !showPastDays;
setShowPastDays(!showPastDays);
if (wasCollapsed) {
setTimeout(() => {
document
.querySelector(".day-block.today")
?.scrollIntoView({ behavior: "smooth", block: "center" });
}, 50);
}
}
}}
>
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
<span className="past-days-label">
{showPastDays ? t("dashboard.schedules.hidePastDays") : t("dashboard.schedules.showPastDays")}
</span>
<span className="past-days-count">
({t("dashboard.schedules.pastDaysCount", { count: pastDays.length })})
</span>
{missedCount > 0 && (
<span
className="past-days-warning"
title={t("dashboard.schedules.missedDoses", { count: missedCount })}
>
{missedCount}
</span>
)}
</div>
);
})()}
{/* Current and future days */}
{futureDays.map((day) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const dayDate = new Date(day.date);
dayDate.setHours(0, 0, 0, 0);
const isToday = dayDate.getTime() === today.getTime();
return (
<div key={day.dateStr} className={`day-block ${isToday ? "today" : ""}`}>
<div className="day-divider">{day.dateStr}</div>
{day.meds.map((item) => {
const medCoverage = coverageByMed[item.medName];
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
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;
const status = willBeOutOfStock
? { className: "danger", label: "status.outOfStock" }
: medCoverage
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings)
: null;
const itemDoseIds = expandDoseIds(item.doses);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
<div className="time-main">
<div className="med-name">
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
<span className="med-name-text">{item.medName}</span>
</div>
<div className="tag-row">
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{status && <span className={`tag ${status.className}`}>{t(status.label)}</span>}
</div>
</div>
<div className="doses-col">
{item.doses.map((dose) => {
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
const now = Date.now();
const dayStart = new Date(day.date).setHours(0, 0, 0, 0);
const isPastDay = dayStart < new Date().setHours(0, 0, 0, 0);
return (
<div key={dose.id} className="dose-item">
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
<span className="dose-usage-main">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
</span>
{med?.pillWeightMg && (
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
)}
</span>
{dose.intakeRemindersEnabled && (
<span
className="reminder-icon info-tooltip"
data-tooltip={t("tooltips.intakeReminders")}
>
<Bell size={14} aria-hidden="true" />
</span>
)}
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= now;
const isOverdue = !isTaken && dose.when < now && !isPastDay;
return (
<div
key={doseId}
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}
>
{person && (
<span
className="person-name clickable"
onClick={() => openUserFilter(person)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") openUserFilter(person);
}}
>
{person}
</span>
)}
{isTaken ? (
<button
className="dose-btn undo"
onClick={() => undoDoseTaken(doseId)}
title={t("common.undo")}
>
{isAutomaticallyTaken && (
<span className="info-tooltip" data-tooltip={t("tooltips.automaticTaken")}>
🤖
</span>
)}
</button>
) : (
<button
className="dose-btn take"
onClick={() => markDoseTaken(doseId)}
disabled={isEmpty}
title={t("dose.markAsTaken")}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true"></span>
</button>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
})}
</div>
</article>
</section>
);
}