fix: restore schedule interaction correctness

* fix: restore schedule interaction correctness

* fix: use scheduled stock timing for historical doses
This commit is contained in:
Daniel Volz
2026-03-14 20:49:13 +01:00
committed by GitHub
parent 816888a697
commit 0160ef3ddf
15 changed files with 888 additions and 286 deletions
+29 -62
View File
@@ -3,7 +3,14 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
import { useTranslation } from "react-i18next";
import { useAuth } from "../components/Auth";
import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, useShare } from "../hooks";
import type { Coverage, FormState, Medication, ScheduleEvent, StockThresholds } from "../types";
import {
type Coverage,
type FormState,
getMedDisplayName,
type Medication,
type ScheduleEvent,
type StockThresholds,
} from "../types";
import { getSystemLocale } from "../utils/formatters";
import { log } from "../utils/logger";
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds, getStockStatus } from "../utils/schedule";
@@ -70,15 +77,11 @@ export interface AppContextValue {
takenDoses: Set<string>;
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
dismissedDoses: Set<string>;
clearingMissed: boolean;
showClearMissedConfirm: boolean;
setShowClearMissedConfirm: (show: boolean) => void;
getDoseId: (baseDoseId: string, person: string | null) => string;
isDoseTakenAutomatically: (doseId: string) => boolean;
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
markDoseTaken: (doseId: string) => Promise<void>;
undoDoseTaken: (doseId: string) => Promise<void>;
dismissMissedDoses: (doseIds: string[]) => Promise<void>;
// From useCollapsedDays
manuallyCollapsedDays: Set<string>;
@@ -393,6 +396,25 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
const coverageByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c])), [coverage.all]);
const outOfStockMedicationIds = useMemo(
() =>
new Set(
activeMeds.filter((med) => (coverageByMed[getMedDisplayName(med)]?.medsLeft ?? 1) <= 0).map((med) => med.id)
),
[activeMeds, coverageByMed]
);
const effectiveTakenDoses = useMemo(
() =>
new Set(
Array.from(doses.takenDoses).filter((doseId) => {
const medId = Number.parseInt(doseId.split("-")[0] ?? "", 10);
return Number.isNaN(medId) || !outOfStockMedicationIds.has(medId);
})
),
[doses.takenDoses, outOfStockMedicationIds]
);
// Centralized stock thresholds for consistent status display across all components
const stockThresholds: StockThresholds = useMemo(
() => ({
@@ -516,8 +538,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
}, [groupedSchedule, scheduleDays]);
const missedPastDoseIds = useMemo(
() => computeMissedPastDoseIds(pastDays, activeMeds, doses.takenDoses, doses.dismissedDoses),
[pastDays, activeMeds, doses.takenDoses, doses.dismissedDoses]
() => computeMissedPastDoseIds(pastDays, activeMeds, effectiveTakenDoses, doses.dismissedDoses),
[pastDays, activeMeds, effectiveTakenDoses, doses.dismissedDoses]
);
// Modal helpers with browser history support
@@ -777,55 +799,6 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
);
}, [settingsHook.settings, settingsHook.savedSettings]);
// New dismissMissedDoses that uses medication-level dismissedUntil dates
// This is robust against timestamp changes from schedule updates or timezone fixes
const [clearingMissedState, setClearingMissedState] = useState(false);
const dismissMissedDoses = useCallback(
async (doseIds: string[]) => {
if (doseIds.length === 0) return;
// Extract unique medication IDs from dose IDs (format: medId-blisterIdx-timestamp[-person])
const medIds = new Set<number>();
for (const doseId of doseIds) {
const parts = doseId.split("-");
if (parts.length >= 1) {
const medId = parseInt(parts[0], 10);
if (!Number.isNaN(medId)) {
medIds.add(medId);
}
}
}
if (medIds.size === 0) return;
// Get today's date in YYYY-MM-DD format
const today = new Date();
const until = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
setClearingMissedState(true);
try {
const res = await fetch("/api/medications/dismiss-until", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ medicationIds: Array.from(medIds), until }),
});
if (res.ok) {
// Reload medications to get updated dismissedUntil values
await medications.loadMeds();
doses.setShowClearMissedConfirm(false);
}
} catch {
// Error - dialog stays open
} finally {
setClearingMissedState(false);
}
},
[medications, doses]
);
// Build context value
const value: AppContextValue = useMemo(
() => ({
@@ -853,15 +826,11 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
takenDoses: doses.takenDoses,
setTakenDoses: doses.setTakenDoses,
dismissedDoses: doses.dismissedDoses,
clearingMissed: clearingMissedState,
showClearMissedConfirm: doses.showClearMissedConfirm,
setShowClearMissedConfirm: doses.setShowClearMissedConfirm,
getDoseId: doses.getDoseId,
isDoseTakenAutomatically: doses.isDoseTakenAutomatically,
countTakenDoses: doses.countTakenDoses,
markDoseTaken: doses.markDoseTaken,
undoDoseTaken: doses.undoDoseTaken,
dismissMissedDoses,
// From useCollapsedDays
manuallyCollapsedDays: collapsed.manuallyCollapsedDays,
@@ -1020,8 +989,6 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
handleImportFileSelect,
handleImportConfirm,
settingsChanged,
clearingMissedState,
dismissMissedDoses,
]
);
+19 -8
View File
@@ -3,6 +3,7 @@
// =============================================================================
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
export interface UseDosesReturn {
takenDoses: Set<string>;
@@ -10,8 +11,6 @@ export interface UseDosesReturn {
takenDoseTimestamps: Map<string, number>;
takenDoseSources: Map<string, "manual" | "automatic">;
dismissedDoses: Set<string>;
showClearMissedConfirm: boolean;
setShowClearMissedConfirm: (show: boolean) => void;
clearDosesState: () => void;
getDoseId: (baseDoseId: string, person: string | null) => string;
isDoseTakenAutomatically: (doseId: string) => boolean;
@@ -22,11 +21,11 @@ export interface UseDosesReturn {
}
export function useDoses(): UseDosesReturn {
const { t } = useTranslation();
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
const [takenDoseTimestamps, setTakenDoseTimestamps] = useState<Map<string, number>>(new Map());
const [takenDoseSources, setTakenDoseSources] = useState<Map<string, "manual" | "automatic">>(new Map());
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
// Track in-flight mutations to prevent polling from overwriting optimistic updates
const mutationInFlightRef = useRef(0);
@@ -36,7 +35,6 @@ export function useDoses(): UseDosesReturn {
setTakenDoseTimestamps(new Map());
setTakenDoseSources(new Map());
setDismissedDoses(new Set());
setShowClearMissedConfirm(false);
mutationInFlightRef.current = 0;
}, []);
@@ -118,6 +116,15 @@ export function useDoses(): UseDosesReturn {
[takenDoses, getDoseId]
);
const getErrorCode = useCallback(async (response: Response): Promise<string | null> => {
try {
const data = (await response.json()) as { code?: string };
return typeof data.code === "string" ? data.code : null;
} catch {
return null;
}
}, []);
const markDoseTaken = useCallback(
async (doseId: string) => {
// Optimistic update
@@ -140,12 +147,18 @@ export function useDoses(): UseDosesReturn {
// Send to server
try {
await fetch("/api/doses/taken", {
const response = await fetch("/api/doses/taken", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ doseId }),
});
if (!response.ok) {
if ((await getErrorCode(response)) === "OUT_OF_STOCK") {
alert(t("common.outOfStockTakeBlocked"));
}
throw new Error("Failed to mark dose as taken");
}
} catch {
// Revert on error
setTakenDoses((prev) => {
@@ -169,7 +182,7 @@ export function useDoses(): UseDosesReturn {
loadTakenDoses();
}
},
[loadTakenDoses]
[getErrorCode, loadTakenDoses, t]
);
const undoDoseTaken = useCallback(
@@ -220,8 +233,6 @@ export function useDoses(): UseDosesReturn {
takenDoseTimestamps,
takenDoseSources,
dismissedDoses,
showClearMissedConfirm,
setShowClearMissedConfirm,
clearDosesState,
getDoseId,
isDoseTakenAutomatically,
+131 -65
View File
@@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next";
import { ConfirmModal, MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
import { useAppContext } from "../context";
import { useModalHistory } from "../hooks";
import {
allowsPillFormSelection,
type Coverage,
@@ -76,19 +75,28 @@ export function DashboardPage() {
getDayStockStatus,
getDoseId,
isDoseTakenAutomatically,
showClearMissedConfirm,
setShowClearMissedConfirm,
clearingMissed,
dismissMissedDoses,
openMedDetail,
openUserFilter,
openShareDialog,
openScheduleLightbox,
stockThresholds,
loadMeds,
loadSettings,
} = useAppContext();
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
const [clearingMissed, setClearingMissed] = useState(false);
useModalHistory(showClearMissedConfirm, "clearMissed", () => setShowClearMissedConfirm(false));
const outOfStockMedicationIds = new Set(
meds.filter((med) => (coverageByMed[getMedDisplayName(med)]?.medsLeft ?? 1) <= 0).map((med) => med.id)
);
const isDoseTakenForDisplay = (doseId: string) => {
const medId = Number.parseInt(doseId.split("-")[0] ?? "", 10);
if (!Number.isNaN(medId) && outOfStockMedicationIds.has(medId)) {
return false;
}
return takenDoses.has(doseId);
};
// Get structured reminder data
const reminderData = getReminderStatusData(
@@ -141,6 +149,67 @@ export function DashboardPage() {
const prescriptionEmptyCount = prescriptionLowMeds.filter((med) => med.remainingRefills <= 0).length;
const getClearMissedPayload = () => {
const medicationIds = new Set<number>();
let latestMissedDate: string | null = null;
for (const day of pastDays) {
for (const item of day.meds) {
const med = meds.find((candidate) => getMedDisplayName(candidate) === item.medName);
if (!med) continue;
const dismissedUntilDate = med.dismissedUntil ?? undefined;
const hasMissedDose = item.doses.some((dose) => {
if (isDoseDismissed(dose.id, dismissedUntilDate)) return false;
const takenByArray = Array.isArray(dose.takenBy) ? dose.takenBy : [];
const ids = takenByArray.length > 0 ? takenByArray.map((person) => `${dose.id}-${person}`) : [dose.id];
return ids.some((doseId) => !isDoseTakenForDisplay(doseId) && !dismissedDoses.has(doseId));
});
if (!hasMissedDose) continue;
medicationIds.add(med.id);
const dayDate = day.date.toISOString().slice(0, 10);
if (!latestMissedDate || dayDate > latestMissedDate) {
latestMissedDate = dayDate;
}
}
}
return {
medicationIds: [...medicationIds],
until: latestMissedDate,
};
};
const clearMissedDoses = async (missedCount: number) => {
const payload = getClearMissedPayload();
if (payload.medicationIds.length === 0 || !payload.until) {
setShowClearMissedConfirm(false);
return;
}
setClearingMissed(true);
try {
const res = await fetch("/api/medications/dismiss-until", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
await loadMeds();
setShowClearMissedConfirm(false);
alert(t("dashboard.schedules.clearMissedSuccess", { count: missedCount }));
} catch {
alert(t("common.saveFailed"));
} finally {
setClearingMissed(false);
}
};
const getTubeUnitLabel = (med: (typeof meds)[number] | undefined, value: number) =>
isLiquidContainerPackageType(med?.packageType) || med?.medicationForm === "liquid"
? t("form.packageAmountUnitMl")
@@ -895,8 +964,8 @@ export function DashboardPage() {
);
// 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;
const allReallyTaken = allDoseIds.length > 0 && allDoseIds.every((id) => isDoseTakenForDisplay(id));
const takenCount = allDoseIds.filter((id) => isDoseTakenForDisplay(id)).length;
// Count missed doses that are NOT dismissed (for warning icon)
const missedNotDismissedCount = day.meds.reduce((count, item) => {
@@ -908,7 +977,9 @@ export function DashboardPage() {
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;
return (
doseCount + ids.filter((id) => !isDoseTakenForDisplay(id) && !dismissedDoses.has(id)).length
);
}, 0)
);
}, 0);
@@ -963,13 +1034,8 @@ export function DashboardPage() {
? getStockStatus(medCov.daysLeft, medCov.medsLeft, stockThresholds, med?.packageType)
: null;
const status = getVisibleStockStatus(med, rawStatus);
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 key={`${day.dateStr}-${item.medName}`} className="time-row">
<div className="time-main">
<div className="med-name">
<div
@@ -1013,8 +1079,11 @@ export function DashboardPage() {
{item.doses.map((dose) => {
// If no takenBy, show single checkbox; otherwise show one per person
const people = dose.takenBy.length > 0 ? dose.takenBy : [null];
const allTaken = people.every((person) =>
isDoseTakenForDisplay(getDoseId(dose.id, person))
);
return (
<div key={dose.id} className="dose-item past">
<div key={dose.id} className={`dose-item past ${allTaken ? "all-taken" : ""}`}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
<span className="dose-usage-main">
@@ -1035,7 +1104,7 @@ export function DashboardPage() {
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
const isTaken = isDoseTakenForDisplay(doseId);
const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
return (
@@ -1070,13 +1139,17 @@ export function DashboardPage() {
</button>
) : (
<button
className="dose-btn take"
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
onClick={() => markDoseTaken(doseId)}
title={t("dose.markAsTaken")}
title={
isEmpty
? t("common.outOfStockTakeBlocked")
: t("dose.markAsTaken")
}
disabled={isEmpty}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true"></span>
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
</button>
)}
</div>
@@ -1154,11 +1227,7 @@ export function DashboardPage() {
<button
type="button"
className="clear-missed-btn"
onClick={(e) => {
e.stopPropagation();
setShowClearMissedConfirm(true);
}}
title={t("dashboard.schedules.clearMissed")}
onClick={() => setShowClearMissedConfirm(true)}
>
{t("dashboard.schedules.clearMissed")}
</button>
@@ -1166,13 +1235,29 @@ export function DashboardPage() {
</div>
);
})()}
{showClearMissedConfirm && (
<ConfirmModal
title={t("dashboard.schedules.clearMissedConfirmTitle")}
message={t("dashboard.schedules.clearMissedConfirmMessage", {
count: missedPastDoseIds.length,
})}
confirmLabel={t("dashboard.schedules.clearMissedConfirm")}
cancelLabel={t("dashboard.schedules.clearMissedCancel")}
onConfirm={() => void clearMissedDoses(missedPastDoseIds.length)}
onCancel={() => {
if (!clearingMissed) setShowClearMissedConfirm(false);
}}
isLoading={clearingMissed}
confirmVariant="warning"
/>
)}
{/* Today - always visible */}
{todayDay &&
(() => {
const day = todayDay;
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 allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => isDoseTakenForDisplay(id));
const takenCount = allDoseIds.filter((id) => isDoseTakenForDisplay(id)).length;
const dayStockStatuses = day.meds.map((item) => {
const medCoverage = coverageByMed[item.medName];
@@ -1244,13 +1329,8 @@ export function DashboardPage() {
)
: null;
const visibleStatus = getVisibleStockStatus(med, status);
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 key={`${day.dateStr}-${item.medName}`} className="time-row">
<div className="time-main">
<div className="med-name">
<div
@@ -1297,7 +1377,7 @@ export function DashboardPage() {
const isOverdue = dose.when < Date.now();
const people = dose.takenBy.length > 0 ? dose.takenBy : [null];
const allTaken = people.every((person) =>
takenDoses.has(getDoseId(dose.id, person))
isDoseTakenForDisplay(getDoseId(dose.id, person))
);
return (
<div
@@ -1324,7 +1404,7 @@ export function DashboardPage() {
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
const isTaken = isDoseTakenForDisplay(doseId);
const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
return (
@@ -1359,13 +1439,17 @@ export function DashboardPage() {
</button>
) : (
<button
className="dose-btn take"
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
onClick={() => markDoseTaken(doseId)}
title={t("dose.markAsTaken")}
title={
isEmpty
? t("common.outOfStockTakeBlocked")
: t("dose.markAsTaken")
}
disabled={isEmpty}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true"></span>
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
</button>
)}
</div>
@@ -1393,7 +1477,7 @@ export function DashboardPage() {
)
)
);
const takenFutureDoses = totalFutureDoses.filter((id) => takenDoses.has(id)).length;
const takenFutureDoses = totalFutureDoses.filter((id) => isDoseTakenForDisplay(id)).length;
return (
<div className="future-days-header">
<div
@@ -1426,8 +1510,8 @@ export function DashboardPage() {
showFutureDays &&
futureDays.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 allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => isDoseTakenForDisplay(id));
const takenCount = allDoseIds.filter((id) => isDoseTakenForDisplay(id)).length;
const dayStockStatuses = day.meds.map((item) => {
const medCoverage = coverageByMed[item.medName];
@@ -1498,13 +1582,8 @@ export function DashboardPage() {
)
: null;
const visibleStatus = getVisibleStockStatus(med, status);
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 key={`${day.dateStr}-${item.medName}`} className="time-row">
<div className="time-main">
<div className="med-name">
<div
@@ -1550,7 +1629,7 @@ export function DashboardPage() {
{item.doses.map((dose) => {
const people = dose.takenBy.length > 0 ? dose.takenBy : [null];
const allTaken = people.every((person) =>
takenDoses.has(getDoseId(dose.id, person))
isDoseTakenForDisplay(getDoseId(dose.id, person))
);
return (
<div key={dose.id} className={`dose-item future ${allTaken ? "all-taken" : ""}`}>
@@ -1574,7 +1653,7 @@ export function DashboardPage() {
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
const isTaken = isDoseTakenForDisplay(doseId);
const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
return (
@@ -1609,13 +1688,13 @@ export function DashboardPage() {
</button>
) : (
<button
className="dose-btn take"
className={`dose-btn take out-of-stock`}
onClick={() => markDoseTaken(doseId)}
title={t("dose.markAsTaken")}
title={t("common.outOfStockTakeBlocked")}
disabled={true}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</button>
)}
</div>
@@ -1637,19 +1716,6 @@ export function DashboardPage() {
</article>
</section>
</div>
{/* Clear Missed Doses Confirmation Modal */}
{showClearMissedConfirm && (
<ConfirmModal
title={t("dashboard.schedules.clearMissedConfirmTitle")}
message={t("dashboard.schedules.clearMissedConfirmMessage", { count: missedPastDoseIds.length })}
confirmLabel={clearingMissed ? t("common.loading") : t("dashboard.schedules.clearMissedConfirm")}
cancelLabel={t("dashboard.schedules.clearMissedCancel")}
onConfirm={() => dismissMissedDoses(missedPastDoseIds)}
onCancel={() => setShowClearMissedConfirm(false)}
isLoading={clearingMissed}
/>
)}
</>
);
}
+154 -52
View File
@@ -1,13 +1,14 @@
/* biome-ignore-all lint/style/noNestedTernary: schedule timeline branches are intentionally explicit */
import { Bell } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { MedicationAvatar } from "../components";
import { ConfirmModal, MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
import { useAppContext } from "../context";
import type { Coverage } from "../types";
import { getMedDisplayName, isLiquidContainerPackageType, isTubePackageType } from "../types";
import { formatNumber } from "../utils/formatters";
import { expandDoseIds, isDoseDismissed } from "../utils/schedule";
import { isDoseDismissed } from "../utils/schedule";
// Helper for user-specific localStorage keys
function userStorageKey(userId: number | undefined, key: string): string {
@@ -90,13 +91,89 @@ export function SchedulePage() {
toggleDayCollapse,
openUserFilter,
missedPastDoseIds,
loadMeds,
} = useAppContext();
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
const [clearingMissed, setClearingMissed] = useState(false);
const outOfStockMedicationIds = new Set(
meds.filter((med) => (coverageByMed[getMedDisplayName(med)]?.medsLeft ?? 1) <= 0).map((med) => med.id)
);
const isDoseTakenForDisplay = (doseId: string) => {
const medId = Number.parseInt(doseId.split("-")[0] ?? "", 10);
if (!Number.isNaN(medId) && outOfStockMedicationIds.has(medId)) {
return false;
}
return takenDoses.has(doseId);
};
const shouldHideNoScheduleStatusForTube = (
med: (typeof meds)[number] | undefined,
status: { className: string; label: string } | null
) => isTubePackageType(med?.packageType) && status?.label === "status.noSchedule";
const getClearMissedPayload = () => {
const medicationIds = new Set<number>();
let latestMissedDate: string | null = null;
for (const day of pastDays) {
for (const item of day.meds) {
const med = meds.find((candidate) => getMedDisplayName(candidate) === item.medName);
if (!med) continue;
const dismissedUntilDate = med.dismissedUntil ?? undefined;
const hasMissedDose = item.doses.some((dose) => {
if (isDoseDismissed(dose.id, dismissedUntilDate)) return false;
const takenByArray = Array.isArray(dose.takenBy) ? dose.takenBy : [];
const ids = takenByArray.length > 0 ? takenByArray.map((person) => `${dose.id}-${person}`) : [dose.id];
return ids.some((doseId) => !isDoseTakenForDisplay(doseId) && !dismissedDoses.has(doseId));
});
if (!hasMissedDose) continue;
medicationIds.add(med.id);
const dayDate = day.date.toISOString().slice(0, 10);
if (!latestMissedDate || dayDate > latestMissedDate) {
latestMissedDate = dayDate;
}
}
}
return {
medicationIds: [...medicationIds],
until: latestMissedDate,
};
};
const clearMissedDoses = async (missedCount: number) => {
const payload = getClearMissedPayload();
if (payload.medicationIds.length === 0 || !payload.until) {
setShowClearMissedConfirm(false);
return;
}
setClearingMissed(true);
try {
const res = await fetch("/api/medications/dismiss-until", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
await loadMeds();
setShowClearMissedConfirm(false);
alert(t("dashboard.schedules.clearMissedSuccess", { count: missedCount }));
} catch {
alert(t("common.saveFailed"));
} finally {
setClearingMissed(false);
}
};
const getTubeUnitLabel = (med: (typeof meds)[number] | undefined, value: number) =>
isLiquidContainerPackageType(med?.packageType) || med?.medicationForm === "liquid"
? t("form.packageAmountUnitMl")
@@ -205,8 +282,8 @@ export function SchedulePage() {
);
// 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;
const allReallyTaken = allDoseIds.length > 0 && allDoseIds.every((id) => isDoseTakenForDisplay(id));
const takenCount = allDoseIds.filter((id) => isDoseTakenForDisplay(id)).length;
// Count missed doses that are NOT dismissed (for warning icon)
const missedNotDismissedCount = day.meds.reduce((count, item) => {
@@ -218,7 +295,7 @@ export function SchedulePage() {
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;
return doseCount + ids.filter((id) => !isDoseTakenForDisplay(id) && !dismissedDoses.has(id)).length;
}, 0)
);
}, 0);
@@ -268,10 +345,8 @@ export function SchedulePage() {
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 key={`${day.dateStr}-${item.medName}`} className="time-row">
<div className="time-main">
<div className="med-name">
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
@@ -285,8 +360,11 @@ export function SchedulePage() {
{item.doses.map((dose) => {
// If no takenBy, show single checkbox; otherwise show one per person
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
const allTaken = people.every((person) =>
isDoseTakenForDisplay(getDoseId(dose.id, person))
);
return (
<div key={dose.id} className="dose-item past">
<div key={dose.id} className={`dose-item past ${allTaken ? "all-taken" : ""}`}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
<span className="dose-usage-main">
@@ -307,7 +385,7 @@ export function SchedulePage() {
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
const isTaken = isDoseTakenForDisplay(doseId);
const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
return (
@@ -341,13 +419,15 @@ export function SchedulePage() {
</button>
) : (
<button
className="dose-btn take"
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
onClick={() => markDoseTaken(doseId)}
disabled={isEmpty}
title={t("dose.markAsTaken")}
title={
isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")
}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true"></span>
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
</button>
)}
</div>
@@ -369,21 +449,10 @@ export function SchedulePage() {
(() => {
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 === " ") {
<div className="past-days-header">
<div
className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedCount > 0 ? "has-missed" : ""}`}
onClick={() => {
const wasCollapsed = !showPastDays;
setShowPastDays(!showPastDays);
if (wasCollapsed) {
@@ -393,27 +462,61 @@ export function SchedulePage() {
?.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}
}}
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>
{missedCount > 0 && (
<button type="button" className="clear-missed-btn" onClick={() => setShowClearMissedConfirm(true)}>
{t("dashboard.schedules.clearMissed")}
</button>
)}
</div>
);
})()}
{showClearMissedConfirm && (
<ConfirmModal
title={t("dashboard.schedules.clearMissedConfirmTitle")}
message={t("dashboard.schedules.clearMissedConfirmMessage", {
count: missedPastDoseIds.length,
})}
confirmLabel={t("dashboard.schedules.clearMissedConfirm")}
cancelLabel={t("dashboard.schedules.clearMissedCancel")}
onConfirm={() => void clearMissedDoses(missedPastDoseIds.length)}
onCancel={() => {
if (!clearingMissed) setShowClearMissedConfirm(false);
}}
isLoading={clearingMissed}
confirmVariant="warning"
/>
)}
{/* Current and future days */}
{futureDays.map((day) => {
const today = new Date();
@@ -437,10 +540,8 @@ export function SchedulePage() {
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings, med?.packageType)
: null;
const visibleStatus = shouldHideNoScheduleStatusForTube(med, status) ? null : status;
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 key={`${day.dateStr}-${item.medName}`} className="time-row">
<div className="time-main">
<div className="med-name">
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
@@ -459,8 +560,9 @@ export function SchedulePage() {
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);
const allTaken = people.every((person) => isDoseTakenForDisplay(getDoseId(dose.id, person)));
return (
<div key={dose.id} className="dose-item">
<div key={dose.id} className={`dose-item ${allTaken ? "all-taken" : ""}`}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
<span className="dose-usage-main">
@@ -481,7 +583,7 @@ export function SchedulePage() {
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
const isTaken = isDoseTakenForDisplay(doseId);
const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= now;
const isOverdue = !isTaken && dose.when < now && !isPastDay;
@@ -516,13 +618,13 @@ export function SchedulePage() {
</button>
) : (
<button
className="dose-btn take"
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
onClick={() => markDoseTaken(doseId)}
disabled={isEmpty}
title={t("dose.markAsTaken")}
title={isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true"></span>
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
</button>
)}
</div>
@@ -196,8 +196,6 @@ describe("useAppContext", () => {
setTakenDoses: vi.fn(),
takenDoseTimestamps: new Map<string, number>(),
dismissedDoses: new Set<string>(),
showClearMissedConfirm: true,
setShowClearMissedConfirm: vi.fn(),
clearDosesState: vi.fn(),
getDoseId: vi.fn((base: string, person: string | null) => (person ? `${base}-${person}` : base)),
isDoseTakenAutomatically: vi.fn(() => false),
@@ -376,38 +374,6 @@ describe("useAppContext", () => {
expect(window.history.back).toHaveBeenCalled();
});
it("dismisses missed doses and posts unique medication IDs", async () => {
const { result } = renderHook(() => useAppContext(), { wrapper });
await act(async () => {
await result.current.dismissMissedDoses(["11-0-1730000000000", "11-2-1730000100000", "12-0-1730000200000"]);
});
expect(fetch).toHaveBeenCalledWith(
"/api/medications/dismiss-until",
expect.objectContaining({
method: "POST",
credentials: "include",
})
);
const body = JSON.parse((fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body as string);
expect(body.medicationIds).toEqual([11, 12]);
expect(mockUseMedications().loadMeds).toHaveBeenCalled();
expect(mockUseDoses().setShowClearMissedConfirm).toHaveBeenCalledWith(false);
});
it("does not dismiss missed doses for empty/invalid IDs", async () => {
const { result } = renderHook(() => useAppContext(), { wrapper });
await act(async () => {
await result.current.dismissMissedDoses([]);
await result.current.dismissMissedDoses(["invalid-dose-id"]);
});
expect(fetch).not.toHaveBeenCalledWith("/api/medications/dismiss-until", expect.anything());
});
it("imports data and triggers reload plus import result state", async () => {
const { result } = renderHook(() => useAppContext(), { wrapper });
@@ -583,15 +549,4 @@ describe("useAppContext", () => {
expect(mockAlert).toHaveBeenCalledWith("exportImport.importError: Import failed");
});
it("keeps clear-missed confirm open when dismiss request fails", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("network"));
const { result } = renderHook(() => useAppContext(), { wrapper });
await act(async () => {
await result.current.dismissMissedDoses(["11-0-1730000000000"]);
});
expect(mockUseDoses().setShowClearMissedConfirm).not.toHaveBeenCalledWith(false);
});
});
+22 -5
View File
@@ -20,7 +20,6 @@ describe("useDoses", () => {
expect(result.current.takenDoses.size).toBe(0);
expect(result.current.dismissedDoses.size).toBe(0);
expect(result.current.showClearMissedConfirm).toBe(false);
});
it("loads taken doses from API on mount", async () => {
@@ -273,14 +272,32 @@ describe("useDoses", () => {
});
});
it("setShowClearMissedConfirm works", () => {
it("shows an out-of-stock alert and reverts the optimistic mark", async () => {
const alertMock = vi.fn();
global.alert = alertMock;
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) })
.mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ code: "OUT_OF_STOCK" }),
})
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) });
const { result } = renderHook(() => useDoses());
act(() => {
result.current.setShowClearMissedConfirm(true);
await waitFor(() => {
expect(result.current.takenDoses.size).toBe(0);
});
expect(result.current.showClearMissedConfirm).toBe(true);
await act(async () => {
await result.current.markDoseTaken("blocked-dose");
});
await waitFor(() => {
expect(result.current.takenDoses.has("blocked-dose")).toBe(false);
});
expect(alertMock).toHaveBeenCalledWith("common.outOfStockTakeBlocked");
});
it("undoDoseTaken encodes special characters in dose ID", async () => {
+29 -42
View File
@@ -182,10 +182,7 @@ const createMockAppContext = (overrides = {}) => ({
getDayStockStatus: vi.fn(() => "success"),
getDoseId: vi.fn((id, person) => (person ? `${id}-${person}` : id)),
isDoseTakenAutomatically: vi.fn(() => false),
showClearMissedConfirm: false,
setShowClearMissedConfirm: vi.fn(),
clearingMissed: false,
dismissMissedDoses: vi.fn(),
loadMeds: vi.fn(),
loadSettings: vi.fn(),
...overrides,
});
@@ -977,26 +974,18 @@ describe("DashboardPage with past days", () => {
}
});
it("shows clear missed doses button when there are missed doses", () => {
render(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>
);
it("posts the computed dismiss-until payload when clearing missed doses", async () => {
const loadMeds = vi.fn();
const alertMock = vi.fn();
global.alert = alertMock;
global.fetch = vi.fn().mockResolvedValue({ ok: true });
// Should show clear missed button
const clearBtn = document.querySelector(".clear-missed-btn");
expect(clearBtn).toBeInTheDocument();
});
it("opens clear missed confirmation modal and confirms action", () => {
const dismissMissedDoses = vi.fn();
mockContextValue = createMockAppContext({
meds: mockMeds,
coverageByMed: { Aspirin: { medsLeft: 25, daysLeft: 25 } },
pastDays: mockPastDays,
showPastDays: false,
missedPastDoseIds: ["1-0-1-John", "1-0-2-John"],
showClearMissedConfirm: true,
dismissMissedDoses,
missedPastDoseIds: [`${mockPastDays[0].meds[0].doses[0].id}-John`],
loadMeds,
});
render(
@@ -1005,9 +994,26 @@ describe("DashboardPage with past days", () => {
</MemoryRouter>
);
expect(screen.getByText(/dashboard\.schedules\.clearMissedConfirmTitle/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /dashboard\.schedules\.clearMissed/i }));
fireEvent.click(screen.getByRole("button", { name: /dashboard\.schedules\.clearMissedConfirm/i }));
expect(dismissMissedDoses).toHaveBeenCalledWith(["1-0-1-John", "1-0-2-John"]);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/medications/dismiss-until",
expect.objectContaining({
method: "POST",
credentials: "include",
})
);
});
const body = JSON.parse(((global.fetch as ReturnType<typeof vi.fn>).mock.calls[0]?.[1]?.body as string) ?? "{}");
expect(body).toEqual({
medicationIds: [1],
until: mockPastDays[0].date.toISOString().slice(0, 10),
});
expect(loadMeds).toHaveBeenCalled();
expect(alertMock).toHaveBeenCalledWith(expect.stringContaining("dashboard.schedules.clearMissedSuccess"));
});
});
@@ -1169,25 +1175,6 @@ describe("DashboardPage additional branches", () => {
expect(openScheduleLightbox).toHaveBeenCalledWith("/api/images/aspirin.png");
});
it("clicking clear missed button opens confirmation", () => {
const setShowClearMissedConfirm = vi.fn();
mockContextValue = createMockAppContext({
pastDays: mockPastDays,
missedPastDoseIds: ["1-0-1-John"],
setShowClearMissedConfirm,
});
render(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>
);
const clearBtn = document.querySelector(".clear-missed-btn") as HTMLButtonElement;
fireEvent.click(clearBtn);
expect(setShowClearMissedConfirm).toHaveBeenCalledWith(true);
});
it("renders and interacts with today day schedule block", () => {
const markDoseTaken = vi.fn();
const undoDoseTaken = vi.fn();
+9
View File
@@ -1636,6 +1636,15 @@ describe("computeMissedPastDoseIds", () => {
expect(result).toEqual([`1-0-${march13}`, `1-0-${march14}`]);
});
it("matches medication dismissedUntil via display name when the schedule row uses genericName", () => {
const march10 = new Date("2024-03-10T00:00:00").getTime();
const pastDays = [makePastDay("Acetylsalicylic Acid", [{ id: `1-0-${march10}` }])];
const meds = [{ name: "", genericName: "Acetylsalicylic Acid", dismissedUntil: "2024-03-12" }];
const result = computeMissedPastDoseIds(pastDays, meds, new Set(), new Set());
expect(result).toEqual([]);
});
it("expands takenBy people into separate dose IDs", () => {
const march10 = new Date("2024-03-10T00:00:00").getTime();
const pastDays = [makePastDay("SharedMed", [{ id: `1-0-${march10}`, takenBy: ["Alice", "Bob"] }])];
+2 -2
View File
@@ -597,13 +597,13 @@ export function computeMissedPastDoseIds(
doses: ReadonlyArray<{ id: string; takenBy: string[] }>;
}>;
}>,
medications: ReadonlyArray<{ name: string; dismissedUntil?: string | null }>,
medications: ReadonlyArray<{ name: string; genericName?: string | null; dismissedUntil?: string | null }>,
takenDoses: Set<string>,
dismissedDoses: Set<string>
): string[] {
const totalPastDoses = pastDays.flatMap((d) =>
d.meds.flatMap((m) => {
const med = medications.find((med) => med.name === m.medName);
const med = medications.find((medication) => getMedDisplayName(medication as Medication) === m.medName);
const dismissedUntilDate = med?.dismissedUntil ?? undefined;
return m.doses.flatMap((dose) => {