Files
medassist-ng/frontend/src/pages/SchedulePage.tsx
T
Daniel Volz 690cb2ff74 fix: correct dose ID generation for empty takenBy arrays (#105)
The takenBy field is a string[]. Empty arrays [] are truthy in JavaScript,
causing d.takenBy ? [...] patterns to generate dose IDs with trailing
hyphens (e.g., '5-0-173...-') instead of base IDs ('5-0-173...').

This mismatch between ID generation and computeMissedPastDoseIds (which
correctly uses .length > 0) caused doses to always appear as missed.

Changes:
- Add expandDoseIds() helper function using correct .length > 0 check
- Replace 8 buggy inline patterns in DashboardPage.tsx
- Refactor SchedulePage.tsx to use shared expandDoseIds()
- Add backend startup repair to strip trailing hyphens from existing IDs
- Add 12 new tests (6 frontend + 6 backend)
2026-02-07 00:08:58 +01:00

359 lines
14 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.
import { useTranslation } from "react-i18next";
import { MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
import { useAppContext } from "../context";
import type { Coverage } from "../types";
import { expandDoseIds } 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,
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 toggle */}
{pastDays.length > 0 &&
(() => {
// Use context's missedPastDoseIds which handles dismissed doses and previous schedule detection
const missedCount = missedPastDoseIds.length;
return (
<div
className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedCount > 0 ? "has-missed" : ""}`}
onClick={() => setShowPastDays(!showPastDays)}
>
<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>
);
})()}
{/* Past days (when expanded) */}
{showPastDays &&
pastDays.map((day) => {
const allDoseIds = day.meds.flatMap((item) => expandDoseIds(item.doses));
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
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" : ""} ${allDayTaken ? "all-taken" : ""} stock-${worstStatus}`}
>
<div
className="day-divider clickable"
onClick={() => 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">
{allDayTaken ? (
<span className="day-complete"> {t("dashboard.schedules.allTaken")}</span>
) : (
<>
<span
className="day-warning"
title={t("dashboard.schedules.missedDoses", { count: allDoseIds.length - takenCount })}
>
</span>
<span className="day-progress">
{takenCount}/{allDoseIds.length}
</span>
</>
)}
</span>
</div>
{!isCollapsed &&
day.meds.map((item) => {
const med = meds.find((m) => m.name === 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>
{med?.intakeRemindersEnabled && (
<span
className="reminder-icon info-tooltip"
data-tooltip={t("tooltips.intakeReminders")}
>
🔔
</span>
)}
</div>
<div className="tag-row">
<span className="tag subtle">
{item.total} {t("common.pills")} {t("common.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">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
</span>
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
{person && (
<span
className="person-name clickable"
onClick={() => openUserFilter(person)}
>
{person}
</span>
)}
{isTaken ? (
<button
className="dose-btn undo"
onClick={() => undoDoseTaken(doseId)}
title={t("common.undo")}
>
</button>
) : (
<button
className="dose-btn take"
onClick={() => markDoseTaken(doseId)}
disabled={isEmpty}
title={t("dose.markAsTaken")}
>
</button>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
})}
</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) => m.name === 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>
{med?.intakeRemindersEnabled && (
<span className="reminder-icon info-tooltip" data-tooltip={t("tooltips.intakeReminders")}>
🔔
</span>
)}
</div>
<div className="tag-row">
<span className="tag subtle">
{item.total} {t("common.pills")} {t("common.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">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
</span>
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
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)}>
{person}
</span>
)}
{isTaken ? (
<button
className="dose-btn undo"
onClick={() => undoDoseTaken(doseId)}
title={t("common.undo")}
>
</button>
) : (
<button
className="dose-btn take"
onClick={() => markDoseTaken(doseId)}
disabled={isEmpty}
title={t("dose.markAsTaken")}
>
</button>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
})}
</div>
</article>
</section>
);
}