diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5d835c2..771dd22 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,7 +13,7 @@ import { AppHeader } from "./components/AppHeader"; import { AuthPage, AuthProvider, useAuth } from "./components/Auth"; import { AppProvider, UnsavedChangesProvider, useAppContext } from "./context"; import { useScrollLock } from "./hooks/useScrollLock"; -import { DashboardPage, MedicationsPage, PlannerPage, SchedulePage, SettingsPage } from "./pages"; +import { DashboardPage, MedicationsPage, PlannerPage, SchedulePage, SettingsPage, SharedOverviewPage } from "./pages"; // Vite injects this at build time from package.json declare const __APP_VERSION__: string; @@ -29,6 +29,7 @@ export default function App() { {/* Public share route - accessible without auth */} + } /> } /> {/* All other routes go through AppRouter */} } /> diff --git a/frontend/src/components/MedDetailModal.tsx b/frontend/src/components/MedDetailModal.tsx index b738447..ef3a1db 100644 --- a/frontend/src/components/MedDetailModal.tsx +++ b/frontend/src/components/MedDetailModal.tsx @@ -16,6 +16,7 @@ import { Lightbox, MedicationAvatar } from "../components"; import { useEscapeKey } from "../hooks"; import type { Coverage, Medication, RefillEntry, StockThresholds } from "../types"; import { + allowsPillFormSelection, getMedDisplayName, getMedTotal, getPackageSize, @@ -245,6 +246,14 @@ export function MedDetailModal({ const closeLabel = t("common.close"); const decrementLabel = t("editStock.decreaseValue"); const incrementLabel = t("editStock.increaseValue"); + const showPillWeightDetails = allowsPillFormSelection(selectedMed.packageType) && !!selectedMed.pillWeightMg; + const pillWeightMg = showPillWeightDetails ? (selectedMed.pillWeightMg ?? 0) : 0; + const isTubeRefillPackage = isTubePackageType(selectedMed.packageType); + const isLiquidRefillPackage = + isLiquidContainerPackageType(selectedMed.packageType) || selectedMed.medicationForm === "liquid"; + const isCountBasedAmountRefillPackage = isLiquidRefillPackage || isTubeRefillPackage; + const liquidRefillAmountPerBottle = Math.max(1, Math.round(Number.isFinite(amountPerPackage) ? amountPerPackage : 1)); + const amountRefillPackageCount = Math.max(0, Math.round(refillLoose / liquidRefillAmountPerBottle)); const getScheduleUsageLabel = (usage: number, intakeUnit?: "ml" | "tsp" | "tbsp" | null) => { if (isLiquidContainerPackageType(selectedMed.packageType)) { if (intakeUnit === "tsp") { @@ -934,7 +943,7 @@ export function MedDetailModal({ {(selectedMed.totalPills ?? packageSize) || "—"} )} - {selectedMed.pillWeightMg && ( + {showPillWeightDetails && (
{t("modal.pillWeight")} @@ -984,8 +993,7 @@ export function MedDetailModal({ > {getScheduleUsageLabel(totalUsage, intake.intakeUnit)} - {selectedMed.pillWeightMg && - ` (${totalUsage * selectedMed.pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`} + {showPillWeightDetails && ` (${totalUsage * pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`} {intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })} @@ -1236,6 +1244,23 @@ export function MedDetailModal({ })} + ) : isCountBasedAmountRefillPackage ? ( + ) : (
+

{t("share.overviewLink")}

+
+ (e.target as HTMLInputElement).select()} + /> + +
{shareCopied && {t("share.copied")}} + {overviewCopied && {t("share.copied")}}
diff --git a/frontend/src/context/AppContext.tsx b/frontend/src/context/AppContext.tsx index a5c90d8..d6308cc 100644 --- a/frontend/src/context/AppContext.tsx +++ b/frontend/src/context/AppContext.tsx @@ -54,6 +54,7 @@ export interface AppContextValue { setSettings: ReturnType["setSettings"]; savedSettings: ReturnType["savedSettings"]; settingsLoading: boolean; + settingsLoadError: ReturnType["settingsLoadError"]; settingsSaving: boolean; settingsSaved: boolean; testingEmail: boolean; @@ -299,14 +300,49 @@ export function AppProvider({ children }: { children: React.ReactNode }) { if (typeof window !== "undefined" && user?.id) { const storedDays = localStorage.getItem(userStorageKey(user.id, "scheduleDays")); setScheduleDays(storedDays ? Number(storedDays) : 30); + } else { + setScheduleDays(30); } }, [user?.id]); - // Load medications and settings when user changes + // Security boundary: clear user-scoped UI state immediately on user/session switches, + // then load fresh data for the active identity. useEffect(() => { + if (!user?.id) { + setScheduleDays(30); + } + + medications.clearMedicationsState(); + settingsHook.resetSettingsState(); + doses.clearDosesState(); + refill.clearRefillState(); + share.resetShareDialogState(); + + setSelectedMed(null); + setShowImageLightbox(false); + setScheduleLightboxImage(null); + setSelectedUser(null); + setShowPastDays(false); + setShowFutureDays(false); + setShowExportModal(false); + setShowImportConfirm(false); + setPendingImportData(null); + setImportResult(null); + medications.loadMeds(); settingsHook.loadSettings(); - }, [medications.loadMeds, settingsHook.loadSettings]); + doses.loadTakenDoses(); + }, [ + user?.id, + medications.clearMedicationsState, + medications.loadMeds, + settingsHook.resetSettingsState, + settingsHook.loadSettings, + doses.clearDosesState, + doses.loadTakenDoses, + refill.clearRefillState, + share.resetShareDialogState, + ]); // Update selectedMed when meds change (e.g., after refill) useEffect(() => { @@ -801,6 +837,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) { setSettings: settingsHook.setSettings, savedSettings: settingsHook.savedSettings, settingsLoading: settingsHook.settingsLoading, + settingsLoadError: settingsHook.settingsLoadError, settingsSaving: settingsHook.settingsSaving, settingsSaved: settingsHook.settingsSaved, testingEmail: settingsHook.testingEmail, diff --git a/frontend/src/hooks/useCollapsedDays.ts b/frontend/src/hooks/useCollapsedDays.ts index c1ae66f..4881cbb 100644 --- a/frontend/src/hooks/useCollapsedDays.ts +++ b/frontend/src/hooks/useCollapsedDays.ts @@ -24,6 +24,9 @@ export function useCollapsedDays(userId: number | undefined): UseCollapsedDaysRe ); setManuallyCollapsedDays(collapsed); setManuallyExpandedDays(expanded); + } else { + setManuallyCollapsedDays(new Set()); + setManuallyExpandedDays(new Set()); } }, [userId]); diff --git a/frontend/src/hooks/useDoses.ts b/frontend/src/hooks/useDoses.ts index 718164d..db1a207 100644 --- a/frontend/src/hooks/useDoses.ts +++ b/frontend/src/hooks/useDoses.ts @@ -12,6 +12,7 @@ export interface UseDosesReturn { dismissedDoses: Set; showClearMissedConfirm: boolean; setShowClearMissedConfirm: (show: boolean) => void; + clearDosesState: () => void; getDoseId: (baseDoseId: string, person: string | null) => string; isDoseTakenAutomatically: (doseId: string) => boolean; countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number }; @@ -30,6 +31,15 @@ export function useDoses(): UseDosesReturn { // Track in-flight mutations to prevent polling from overwriting optimistic updates const mutationInFlightRef = useRef(0); + const clearDosesState = useCallback(() => { + setTakenDoses(new Set()); + setTakenDoseTimestamps(new Map()); + setTakenDoseSources(new Map()); + setDismissedDoses(new Set()); + setShowClearMissedConfirm(false); + mutationInFlightRef.current = 0; + }, []); + // Load taken doses from server const loadTakenDoses = useCallback(async () => { // Skip polling while mutations are in-flight to prevent race conditions @@ -60,12 +70,15 @@ export function useDoses(): UseDosesReturn { setTakenDoseTimestamps(timestamps); setTakenDoseSources(sources); setDismissedDoses(dismissed); + } else if (res.status === 401 || res.status === 403) { + // Prevent showing previous user's dose state after auth/session changes. + clearDosesState(); } // Don't reset on error - keep current state } catch { // Don't reset on error - keep current state } - }, []); + }, [clearDosesState]); // Poll for taken doses from server (works with or without auth) useEffect(() => { @@ -209,6 +222,7 @@ export function useDoses(): UseDosesReturn { dismissedDoses, showClearMissedConfirm, setShowClearMissedConfirm, + clearDosesState, getDoseId, isDoseTakenAutomatically, countTakenDoses, diff --git a/frontend/src/hooks/useMedicationForm.ts b/frontend/src/hooks/useMedicationForm.ts index c03561b..38ccaf1 100644 --- a/frontend/src/hooks/useMedicationForm.ts +++ b/frontend/src/hooks/useMedicationForm.ts @@ -350,14 +350,15 @@ export function useMedicationForm(): UseMedicationFormReturn { const next = { ...prev, [key]: value } as FormState; if (key === "packageType") { - if (isTubePackageType(value)) { + const nextPackageType = value as FormState["packageType"]; + if (isTubePackageType(nextPackageType)) { next.packCount = "1"; next.packageAmountValue = String(Math.max(1, Number(next.packageAmountValue) || 0)); next.medicationForm = "topical"; next.lifecycleCategory = "treatment_period"; next.doseUnit = "units"; next.packageAmountUnit = "g"; - } else if (isLiquidContainerPackageType(value)) { + } else if (isLiquidContainerPackageType(nextPackageType)) { next.packCount = String(Math.max(1, Number(next.packCount) || 1)); next.packageAmountValue = String(Math.max(1, Number(next.packageAmountValue) || 0)); next.medicationForm = "liquid"; diff --git a/frontend/src/hooks/useMedications.ts b/frontend/src/hooks/useMedications.ts index 6d08408..246b538 100644 --- a/frontend/src/hooks/useMedications.ts +++ b/frontend/src/hooks/useMedications.ts @@ -8,6 +8,7 @@ export interface UseMedicationsReturn { saving: boolean; setSaving: React.Dispatch>; uploadingImage: boolean; + clearMedicationsState: () => void; loadMeds: () => void; deleteMed: (id: number, editingId: number | null, resetForm: () => void) => Promise; uploadMedImage: (medId: number, file: File) => Promise; @@ -20,6 +21,13 @@ export function useMedications(): UseMedicationsReturn { const [saving, setSaving] = useState(false); const [uploadingImage, setUploadingImage] = useState(false); + const clearMedicationsState = useCallback(() => { + setMeds([]); + setLoading(false); + setSaving(false); + setUploadingImage(false); + }, []); + const loadMeds = useCallback(() => { setLoading(true); fetch("/api/medications?includeObsolete=true", { credentials: "include" }) @@ -96,6 +104,7 @@ export function useMedications(): UseMedicationsReturn { saving, setSaving, uploadingImage, + clearMedicationsState, loadMeds, deleteMed, uploadMedImage, diff --git a/frontend/src/hooks/useRefill.ts b/frontend/src/hooks/useRefill.ts index 5444959..acb87e6 100644 --- a/frontend/src/hooks/useRefill.ts +++ b/frontend/src/hooks/useRefill.ts @@ -36,6 +36,7 @@ export interface UseRefillReturn { editStockMedication: Medication | null; // Actions + clearRefillState: () => void; loadRefillHistory: (medId: number) => Promise; submitRefill: ( medId: number, @@ -69,6 +70,22 @@ export function useRefill(): UseRefillReturn { const [editStockSaving, setEditStockSaving] = useState(false); const [editStockMedication, setEditStockMedication] = useState(null); + const clearRefillState = useCallback(() => { + setShowRefillModal(false); + setRefillPacks(1); + setRefillLoose(0); + setUsePrescriptionRefill(false); + setRefillSaving(false); + setRefillHistory([]); + setRefillHistoryExpanded(false); + setShowEditStockModal(false); + setEditStockFullBlisters(0); + setEditStockPartialBlisterPills(0); + setEditStockLoosePills(0); + setEditStockSaving(false); + setEditStockMedication(null); + }, []); + // Load refill history for a medication const loadRefillHistory = useCallback(async (medId: number) => { try { @@ -327,6 +344,7 @@ export function useRefill(): UseRefillReturn { }, [showEditStockModal]); return { + clearRefillState, showRefillModal, setShowRefillModal, refillPacks, diff --git a/frontend/src/hooks/useSettings.ts b/frontend/src/hooks/useSettings.ts index 41b512b..818bd2d 100644 --- a/frontend/src/hooks/useSettings.ts +++ b/frontend/src/hooks/useSettings.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { log } from "../utils/logger"; export interface Settings { emailEnabled: boolean; @@ -54,6 +55,8 @@ export interface Settings { expiryWarningDays: number; } +export type SettingsLoadError = "auth" | "forbidden" | "request" | null; + const defaultSettings: Settings = { emailEnabled: false, notificationEmail: "", @@ -108,6 +111,7 @@ export interface UseSettingsReturn { setSettings: React.Dispatch>; savedSettings: Settings; settingsLoading: boolean; + settingsLoadError: SettingsLoadError; settingsSaving: boolean; settingsSaved: boolean; testingEmail: boolean; @@ -121,6 +125,7 @@ export interface UseSettingsReturn { testEmail: () => Promise; testShoutrrr: () => Promise; hasUnsavedChanges: boolean; + resetSettingsState: () => void; } export function useSettings(): UseSettingsReturn { @@ -128,6 +133,7 @@ export function useSettings(): UseSettingsReturn { const [settings, setSettings] = useState(defaultSettings); const [savedSettings, setSavedSettings] = useState(defaultSettings); const [settingsLoading, setSettingsLoading] = useState(false); + const [settingsLoadError, setSettingsLoadError] = useState(null); const [settingsSaving, setSettingsSaving] = useState(false); const [settingsSaved, setSettingsSaved] = useState(false); const [testingEmail, setTestingEmail] = useState(false); @@ -135,20 +141,123 @@ export function useSettings(): UseSettingsReturn { const [testingShoutrrr, setTestingShoutrrr] = useState(false); const [testShoutrrrResult, setTestShoutrrrResult] = useState<{ success: boolean; message: string } | null>(null); + // Generation counter: incremented on every resetSettingsState call. + // loadSettings captures the current generation; if it changes before + // the fetch completes, the stale response is silently discarded. + const loadGenerationRef = useRef(0); + + const resetSettingsState = useCallback(() => { + loadGenerationRef.current += 1; // Invalidate any in-flight loadSettings + setSettings(defaultSettings); + setSavedSettings(defaultSettings); + setSettingsLoading(false); + setSettingsLoadError(null); + setSettingsSaving(false); + setSettingsSaved(false); + setTestingEmail(false); + setTestEmailResult(null); + setTestingShoutrrr(false); + setTestShoutrrrResult(null); + }, []); + + const clearReminderMetadata = useCallback(() => { + setSettings((prev) => ({ + ...prev, + lastAutoEmailSent: null, + lastNotificationType: null, + lastNotificationChannel: null, + lastReminderMedName: null, + lastReminderTakenBy: null, + lastStockReminderSent: null, + lastStockReminderChannel: null, + lastStockReminderMedNames: null, + lastPrescriptionReminderSent: null, + lastPrescriptionReminderChannel: null, + lastPrescriptionReminderMedNames: null, + })); + setSavedSettings((prev) => ({ + ...prev, + lastAutoEmailSent: null, + lastNotificationType: null, + lastNotificationChannel: null, + lastReminderMedName: null, + lastReminderTakenBy: null, + lastStockReminderSent: null, + lastStockReminderChannel: null, + lastStockReminderMedNames: null, + lastPrescriptionReminderSent: null, + lastPrescriptionReminderChannel: null, + lastPrescriptionReminderMedNames: null, + })); + }, []); + + const fetchWithRefresh = useCallback(async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const requestInit: RequestInit = { + credentials: "include", + ...init, + }; + + let response = await fetch(input, requestInit); + if (response.status !== 401) { + return response; + } + + const refreshResponse = await fetch("/api/auth/refresh", { + method: "POST", + credentials: "include", + }); + if (!refreshResponse.ok) { + return response; + } + + response = await fetch(input, requestInit); + return response; + }, []); + // Load settings function - exposed for manual refresh (e.g., after auth) const loadSettings = useCallback(() => { setSettingsLoading(true); - fetch("/api/settings", { credentials: "include" }) - .then((res) => (res.ok ? res.json() : Promise.reject())) + const generation = loadGenerationRef.current; + fetchWithRefresh("/api/settings") + .then((res) => { + // Discard result if a newer loadSettings call (or resetSettingsState) has fired + if (loadGenerationRef.current !== generation) return Promise.reject("stale"); + if (!res.ok) { + log.warn("[useSettings] loadSettings failed", { status: res.status }); + if (res.status === 401 || res.status === 403) { + resetSettingsState(); + } + return Promise.reject({ status: res.status }); + } + return res.json(); + }) .then((data) => { + if (!data || loadGenerationRef.current !== generation) return; + log.debug("[useSettings] settings loaded", { smtpConfigured: !!data.smtpHost }); const newSettings = { ...defaultSettings, ...data, smtpPass: "" }; setSettings(newSettings); setSavedSettings(newSettings); + setSettingsLoadError(null); setSettingsSaved(false); }) - .catch(() => {}) - .finally(() => setSettingsLoading(false)); - }, []); + .catch((error: unknown) => { + if (error === "stale") return; + const status = + typeof error === "object" && error !== null && "status" in error ? (error.status as number) : undefined; + if (status === 401) { + setSettingsLoadError("auth"); + return; + } + if (status === 403) { + setSettingsLoadError("forbidden"); + return; + } + setSettingsLoadError("request"); + }) + .finally(() => { + if (loadGenerationRef.current === generation) setSettingsLoading(false); + }); + }, [fetchWithRefresh, resetSettingsState]); // Load settings on mount useEffect(() => { @@ -158,41 +267,59 @@ export function useSettings(): UseSettingsReturn { // Auto-refresh reminder status (last sent timestamp) every 30 seconds useEffect(() => { const refreshReminderStatus = () => { - fetch("/api/settings", { credentials: "include" }) - .then((res) => (res.ok ? res.json() : Promise.reject())) + fetchWithRefresh("/api/settings") + .then((res) => { + if (!res.ok) { + if (res.status === 401 || res.status === 403) { + clearReminderMetadata(); + } + return Promise.reject(); + } + return res.json(); + }) .then((data) => { + const pick = (key: string, fallback: T): T => (Object.hasOwn(data, key) ? (data[key] as T) : fallback); + // Only update the reminder-related fields without triggering unsaved changes setSettings((prev) => ({ ...prev, - lastAutoEmailSent: data.lastAutoEmailSent ?? prev.lastAutoEmailSent, - lastNotificationType: data.lastNotificationType ?? prev.lastNotificationType, - lastNotificationChannel: data.lastNotificationChannel ?? prev.lastNotificationChannel, - lastReminderMedName: data.lastReminderMedName ?? prev.lastReminderMedName, - lastReminderTakenBy: data.lastReminderTakenBy ?? prev.lastReminderTakenBy, - lastStockReminderSent: data.lastStockReminderSent ?? prev.lastStockReminderSent, - lastStockReminderChannel: data.lastStockReminderChannel ?? prev.lastStockReminderChannel, - lastStockReminderMedNames: data.lastStockReminderMedNames ?? prev.lastStockReminderMedNames, - lastPrescriptionReminderSent: data.lastPrescriptionReminderSent ?? prev.lastPrescriptionReminderSent, - lastPrescriptionReminderChannel: - data.lastPrescriptionReminderChannel ?? prev.lastPrescriptionReminderChannel, - lastPrescriptionReminderMedNames: - data.lastPrescriptionReminderMedNames ?? prev.lastPrescriptionReminderMedNames, + lastAutoEmailSent: pick("lastAutoEmailSent", prev.lastAutoEmailSent), + lastNotificationType: pick("lastNotificationType", prev.lastNotificationType), + lastNotificationChannel: pick("lastNotificationChannel", prev.lastNotificationChannel), + lastReminderMedName: pick("lastReminderMedName", prev.lastReminderMedName), + lastReminderTakenBy: pick("lastReminderTakenBy", prev.lastReminderTakenBy), + lastStockReminderSent: pick("lastStockReminderSent", prev.lastStockReminderSent), + lastStockReminderChannel: pick("lastStockReminderChannel", prev.lastStockReminderChannel), + lastStockReminderMedNames: pick("lastStockReminderMedNames", prev.lastStockReminderMedNames), + lastPrescriptionReminderSent: pick("lastPrescriptionReminderSent", prev.lastPrescriptionReminderSent), + lastPrescriptionReminderChannel: pick( + "lastPrescriptionReminderChannel", + prev.lastPrescriptionReminderChannel + ), + lastPrescriptionReminderMedNames: pick( + "lastPrescriptionReminderMedNames", + prev.lastPrescriptionReminderMedNames + ), })); setSavedSettings((prev) => ({ ...prev, - lastAutoEmailSent: data.lastAutoEmailSent ?? prev.lastAutoEmailSent, - lastNotificationType: data.lastNotificationType ?? prev.lastNotificationType, - lastNotificationChannel: data.lastNotificationChannel ?? prev.lastNotificationChannel, - lastReminderMedName: data.lastReminderMedName ?? prev.lastReminderMedName, - lastReminderTakenBy: data.lastReminderTakenBy ?? prev.lastReminderTakenBy, - lastStockReminderSent: data.lastStockReminderSent ?? prev.lastStockReminderSent, - lastStockReminderChannel: data.lastStockReminderChannel ?? prev.lastStockReminderChannel, - lastStockReminderMedNames: data.lastStockReminderMedNames ?? prev.lastStockReminderMedNames, - lastPrescriptionReminderSent: data.lastPrescriptionReminderSent ?? prev.lastPrescriptionReminderSent, - lastPrescriptionReminderChannel: - data.lastPrescriptionReminderChannel ?? prev.lastPrescriptionReminderChannel, - lastPrescriptionReminderMedNames: - data.lastPrescriptionReminderMedNames ?? prev.lastPrescriptionReminderMedNames, + lastAutoEmailSent: pick("lastAutoEmailSent", prev.lastAutoEmailSent), + lastNotificationType: pick("lastNotificationType", prev.lastNotificationType), + lastNotificationChannel: pick("lastNotificationChannel", prev.lastNotificationChannel), + lastReminderMedName: pick("lastReminderMedName", prev.lastReminderMedName), + lastReminderTakenBy: pick("lastReminderTakenBy", prev.lastReminderTakenBy), + lastStockReminderSent: pick("lastStockReminderSent", prev.lastStockReminderSent), + lastStockReminderChannel: pick("lastStockReminderChannel", prev.lastStockReminderChannel), + lastStockReminderMedNames: pick("lastStockReminderMedNames", prev.lastStockReminderMedNames), + lastPrescriptionReminderSent: pick("lastPrescriptionReminderSent", prev.lastPrescriptionReminderSent), + lastPrescriptionReminderChannel: pick( + "lastPrescriptionReminderChannel", + prev.lastPrescriptionReminderChannel + ), + lastPrescriptionReminderMedNames: pick( + "lastPrescriptionReminderMedNames", + prev.lastPrescriptionReminderMedNames + ), })); }) .catch(() => {}); @@ -200,7 +327,7 @@ export function useSettings(): UseSettingsReturn { const interval = setInterval(refreshReminderStatus, 30000); return () => clearInterval(interval); - }, []); + }, [clearReminderMetadata, fetchWithRefresh]); // Internal save function (no event needed) const performSave = useCallback( @@ -246,20 +373,30 @@ export function useSettings(): UseSettingsReturn { smtpSecure: settingsToSave.smtpSecure, }; - await fetch("/api/settings", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify(payload), - }).catch(() => null); + try { + const response = await fetchWithRefresh("/api/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); - const updatedSettings = { ...settingsToSave }; - setSettings(updatedSettings); - setSettingsSaving(false); - setSavedSettings(updatedSettings); - setSettingsSaved(true); + if (!response.ok) { + throw new Error(`SETTINGS_SAVE_FAILED_${response.status}`); + } + + const updatedSettings = { ...settingsToSave }; + setSettings(updatedSettings); + setSavedSettings(updatedSettings); + setSettingsSaved(true); + } catch { + setSettingsSaved(false); + // Keep UI aligned with backend truth if save failed (auth/session/network/server error). + loadSettings(); + } finally { + setSettingsSaving(false); + } }, - [i18n.language] + [fetchWithRefresh, i18n.language, loadSettings] ); // Debounced auto-save: fires whenever settings change @@ -321,10 +458,9 @@ export function useSettings(): UseSettingsReturn { setTestingEmail(true); setTestEmailResult(null); try { - const res = await fetch("/api/settings/test-email", { + const res = await fetchWithRefresh("/api/settings/test-email", { method: "POST", headers: { "Content-Type": "application/json" }, - credentials: "include", body: JSON.stringify({ email: settings.notificationEmail }), }); const data = await res.json(); @@ -337,16 +473,15 @@ export function useSettings(): UseSettingsReturn { } finally { setTestingEmail(false); } - }, [settings.notificationEmail]); + }, [fetchWithRefresh, settings.notificationEmail]); const testShoutrrr = useCallback(async () => { setTestingShoutrrr(true); setTestShoutrrrResult(null); try { - const res = await fetch("/api/settings/test-shoutrrr", { + const res = await fetchWithRefresh("/api/settings/test-shoutrrr", { method: "POST", headers: { "Content-Type": "application/json" }, - credentials: "include", body: JSON.stringify({ url: settings.shoutrrrUrl }), }); const data = await res.json(); @@ -359,7 +494,7 @@ export function useSettings(): UseSettingsReturn { } finally { setTestingShoutrrr(false); } - }, [settings.shoutrrrUrl]); + }, [fetchWithRefresh, settings.shoutrrrUrl]); // Check for unsaved changes const hasUnsavedChanges = JSON.stringify(settings) !== JSON.stringify(savedSettings); @@ -369,6 +504,7 @@ export function useSettings(): UseSettingsReturn { setSettings, savedSettings, settingsLoading, + settingsLoadError, settingsSaving, settingsSaved, testingEmail, @@ -382,5 +518,6 @@ export function useSettings(): UseSettingsReturn { testEmail, testShoutrrr, hasUnsavedChanges, + resetSettingsState, }; } diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index 0760ba6..b0a27c2 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -292,6 +292,18 @@ "title": "Sprache", "select": "Sprache auswählen" }, + "apiKey": { + "title": "API-Zugriff", + "generateTitle": "API-Key erzeugen", + "generateDesc": "Erstellt einen neuen API-Key mit Schreibrechten. Der vorherige Key wird automatisch ungültig.", + "generateButton": "Key erzeugen", + "generating": "Wird erzeugt...", + "currentToken": "Neuer API-Key", + "copyButton": "Kopieren", + "copied": "Kopiert", + "copyHint": "Diesen Key jetzt kopieren. Er wird nur einmal angezeigt und kann später nicht erneut abgerufen werden.", + "generateError": "API-Key konnte nicht erzeugt werden" + }, "notifications": { "title": "Benachrichtigungen", "channels": "Kanäle", @@ -312,7 +324,11 @@ }, "email": { "recipient": "Empfänger", - "notConfigured": "Nicht konfiguriert" + "notConfigured": "Nicht konfiguriert", + "serverNotConfigured": "E-Mail-Benachrichtigungen bleiben deaktiviert, bis SMTP im Backend konfiguriert ist.", + "loadErrorAuth": "Die E-Mail-Einstellungen konnten nicht geladen werden, weil deine Sitzung nicht mehr gültig ist. Bitte melde dich erneut an.", + "loadErrorForbidden": "Die E-Mail-Einstellungen konnten nicht geladen werden, weil diese Sitzung sie nicht lesen darf.", + "loadErrorGeneric": "Die Verfügbarkeit von E-Mail konnte nicht geprüft werden, weil das Laden der Einstellungen fehlgeschlagen ist." }, "push": { "url": "URL", @@ -541,7 +557,10 @@ "generating": "Wird generiert...", "generateAnother": "Weiteren Link generieren", "linkGenerated": "Teilen-Link erstellt!", + "scheduleLink": "Zeitplan-Link", + "overviewLink": "Übersichts-Link", "copyLink": "Link kopieren", + "copyOverviewLink": "Übersichts-Link kopieren", "copied": "In Zwischenablage kopiert!", "noPeople": "Keine Medikamente mit 'Eingenommen von' zugewiesen. Füge zuerst eine Person zu einem Medikament hinzu.", "scheduleFor": "Zeitplan für", @@ -557,6 +576,34 @@ "expiredOn": "Abgelaufen am: {{date}}" } }, + "sharedOverview": { + "title": "Medikamentenübersicht für {{person}}", + "sharedBy": "Geteilt von {{user}}", + "expiredOn": "Abgelaufen am: {{date}}", + "noMedications": "Für diesen Teilen-Link sind keine Medikamente verfügbar.", + "columns": { + "name": "Name", + "package": "Packung", + "stock": "Bestand", + "daysLeft": "Tage übrig", + "nextIntake": "Nächste Einnahme", + "depletion": "Aufgebraucht", + "priority": "Priorität" + }, + "priority": { + "normal": "Normal", + "high": "Hoch" + }, + "stock": { + "of": "{{current}} von {{capacity}}" + }, + "error": { + "notFound": "Teilen-Link nicht gefunden", + "expired": "Dieser geteilte Übersichts-Link ist abgelaufen", + "rateLimit": "Zu viele Anfragen. Bitte versuche es in einem Moment erneut.", + "generic": "Die Medikamentenübersicht konnte nicht geladen werden" + } + }, "exportImport": { "title": "Datenexport / -import", "description": "Sichere deine Daten oder übertrage sie auf ein anderes Gerät.", diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index db44436..d799eb9 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -292,6 +292,18 @@ "title": "Language", "select": "Select language" }, + "apiKey": { + "title": "API Access", + "generateTitle": "Generate API key", + "generateDesc": "Creates a new write-capable API key. The previous key becomes invalid automatically.", + "generateButton": "Generate key", + "generating": "Generating...", + "currentToken": "New API key", + "copyButton": "Copy", + "copied": "Copied", + "copyHint": "Copy this key now. It is shown only once and cannot be retrieved later.", + "generateError": "Failed to generate API key" + }, "notifications": { "title": "Notifications", "channels": "Channels", @@ -312,7 +324,11 @@ }, "email": { "recipient": "Recipient", - "notConfigured": "Not configured" + "notConfigured": "Not configured", + "serverNotConfigured": "Email notifications stay unavailable until SMTP is configured on the backend.", + "loadErrorAuth": "Email settings could not be loaded because your session is no longer valid. Please sign in again.", + "loadErrorForbidden": "Email settings could not be loaded because this session is not allowed to read them.", + "loadErrorGeneric": "Email availability could not be verified because loading settings failed." }, "push": { "url": "URL", @@ -541,7 +557,10 @@ "generating": "Generating...", "generateAnother": "Generate another link", "linkGenerated": "Share link generated!", + "scheduleLink": "Schedule link", + "overviewLink": "Overview link", "copyLink": "Copy Link", + "copyOverviewLink": "Copy Overview Link", "copied": "Copied to clipboard!", "noPeople": "No medications with 'Taken by' assigned. Add a person to a medication first.", "scheduleFor": "Schedule for", @@ -557,6 +576,34 @@ "expiredOn": "Expired on: {{date}}" } }, + "sharedOverview": { + "title": "Medication Overview for {{person}}", + "sharedBy": "Shared by {{user}}", + "expiredOn": "Expired on: {{date}}", + "noMedications": "No medications available for this share link.", + "columns": { + "name": "Name", + "package": "Package", + "stock": "Stock", + "daysLeft": "Days left", + "nextIntake": "Next intake", + "depletion": "Depletion", + "priority": "Priority" + }, + "priority": { + "normal": "Normal", + "high": "High" + }, + "stock": { + "of": "{{current}} of {{capacity}}" + }, + "error": { + "notFound": "Share link not found", + "expired": "This shared overview link has expired", + "rateLimit": "Too many requests. Please try again in a moment.", + "generic": "Failed to load medication overview" + } + }, "exportImport": { "title": "Data Export / Import", "description": "Backup your data or transfer it to another device.", diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index ec431ad..817bdc5 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -50,7 +50,9 @@ export function DashboardPage() { const { user } = useAuth(); const { meds, + loading, settings, + settingsLoading, coverage, coverageByMed, depletionByMed, @@ -134,6 +136,7 @@ export function DashboardPage() { .sort((a, b) => a.remainingRefills - b.remainingRefills); const anyRemindersEnabled = stockRemindersEnabled || intakeRemindersEnabled || prescriptionRemindersEnabled; + const remindersLoading = loading || settingsLoading; const showOnlyToday = settings.upcomingTodayOnly; const prescriptionEmptyCount = prescriptionLowMeds.filter((med) => med.remainingRefills <= 0).length; @@ -143,12 +146,14 @@ export function DashboardPage() { ? t("form.packageAmountUnitMl") : t("form.blisters.applications", { count: Math.abs(value) }); + const getTubeStockUnitLabel = () => t("form.packageAmountUnitG"); + const formatStockLabel = (med: (typeof meds)[number] | undefined, medsLeft: number) => { if (isLiquidContainerPackageType(med?.packageType)) { return `${formatNumber(medsLeft)} ${t("form.packageAmountUnitMl")}`; } if (isTubePackageType(med?.packageType)) { - return `${formatNumber(medsLeft)} ${getTubeUnitLabel(med, medsLeft)}`; + return `${formatNumber(medsLeft)} ${getTubeStockUnitLabel()}`; } return t("table.pillsCount", { count: Math.round(medsLeft) }); }; @@ -387,78 +392,59 @@ export function DashboardPage() { return ( <> - {anyRemindersEnabled && ( -
+ {remindersLoading ? ( +
{t("dashboard.reminders.active")} - {stockRemindersEnabled && ( - {reminderData.status.text} - )} - {prescriptionStatus && ( - {prescriptionStatus.text} - )}
- {(reminderData.lowStockMeds.length > 0 || - (prescriptionRemindersEnabled && prescriptionLowMeds.length > 0) || - (stockRemindersEnabled && reminderData.lastStockSent) || - (intakeRemindersEnabled && reminderData.lastIntakeSent)) && ( -
- {stockRemindersEnabled && reminderData.lowStockMeds.length > 0 && ( -
- {t("dashboard.reminders.needsRefill")}: - - {reminderData.lowStockMeds.map((med, idx) => { - const medication = meds.find((m) => getMedDisplayName(m) === med.name); - const cov = coverage.all.find((c) => c.name === med.name); - const status = cov - ? getStockStatus(cov.daysLeft, cov.medsLeft, stockThresholds, medication?.packageType) - : null; - const textClass = - status?.className === "danger" - ? "danger-text" - : status?.className === "warning" - ? "warning-text" - : ""; - return ( - - {idx > 0 && ", "} - medication && openMedDetail(medication)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - if (medication) openMedDetail(medication); - } - }} - > - {med.name} - - - {" "} - {t("dashboard.reminders.daysLeft", { count: med.daysLeft, days: med.daysLeft })} - - - ); - })} - -
+
+ + + +
+
+ ) : ( + anyRemindersEnabled && ( +
+
+ + + + {t("dashboard.reminders.active")} + {stockRemindersEnabled && ( + {reminderData.status.text} )} - {prescriptionRemindersEnabled && prescriptionLowMeds.length > 0 && ( -
- {t("dashboard.reminders.needsPrescriptionRefill")}: - - {prescriptionLowMeds.map((med, idx) => { - const medication = meds.find((m) => m.id === med.id); - const textClass = med.remainingRefills <= 0 ? "danger-text" : "warning-text"; - return ( - - {idx > 0 && ", "} - - {t("prescription.remainingRefills")}: {med.remainingRefills} ·{" "} - {t("dashboard.reminders.usedBy")}:{" "} + {prescriptionStatus && ( + {prescriptionStatus.text} + )} +
+ {(reminderData.lowStockMeds.length > 0 || + (prescriptionRemindersEnabled && prescriptionLowMeds.length > 0) || + (stockRemindersEnabled && reminderData.lastStockSent) || + (intakeRemindersEnabled && reminderData.lastIntakeSent)) && ( +
+ {stockRemindersEnabled && reminderData.lowStockMeds.length > 0 && ( +
+ {t("dashboard.reminders.needsRefill")}: + + {reminderData.lowStockMeds.map((med, idx) => { + const medication = meds.find((m) => getMedDisplayName(m) === med.name); + const cov = coverage.all.find((c) => c.name === med.name); + const status = cov + ? getStockStatus(cov.daysLeft, cov.medsLeft, stockThresholds, medication?.packageType) + : null; + const textClass = + status?.className === "danger" + ? "danger-text" + : status?.className === "warning" + ? "warning-text" + : ""; + return ( + + {idx > 0 && ", "} medication && openMedDetail(medication)} @@ -470,95 +456,130 @@ export function DashboardPage() { > {med.name} - - - ); - })} - -
- )} - {stockRemindersEnabled && reminderData.lastStockSent && ( -
- {t("dashboard.reminders.lastStockSent")}: - - {reminderData.lastStockSent.medNames && - (() => { - const names = reminderData.lastStockSent!.medNames!.split(", "); - return names.map((name, idx) => { - const medication = meds.find((m) => getMedDisplayName(m) === name); - return ( - - {idx > 0 && ", "} - {medication ? ( - openMedDetail(medication)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") openMedDetail(medication); - }} - > - {name} - - ) : ( - {name} - )} + + {" "} + {t("dashboard.reminders.daysLeft", { count: med.daysLeft, days: med.daysLeft })} - ); - }); - })()} - {reminderData.lastStockSent.date} - -
- )} - {intakeRemindersEnabled && reminderData.lastIntakeSent && ( -
- {t("dashboard.reminders.lastSent")}: - - {reminderData.lastIntakeSent.medName && - (() => { - const medication = meds.find( - (m) => getMedDisplayName(m) === reminderData.lastIntakeSent!.medName - ); - return medication ? ( - openMedDetail(medication)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") openMedDetail(medication); - }} - > - {reminderData.lastIntakeSent!.medName} - ) : ( - {reminderData.lastIntakeSent!.medName} ); - })()} - {reminderData.lastIntakeSent.takenBy && ( - ({reminderData.lastIntakeSent.takenBy}) - )} - {reminderData.lastIntakeSent.date} + })} + +
+ )} + {prescriptionRemindersEnabled && prescriptionLowMeds.length > 0 && ( +
+ {t("dashboard.reminders.needsPrescriptionRefill")}: + + {prescriptionLowMeds.map((med, idx) => { + const medication = meds.find((m) => m.id === med.id); + const textClass = med.remainingRefills <= 0 ? "danger-text" : "warning-text"; + return ( + + {idx > 0 && ", "} + + {t("prescription.remainingRefills")}: {med.remainingRefills} ·{" "} + {t("dashboard.reminders.usedBy")}:{" "} + medication && openMedDetail(medication)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + if (medication) openMedDetail(medication); + } + }} + > + {med.name} + + + + ); + })} + +
+ )} + {stockRemindersEnabled && reminderData.lastStockSent && ( +
+ {t("dashboard.reminders.lastStockSent")}: + + {reminderData.lastStockSent.medNames && + (() => { + const names = reminderData.lastStockSent!.medNames!.split(", "); + return names.map((name, idx) => { + const medication = meds.find((m) => getMedDisplayName(m) === name); + return ( + + {idx > 0 && ", "} + {medication ? ( + openMedDetail(medication)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") openMedDetail(medication); + }} + > + {name} + + ) : ( + {name} + )} + + ); + }); + })()} + {reminderData.lastStockSent.date} + +
+ )} + {intakeRemindersEnabled && reminderData.lastIntakeSent && ( +
+ {t("dashboard.reminders.lastSent")}: + + {reminderData.lastIntakeSent.medName && + (() => { + const medication = meds.find( + (m) => getMedDisplayName(m) === reminderData.lastIntakeSent!.medName + ); + return medication ? ( + openMedDetail(medication)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") openMedDetail(medication); + }} + > + {reminderData.lastIntakeSent!.medName} + + ) : ( + {reminderData.lastIntakeSent!.medName} + ); + })()} + {reminderData.lastIntakeSent.takenBy && ( + ({reminderData.lastIntakeSent.takenBy}) + )} + {reminderData.lastIntakeSent.date} + +
+ )} +
+ )} + {((stockRemindersEnabled && reminderData.lowStockMeds.length > 0) || + (prescriptionRemindersEnabled && prescriptionLowMeds.length > 0)) && ( +
+ + {reminderResult && ( + + {reminderResult.message} -
- )} -
- )} - {((stockRemindersEnabled && reminderData.lowStockMeds.length > 0) || - (prescriptionRemindersEnabled && prescriptionLowMeds.length > 0)) && ( -
- - {reminderResult && ( - - {reminderResult.message} - - )} -
- )} -
+ )} + + )} +
+ ) )} {/* Reorder Reminder card: Only show when reminders are NOT enabled (otherwise Reminder Bar shows the same info) */} - {!anyRemindersEnabled && ( + {!remindersLoading && !anyRemindersEnabled && (
@@ -640,159 +661,169 @@ export function DashboardPage() {

{t("dashboard.overview.title")}

-
-
- {t("table.name")} - {t("table.stock")} - {t("table.dailyConsumption")} - {t("table.stockDetails")} - {t("table.daysLeft")} - {t("table.runsOut")} - {t("table.expiry")} - {t("table.status")} + {loading ? ( +
+ {t("common.loading")} + + + +
- {coverage.all.map((row) => { - const med = meds.find((m) => getMedDisplayName(m) === row.name); - const rawStatus = getStockStatus(row.daysLeft, row.medsLeft, stockThresholds, med?.packageType); - const status = getVisibleStockStatus(med, rawStatus); - const expiryClass = getExpiryClass(med?.expiryDate, settings.expiryWarningDays); - const textClass = - rawStatus.className === "danger" - ? "danger-text" - : rawStatus.className === "warning" - ? "warning-text" - : "success-text"; - const stock = getBlisterStock( - Math.round(row.medsLeft), - med?.pillsPerBlister ?? 1, - med?.looseTablets ?? 0, - med ? getMedTotal(med) : Math.round(row.medsLeft) - ); - return ( -
med && openMedDetail(med)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - if (med) openMedDetail(med); - } - }} - > - - - { - e.stopPropagation(); - if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`); - }} - onKeyDown={(e) => { - e.stopPropagation(); - if (e.key === "Enter" || e.key === " ") { + ) : ( +
+
+ {t("table.name")} + {t("table.stock")} + {t("table.dailyConsumption")} + {t("table.stockDetails")} + {t("table.daysLeft")} + {t("table.runsOut")} + {t("table.expiry")} + {t("table.status")} +
+ {coverage.all.map((row) => { + const med = meds.find((m) => getMedDisplayName(m) === row.name); + const rawStatus = getStockStatus(row.daysLeft, row.medsLeft, stockThresholds, med?.packageType); + const status = getVisibleStockStatus(med, rawStatus); + const expiryClass = getExpiryClass(med?.expiryDate, settings.expiryWarningDays); + const textClass = + rawStatus.className === "danger" + ? "danger-text" + : rawStatus.className === "warning" + ? "warning-text" + : "success-text"; + const stock = getBlisterStock( + Math.round(row.medsLeft), + med?.pillsPerBlister ?? 1, + med?.looseTablets ?? 0, + med ? getMedTotal(med) : Math.round(row.medsLeft) + ); + return ( +
med && openMedDetail(med)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + if (med) openMedDetail(med); + } + }} + > + + + { + e.stopPropagation(); if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`); - } - }} - > - - - - - {row.name} - {med?.notes && ( - <> - {" "} - - - - )} - {med?.prescriptionEnabled && ( - <> - {" "} - - - - )} + }} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Enter" || e.key === " ") { + if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`); + } + }} + > + - {med?.takenBy && med.takenBy.length > 0 && ( - - {med.takenBy.map((person) => ( - { - e.stopPropagation(); - openUserFilter(person); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { + + + {row.name} + {med?.notes && ( + <> + {" "} + + + + )} + {med?.prescriptionEnabled && ( + <> + {" "} + + + + )} + + {med?.takenBy && med.takenBy.length > 0 && ( + + {med.takenBy.map((person) => ( + { e.stopPropagation(); openUserFilter(person); - } - }} - > - {person} - {med.intakes?.some((i) => i.takenBy === person && i.intakeRemindersEnabled) && ( - - ))} - - )} + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.stopPropagation(); + openUserFilter(person); + } + }} + > + {person} + {med.intakes?.some((i) => i.takenBy === person && i.intakeRemindersEnabled) && ( + + ))} + + )} + - - - {isAmountBasedPackageType(med?.packageType) - ? formatStockLabel(med, row.medsLeft) - : formatFullBlisters(stock.fullBlisters, t)} - - - {formatDailyConsumption(med)} - - - {isAmountBasedPackageType(med?.packageType) - ? "—" - : formatOpenBlisterAndLoose( - stock.openBlisterPills, - stock.loosePills, - med?.pillsPerBlister ?? 1, - t - )} - - - {formatNumber(row.daysLeft)} - - {row.depletionDate ?? "-"} - - {med?.expiryDate - ? new Date(med.expiryDate).toLocaleDateString(getSystemLocale(i18n.language), { - day: "2-digit", - month: "short", - year: "2-digit", - }) - : "-"} - - - {status ? t(status.label) : "-"} - -
- ); - })} -
+ + {isAmountBasedPackageType(med?.packageType) + ? formatStockLabel(med, row.medsLeft) + : formatFullBlisters(stock.fullBlisters, t)} + + + {formatDailyConsumption(med)} + + + {isAmountBasedPackageType(med?.packageType) + ? "—" + : formatOpenBlisterAndLoose( + stock.openBlisterPills, + stock.loosePills, + med?.pillsPerBlister ?? 1, + t + )} + + + {formatNumber(row.daysLeft)} + + {row.depletionDate ?? "-"} + + {med?.expiryDate + ? new Date(med.expiryDate).toLocaleDateString(getSystemLocale(i18n.language), { + day: "2-digit", + month: "short", + year: "2-digit", + }) + : "-"} + + + {status ? t(status.label) : "-"} + +
+ ); + })} +
+ )}
@@ -800,263 +831,269 @@ export function DashboardPage() {

{t("dashboard.schedules.title")}

-
- - {meds.some((m) => m.takenBy && m.takenBy.length > 0) && ( -
+ ) : ( +
+ + {meds.some((m) => m.takenBy && m.takenBy.length > 0) && ( + + )} +
+ )} +
+ {loading ? ( +
+ {t("common.loading")} + + + +
+ ) : ( +
+ {/* Past days (when expanded) — rendered above toggle */} + {!showOnlyToday && + 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 isAutoCollapsed = true; // Past days are always auto-collapsed + const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr); + const isCollapsed = !isManuallyExpanded; + const _worstStatus = getDayStockStatus(day.meds); + + return (
toggleDayCollapse(day.dateStr, isAutoCollapsed)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed); - }} - title={isCollapsed ? t("common.expand") : t("common.collapse")} + key={day.dateStr} + className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allReallyTaken ? "all-taken" : allDoseIds.length > 0 ? "past-missed" : ""}`} > - {isCollapsed ? "▶" : "▼"} - {day.dateStr} - - {allReallyTaken ? ( - ✓ {t("dashboard.schedules.allTaken")} - ) : ( - <> - {hasRealMissed && ( - - ⚠️ - - )} - - {takenCount}/{allDoseIds.length} - - - )} - -
- {!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 rawStatus = medCov - ? 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 ( -
-
-
-
med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`); - } - }} +
toggleDayCollapse(day.dateStr, isAutoCollapsed)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed); + }} + title={isCollapsed ? t("common.expand") : t("common.collapse")} + > + {isCollapsed ? "▶" : "▼"} + {day.dateStr} + + {allReallyTaken ? ( + ✓ {t("dashboard.schedules.allTaken")} + ) : ( + <> + {hasRealMissed && ( + - -
-
med && openMedDetail(med)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - if (med) openMedDetail(med); - } - }} - > - {item.medName} - {med?.genericName && {med.genericName}} -
-
-
- - {formatTotalUsageLabel(med, item.total, item.doses[0]?.intakeUnit, item.doses)} + ⚠️ - {status && ( - {t(status.label)} - )} + )} + + {takenCount}/{allDoseIds.length} + + + )} + +
+ {!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 rawStatus = medCov + ? 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 ( +
+
+
+
+ med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`) + } + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`); + } + }} + > + +
+
med && openMedDetail(med)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + if (med) openMedDetail(med); + } + }} + > + {item.medName} + {med?.genericName && ( + {med.genericName} + )} +
+
+
+ + {formatTotalUsageLabel(med, item.total, item.doses[0]?.intakeUnit, item.doses)} + + {status && ( + {t(status.label)} + )} +
+
+
+ {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 ( +
+ {dose.timeStr} + + + {formatDoseUsageLabel(med, dose.usage, dose.intakeUnit)} + + {allowsPillFormSelection(med?.packageType) && med?.pillWeightMg && ( + {`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`} + )} + + {dose.intakeRemindersEnabled && ( + + + )} +
+ {people.map((person) => { + const doseId = getDoseId(dose.id, person); + const isTaken = takenDoses.has(doseId); + const isAutomaticallyTaken = + isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now(); + return ( +
+ {person && ( + openUserFilter(person)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") openUserFilter(person); + }} + > + {person} + + )} + {isTaken ? ( + + ) : ( + + )} +
+ ); + })} +
+
+ ); + })}
-
- {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 ( -
- {dose.timeStr} - - - {formatDoseUsageLabel(med, dose.usage, dose.intakeUnit)} - - {allowsPillFormSelection(med?.packageType) && med?.pillWeightMg && ( - {`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`} - )} - - {dose.intakeRemindersEnabled && ( - - - )} -
- {people.map((person) => { - const doseId = getDoseId(dose.id, person); - const isTaken = takenDoses.has(doseId); - const isAutomaticallyTaken = - isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now(); - return ( -
- {person && ( - openUserFilter(person)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") openUserFilter(person); - }} - > - {person} - - )} - {isTaken ? ( - - ) : ( - - )} -
- ); - })} -
-
- ); - })} -
-
- ); - })} -
- ); - })} - {/* Past days toggle */} - {!showOnlyToday && - pastDays.length > 0 && - (() => { - const missedCount = missedPastDoseIds.length; - const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => expandDoseIds(m.doses))); - return ( -
-
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 === " ") { + ); + })} +
+ ); + })} + {/* Past days toggle */} + {!showOnlyToday && + pastDays.length > 0 && + (() => { + const missedCount = missedPastDoseIds.length; + const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => expandDoseIds(m.doses))); + return ( +
+
0 ? "has-missed" : ""}`} + onClick={() => { const wasCollapsed = !showPastDays; setShowPastDays(!showPastDays); if (wasCollapsed) { @@ -1066,500 +1103,527 @@ export function DashboardPage() { ?.scrollIntoView({ behavior: "smooth", block: "center" }); }, 50); } - } - }} - > - {showPastDays ? "▼" : "▶"} - - {showPastDays ? t("dashboard.schedules.hidePastDays") : t("dashboard.schedules.showPastDays")} - - - ({t("dashboard.schedules.pastDaysCount", { count: pastDays.length })}) - - {missedCount > 0 ? ( - - ⚠️ {missedCount} - - ) : totalPastDoses.length > 0 ? ( - - ✓ - - ) : null} -
- {missedCount > 0 && ( - - )} -
- ); - })()} - {/* 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 dayStockStatuses = day.meds.map((item) => { - const medCoverage = coverageByMed[item.medName]; - const depletionTime = depletionByMed[item.medName]; - const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime; - if (willBeOutOfStock) return "danger"; - if (!medCoverage) return "success"; - const med = getMedByName(item.medName); - const status = getStockStatus( - medCoverage.daysLeft, - medCoverage.medsLeft, - stockThresholds, - med?.packageType - ); - return status.className; - }); - const worstStatus = dayStockStatuses.includes("danger") - ? "danger" - : dayStockStatuses.includes("warning") - ? "warning" - : "success"; - - // Today: expanded by default, can be manually collapsed - const isAutoCollapsed = allDayTaken; - const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr); - const isManuallyCollapsed = manuallyCollapsedDays.has(day.dateStr); - const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed; - - return ( -
-
toggleDayCollapse(day.dateStr, isAutoCollapsed)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed); - }} - title={isCollapsed ? t("common.expand") : t("common.collapse")} - > - {isCollapsed ? "▶" : "▼"} - {day.dateStr} - - {allDayTaken ? ( - ✓ {t("dashboard.schedules.allTaken")} - ) : ( - - {takenCount}/{allDoseIds.length} - - )} - -
- {!isCollapsed && - day.meds.map((item) => { - const medCoverage = coverageByMed[item.medName]; - const med = meds.find((m) => getMedDisplayName(m) === item.medName); - const depletionTime = depletionByMed[item.medName]; - const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false; - const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime; - const status = willBeOutOfStock - ? { className: "danger", label: "status.outOfStock" } - : medCoverage - ? getStockStatus( - medCoverage.daysLeft, - medCoverage.medsLeft, - stockThresholds, - med?.packageType - ) - : null; - const visibleStatus = getVisibleStockStatus(med, status); - const itemDoseIds = expandDoseIds(item.doses); - const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); - return ( -
-
-
-
med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`); - } - }} - > - -
-
med && openMedDetail(med)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - if (med) openMedDetail(med); - } - }} - > - {item.medName} - {med?.genericName && {med.genericName}} -
-
-
- - {formatTotalUsageLabel(med, item.total, item.doses[0]?.intakeUnit, item.doses)} - - {visibleStatus && ( - - {t(visibleStatus.label)} - - )} -
-
-
- {item.doses.map((dose) => { - 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))); - return ( -
- {dose.timeStr} - - - {formatDoseUsageLabel(med, dose.usage, dose.intakeUnit)} - - {allowsPillFormSelection(med?.packageType) && med?.pillWeightMg && ( - {`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`} - )} - - {dose.intakeRemindersEnabled && ( - - - )} -
- {people.map((person) => { - const doseId = getDoseId(dose.id, person); - const isTaken = takenDoses.has(doseId); - const isAutomaticallyTaken = - isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now(); - return ( -
- {person && ( - openUserFilter(person)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") openUserFilter(person); - }} - > - {person} - - )} - {isTaken ? ( - - ) : ( - - )} -
- ); - })} -
-
- ); - })} -
-
- ); - })} -
- ); - })()} - {/* Future days toggle */} - {!showOnlyToday && - futureDays.length > 0 && - (() => { - const totalFutureDoses = futureDays.flatMap((d) => - d.meds.flatMap((m) => - m.doses.flatMap((dose) => - dose.takenBy.length > 0 ? dose.takenBy.map((p) => `${dose.id}-${p}`) : [dose.id] - ) - ) - ); - const takenFutureDoses = totalFutureDoses.filter((id) => takenDoses.has(id)).length; - return ( -
-
setShowFutureDays(!showFutureDays)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") setShowFutureDays(!showFutureDays); - }} - > - {showFutureDays ? "▼" : "▶"} - - {showFutureDays - ? t("dashboard.schedules.hideFutureDays") - : t("dashboard.schedules.showFutureDays")} - - - ({t("dashboard.schedules.futureDaysCount", { count: futureDays.length })}) - - {takenFutureDoses > 0 && totalFutureDoses.length > 0 && ( - - {takenFutureDoses}/{totalFutureDoses.length} + {showPastDays ? "▼" : "▶"} + + {showPastDays + ? t("dashboard.schedules.hidePastDays") + : t("dashboard.schedules.showPastDays")} + + ({t("dashboard.schedules.pastDaysCount", { count: pastDays.length })}) + + {missedCount > 0 ? ( + + ⚠️ {missedCount} + + ) : totalPastDoses.length > 0 ? ( + + ✓ + + ) : null} +
+ {missedCount > 0 && ( + )}
-
- ); - })()} - {/* Future days */} - {!showOnlyToday && - 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 dayStockStatuses = day.meds.map((item) => { - const medCoverage = coverageByMed[item.medName]; - const depletionTime = depletionByMed[item.medName]; - const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime; - if (willBeOutOfStock) return "danger"; - if (!medCoverage) return "success"; - const med = getMedByName(item.medName); - const status = getStockStatus( - medCoverage.daysLeft, - medCoverage.medsLeft, - stockThresholds, - med?.packageType ); - return status.className; - }); - const worstStatus = dayStockStatuses.includes("danger") - ? "danger" - : dayStockStatuses.includes("warning") - ? "warning" - : "success"; + })()} + {/* 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; - // Future days: collapsed by default - const isAutoCollapsed = true; - const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr); - const isCollapsed = !isManuallyExpanded; + const dayStockStatuses = day.meds.map((item) => { + const medCoverage = coverageByMed[item.medName]; + const depletionTime = depletionByMed[item.medName]; + const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime; + if (willBeOutOfStock) return "danger"; + if (!medCoverage) return "success"; + const med = getMedByName(item.medName); + const status = getStockStatus( + medCoverage.daysLeft, + medCoverage.medsLeft, + stockThresholds, + med?.packageType + ); + return status.className; + }); + const worstStatus = dayStockStatuses.includes("danger") + ? "danger" + : dayStockStatuses.includes("warning") + ? "warning" + : "success"; - return ( -
+ // Today: expanded by default, can be manually collapsed + const isAutoCollapsed = allDayTaken; + const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr); + const isManuallyCollapsed = manuallyCollapsedDays.has(day.dateStr); + const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed; + + return (
toggleDayCollapse(day.dateStr, isAutoCollapsed)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed); - }} - title={isCollapsed ? t("common.expand") : t("common.collapse")} + key={day.dateStr} + className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} today ${worstStatus ? `stock-${worstStatus}` : ""}`} > - {isCollapsed ? "▶" : "▼"} - {day.dateStr} - - {allDayTaken ? ( - ✓ {t("dashboard.schedules.allTaken")} - ) : ( - - {takenCount}/{allDoseIds.length} +
toggleDayCollapse(day.dateStr, isAutoCollapsed)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed); + }} + title={isCollapsed ? t("common.expand") : t("common.collapse")} + > + {isCollapsed ? "▶" : "▼"} + {day.dateStr} + + {allDayTaken ? ( + ✓ {t("dashboard.schedules.allTaken")} + ) : ( + + {takenCount}/{allDoseIds.length} + + )} + +
+ {!isCollapsed && + day.meds.map((item) => { + const medCoverage = coverageByMed[item.medName]; + const med = meds.find((m) => getMedDisplayName(m) === item.medName); + const depletionTime = depletionByMed[item.medName]; + const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false; + const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime; + const status = willBeOutOfStock + ? { className: "danger", label: "status.outOfStock" } + : medCoverage + ? getStockStatus( + medCoverage.daysLeft, + medCoverage.medsLeft, + stockThresholds, + med?.packageType + ) + : null; + const visibleStatus = getVisibleStockStatus(med, status); + const itemDoseIds = expandDoseIds(item.doses); + const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); + return ( +
+
+
+
+ med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`) + } + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`); + } + }} + > + +
+
med && openMedDetail(med)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + if (med) openMedDetail(med); + } + }} + > + {item.medName} + {med?.genericName && ( + {med.genericName} + )} +
+
+
+ + {formatTotalUsageLabel(med, item.total, item.doses[0]?.intakeUnit, item.doses)} + + {visibleStatus && ( + + {t(visibleStatus.label)} + + )} +
+
+
+ {item.doses.map((dose) => { + 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)) + ); + return ( +
+ {dose.timeStr} + + + {formatDoseUsageLabel(med, dose.usage, dose.intakeUnit)} + + {allowsPillFormSelection(med?.packageType) && med?.pillWeightMg && ( + {`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`} + )} + + {dose.intakeRemindersEnabled && ( + + + )} +
+ {people.map((person) => { + const doseId = getDoseId(dose.id, person); + const isTaken = takenDoses.has(doseId); + const isAutomaticallyTaken = + isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now(); + return ( +
+ {person && ( + openUserFilter(person)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") openUserFilter(person); + }} + > + {person} + + )} + {isTaken ? ( + + ) : ( + + )} +
+ ); + })} +
+
+ ); + })} +
+
+ ); + })} +
+ ); + })()} + {/* Future days toggle */} + {!showOnlyToday && + futureDays.length > 0 && + (() => { + const totalFutureDoses = futureDays.flatMap((d) => + d.meds.flatMap((m) => + m.doses.flatMap((dose) => + dose.takenBy.length > 0 ? dose.takenBy.map((p) => `${dose.id}-${p}`) : [dose.id] + ) + ) + ); + const takenFutureDoses = totalFutureDoses.filter((id) => takenDoses.has(id)).length; + return ( +
+
setShowFutureDays(!showFutureDays)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") setShowFutureDays(!showFutureDays); + }} + > + {showFutureDays ? "▼" : "▶"} + + {showFutureDays + ? t("dashboard.schedules.hideFutureDays") + : t("dashboard.schedules.showFutureDays")} + + + ({t("dashboard.schedules.futureDaysCount", { count: futureDays.length })}) + + {takenFutureDoses > 0 && totalFutureDoses.length > 0 && ( + + {takenFutureDoses}/{totalFutureDoses.length} )} - +
- {!isCollapsed && - day.meds.map((item) => { - const medCoverage = coverageByMed[item.medName]; - const med = meds.find((m) => getMedDisplayName(m) === item.medName); - const depletionTime = depletionByMed[item.medName]; - const _isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false; - const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime; - const status = willBeOutOfStock - ? { className: "danger", label: "status.outOfStock" } - : medCoverage - ? getStockStatus( - medCoverage.daysLeft, - medCoverage.medsLeft, - stockThresholds, - med?.packageType - ) - : null; - const visibleStatus = getVisibleStockStatus(med, status); - const itemDoseIds = expandDoseIds(item.doses); - const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); - return ( -
-
-
-
med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`); + ); + })()} + {/* Future days */} + {!showOnlyToday && + 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 dayStockStatuses = day.meds.map((item) => { + const medCoverage = coverageByMed[item.medName]; + const depletionTime = depletionByMed[item.medName]; + const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime; + if (willBeOutOfStock) return "danger"; + if (!medCoverage) return "success"; + const med = getMedByName(item.medName); + const status = getStockStatus( + medCoverage.daysLeft, + medCoverage.medsLeft, + stockThresholds, + med?.packageType + ); + return status.className; + }); + const worstStatus = dayStockStatuses.includes("danger") + ? "danger" + : dayStockStatuses.includes("warning") + ? "warning" + : "success"; + + // Future days: collapsed by default + const isAutoCollapsed = true; + const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr); + const isCollapsed = !isManuallyExpanded; + + return ( +
+
toggleDayCollapse(day.dateStr, isAutoCollapsed)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed); + }} + title={isCollapsed ? t("common.expand") : t("common.collapse")} + > + {isCollapsed ? "▶" : "▼"} + {day.dateStr} + + {allDayTaken ? ( + ✓ {t("dashboard.schedules.allTaken")} + ) : ( + + {takenCount}/{allDoseIds.length} + + )} + +
+ {!isCollapsed && + day.meds.map((item) => { + const medCoverage = coverageByMed[item.medName]; + const med = meds.find((m) => getMedDisplayName(m) === item.medName); + const depletionTime = depletionByMed[item.medName]; + const _isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false; + const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime; + const status = willBeOutOfStock + ? { className: "danger", label: "status.outOfStock" } + : medCoverage + ? getStockStatus( + medCoverage.daysLeft, + medCoverage.medsLeft, + stockThresholds, + med?.packageType + ) + : null; + const visibleStatus = getVisibleStockStatus(med, status); + const itemDoseIds = expandDoseIds(item.doses); + const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); + return ( +
+
+
+
+ med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`) } - }} - > - -
-
med && openMedDetail(med)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - if (med) openMedDetail(med); - } - }} - > - {item.medName} - {med?.genericName && {med.genericName}} -
-
-
- - {formatTotalUsageLabel(med, item.total, item.doses[0]?.intakeUnit, item.doses)} - - {visibleStatus && ( - - {t(visibleStatus.label)} - - )} -
-
-
- {item.doses.map((dose) => { - const people = dose.takenBy.length > 0 ? dose.takenBy : [null]; - const allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person))); - return ( -
- {dose.timeStr} - - - {formatDoseUsageLabel(med, dose.usage, dose.intakeUnit)} - - {allowsPillFormSelection(med?.packageType) && med?.pillWeightMg && ( - {`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`} - )} - - {dose.intakeRemindersEnabled && ( - - - )} -
- {people.map((person) => { - const doseId = getDoseId(dose.id, person); - const isTaken = takenDoses.has(doseId); - const isAutomaticallyTaken = - isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now(); - return ( -
- {person && ( - openUserFilter(person)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") openUserFilter(person); - }} - > - {person} - - )} - {isTaken ? ( - - ) : ( - - )} -
- ); - })} -
+ onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`); + } + }} + > +
- ); - })} +
med && openMedDetail(med)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + if (med) openMedDetail(med); + } + }} + > + {item.medName} + {med?.genericName && ( + {med.genericName} + )} +
+
+
+ + {formatTotalUsageLabel(med, item.total, item.doses[0]?.intakeUnit, item.doses)} + + {visibleStatus && ( + + {t(visibleStatus.label)} + + )} +
+
+
+ {item.doses.map((dose) => { + const people = dose.takenBy.length > 0 ? dose.takenBy : [null]; + const allTaken = people.every((person) => + takenDoses.has(getDoseId(dose.id, person)) + ); + return ( +
+ {dose.timeStr} + + + {formatDoseUsageLabel(med, dose.usage, dose.intakeUnit)} + + {allowsPillFormSelection(med?.packageType) && med?.pillWeightMg && ( + {`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`} + )} + + {dose.intakeRemindersEnabled && ( + + + )} +
+ {people.map((person) => { + const doseId = getDoseId(dose.id, person); + const isTaken = takenDoses.has(doseId); + const isAutomaticallyTaken = + isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now(); + return ( +
+ {person && ( + openUserFilter(person)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") openUserFilter(person); + }} + > + {person} + + )} + {isTaken ? ( + + ) : ( + + )} +
+ ); + })} +
+
+ ); + })} +
-
- ); - })} -
- ); - })} -
+ ); + })} +
+ ); + })} +
+ )}
diff --git a/frontend/src/pages/MedicationsPage.tsx b/frontend/src/pages/MedicationsPage.tsx index 0e8f0a4..fe26fb4 100644 --- a/frontend/src/pages/MedicationsPage.tsx +++ b/frontend/src/pages/MedicationsPage.tsx @@ -1261,7 +1261,7 @@ export function MedicationsPage() {