// ============================================================================= // SharedSchedule Component - Public view for shared schedules // ============================================================================= /* biome-ignore-all lint/style/noNestedTernary: rendering branches are intentionally explicit in schedule UI */ /* biome-ignore-all lint/correctness/useExhaustiveDependencies: modal and helper callbacks are stable at runtime */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useParams } from "react-router-dom"; import { useEscapeKey } from "../hooks"; import type { ExpiredLinkData, SharedScheduleData } from "../types"; import { allowsPillFormSelection, getMedDisplayName, getMedTotal, isLiquidContainerPackageType, isTubePackageType, } from "../types"; import { getSystemLocale } from "../utils/formatters"; import { isDoseDismissed, parseLocalDateTime } from "../utils/schedule"; import { loadCollapsedDaysFromStorage } from "../utils/storage"; import { MedicationAvatar } from "./MedicationAvatar"; import { SharedMedicationOverviewSection } from "./SharedMedicationOverviewSection"; export function SharedSchedule() { const { token } = useParams<{ token: string }>(); const { t, i18n } = useTranslation(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [expiredData, setExpiredData] = useState(null); const [takenDoses, setTakenDoses] = useState>(new Set()); const [automaticTakenDoses, setAutomaticTakenDoses] = useState>(new Set()); const [dismissedDoses, setDismissedDoses] = useState>(new Set()); const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null); const [showPastDays, setShowPastDays] = useState(false); const [showFutureDays, setShowFutureDays] = useState(false); const isLiquidContainerMed = (med: SharedScheduleData["medications"][number] | undefined) => isLiquidContainerPackageType(med?.packageType); const convertLiquidUsageToMl = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): number => { if (unit === "tsp") return usage * 5; if (unit === "tbsp") return usage * 15; return usage; }; const convertUsageForStock = ( usage: number, med: SharedScheduleData["medications"][number] | undefined, unit: "ml" | "tsp" | "tbsp" | null | undefined ): number => { if (isTubePackageType(med?.packageType)) return 0; if (!isLiquidContainerMed(med)) return usage; return convertLiquidUsageToMl(usage, unit); }; const formatAmount = (value: number) => { const rounded = Math.round(value * 100) / 100; return String(rounded); }; const getLiquidCountUnitLabel = (unit: "ml" | "tsp" | "tbsp" | null | undefined, usage: number): string => { if (unit === "tsp") return t("form.blisters.teaspoons", { count: Math.abs(usage) }); if (unit === "tbsp") return t("form.blisters.tablespoons", { count: Math.abs(usage) }); return t("form.packageAmountUnitMl"); }; const formatLiquidUsageLabel = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): string => { const normalizedUsage = Number(usage); if (!Number.isFinite(normalizedUsage) || normalizedUsage <= 0) { return `0 ${t("form.packageAmountUnitMl")}`; } if (unit === "ml" || unit == null) { return `${formatAmount(normalizedUsage)} ${t("form.packageAmountUnitMl")}`; } const mlTotal = convertLiquidUsageToMl(normalizedUsage, unit); return `${formatAmount(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage)} ${formatAmount(mlTotal)} ${t("form.packageAmountUnitMl")}`; }; const formatDoseUsageLabel = ( med: SharedScheduleData["medications"][number] | undefined, usage: number, intakeUnit?: "ml" | "tsp" | "tbsp" | null ) => { if (isLiquidContainerMed(med)) { return formatLiquidUsageLabel(usage, intakeUnit); } return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`; }; const formatTotalUsageLabel = ( med: SharedScheduleData["medications"][number] | undefined, total: number, doses?: Array<{ usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }> ) => { if (isLiquidContainerMed(med)) { if (doses && doses.length > 0) { const normalizedDoses = doses.filter((dose) => Number.isFinite(Number(dose.usage)) && Number(dose.usage) > 0); if (normalizedDoses.length > 0) { const allUnits = new Set(normalizedDoses.map((dose) => dose.intakeUnit ?? "ml")); if (allUnits.size === 1) { const onlyUnit = normalizedDoses[0]?.intakeUnit ?? "ml"; const totalUsageInUnit = normalizedDoses.reduce((sum, dose) => sum + Number(dose.usage), 0); return formatLiquidUsageLabel(totalUsageInUnit, onlyUnit); } const totalMl = normalizedDoses.reduce( (sum, dose) => sum + convertLiquidUsageToMl(Number(dose.usage), dose.intakeUnit ?? "ml"), 0 ); return `${formatAmount(totalMl)} ${t("form.packageAmountUnitMl")}`; } } return `${formatAmount(total)} ${t("form.packageAmountUnitMl")}`; } return t("common.pillsTotal", { count: total }); }; // Theme preference: light, dark, or system type ThemePreference = "light" | "dark" | "system"; const [themePreference, setThemePreference] = useState(() => { if (typeof window !== "undefined") { const stored = localStorage.getItem("theme") as ThemePreference | null; if (stored === "light" || stored === "dark" || stored === "system") return stored; } return "dark"; }); function getSystemTheme(): "light" | "dark" { if (typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: light)").matches) { return "light"; } return "dark"; } const resolvedTheme = themePreference === "system" ? getSystemTheme() : themePreference; // Apply resolved theme to document useEffect(() => { document.documentElement.setAttribute("data-theme", resolvedTheme); localStorage.setItem("theme", themePreference); }, [themePreference, resolvedTheme]); // Listen for system theme changes when preference is "system" useEffect(() => { if (themePreference !== "system") return; const mq = window.matchMedia?.("(prefers-color-scheme: light)"); if (!mq) return; const handler = () => { const resolved = mq.matches ? "light" : "dark"; document.documentElement.setAttribute("data-theme", resolved); }; mq.addEventListener("change", handler); return () => mq.removeEventListener("change", handler); }, [themePreference]); // Theme dropdown state const [themeMenuOpen, setThemeMenuOpen] = useState(false); const themeMenuRef = useRef(null); useEffect(() => { if (!themeMenuOpen) return; const handleClickOutside = (e: MouseEvent) => { if (themeMenuRef.current && !themeMenuRef.current.contains(e.target as Node)) { setThemeMenuOpen(false); } }; document.addEventListener("click", handleClickOutside); return () => document.removeEventListener("click", handleClickOutside); }, [themeMenuOpen]); // Collapsed days state for SharedSchedule (token-specific localStorage) const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState>(new Set()); const [manuallyExpandedDays, setManuallyExpandedDays] = useState>(new Set()); // Load collapsed/expanded state from localStorage useEffect(() => { if (token && typeof window !== "undefined") { const { collapsed, expanded } = loadCollapsedDaysFromStorage( `share_${token}_collapsedDays`, `share_${token}_expandedDays` ); setManuallyCollapsedDays(collapsed); setManuallyExpandedDays(expanded); } }, [token]); // Toggle day collapse/expand for SharedSchedule function toggleDayCollapse(dateStr: string, isAutoCollapsed: boolean) { if (isAutoCollapsed) { setManuallyExpandedDays((prev) => { const next = new Set(prev); if (next.has(dateStr)) { next.delete(dateStr); } else { next.add(dateStr); } if (token) localStorage.setItem(`share_${token}_expandedDays`, JSON.stringify([...next])); return next; }); } else { setManuallyCollapsedDays((prev) => { const next = new Set(prev); if (next.has(dateStr)) { next.delete(dateStr); } else { next.add(dateStr); } if (token) localStorage.setItem(`share_${token}_collapsedDays`, JSON.stringify([...next])); return next; }); } } // Helper functions for lightbox with history support (mobile back swipe) function openLightbox(url: string, name: string) { setLightboxImage({ url, name }); window.history.pushState({ modal: "lightbox" }, ""); } function closeLightbox() { if (lightboxImage) { window.history.back(); } } // Close lightbox on Escape key useEscapeKey(!!lightboxImage, closeLightbox); // Handle browser back button to close lightbox useEffect(() => { function handlePopState() { if (lightboxImage) { setLightboxImage(null); } } window.addEventListener("popstate", handlePopState); return () => window.removeEventListener("popstate", handlePopState); }, [lightboxImage]); // Load taken doses from server with polling for real-time sync // Separates taken and dismissed doses (like main app's useDoses hook) const loadTakenDoses = useCallback(async () => { if (!token) return; try { const res = await fetch(`/api/share/${token}/doses`); if (res.ok) { const data = await res.json(); const taken = new Set(); const automatic = new Set(); const dismissed = new Set(); for (const d of data.doses as Array<{ doseId: string; dismissed?: boolean; takenSource?: string }>) { if (d.dismissed) { dismissed.add(d.doseId); } else { taken.add(d.doseId); if (d.takenSource === "automatic") { automatic.add(d.doseId); } } } setTakenDoses(taken); setAutomaticTakenDoses(automatic); setDismissedDoses(dismissed); } else { setTakenDoses(new Set()); setAutomaticTakenDoses(new Set()); setDismissedDoses(new Set()); } } catch { setTakenDoses(new Set()); setAutomaticTakenDoses(new Set()); setDismissedDoses(new Set()); } }, [token]); useEffect(() => { if (!token) return; loadTakenDoses(); // Poll for updates every 5 seconds (real-time sync with dashboard) const interval = setInterval(loadTakenDoses, 5000); return () => clearInterval(interval); }, [loadTakenDoses, token]); // Get dose ID - for per-intake takenBy, the ID already has the person suffix // This helper is kept for compatibility but since dose.id already includes the suffix, it just returns the id function _getDoseId(doseId: string, _person: string | null): string { // The dose.id already includes the person suffix if there's a per-intake takenBy return doseId; } async function markDoseTaken(doseId: string) { // Optimistic update setTakenDoses((prev) => { const next = new Set(prev); next.add(doseId); return next; }); setAutomaticTakenDoses((prev) => { const next = new Set(prev); next.delete(doseId); return next; }); // Send to server try { const response = await fetch(`/api/share/${token}/doses`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ doseId }), }); if (!response.ok) { try { const data = (await response.json()) as { code?: string }; if (data.code === "OUT_OF_STOCK") { alert(t("common.outOfStockTakeBlocked")); } } catch { // Ignore JSON parsing errors and fall back to the optimistic rollback only. } throw new Error("Failed to mark shared dose as taken"); } } catch { // Revert on error setTakenDoses((prev) => { const next = new Set(prev); next.delete(doseId); return next; }); } finally { loadTakenDoses(); } } async function undoDoseTaken(doseId: string) { // Optimistic update setTakenDoses((prev) => { const next = new Set(prev); next.delete(doseId); return next; }); setAutomaticTakenDoses((prev) => { const next = new Set(prev); next.delete(doseId); return next; }); // Send to server try { await fetch(`/api/share/${token}/doses/${encodeURIComponent(doseId)}`, { method: "DELETE", }); } catch { // Revert on error setTakenDoses((prev) => { const next = new Set(prev); next.add(doseId); return next; }); } } const isDoseTakenAutomatically = (doseId: string) => automaticTakenDoses.has(doseId); useEffect(() => { async function fetchData() { if (!token) { setError("Invalid link"); setLoading(false); return; } try { const res = await fetch(`/api/share/${token}`); if (res.ok) { const json = await res.json(); setData(json); } else if (res.status === 410) { // Link expired - get owner info const json = await res.json(); setExpiredData({ ownerUsername: json.ownerUsername, takenBy: json.takenBy, expiredAt: json.expiredAt, }); } else if (res.status === 404) { setError(t("share.notFound")); } else { setError(t("share.error")); } } catch { setError(t("share.error")); } finally { setLoading(false); } } fetchData(); }, [token, t]); // Build schedule from medications - matches buildSchedulePreview logic exactly const schedule = useMemo(() => { if (!data) return []; // Use same logic as buildSchedulePreview in main app const now = new Date(); const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); // Midnight today // Use 180 days horizon like main app (scheduleDays only limits futureDays display) const end = new Date(); end.setDate(end.getDate() + 180); const doses: { id: string; when: number; medName: string; usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null; timeStr: string; isPast: boolean; takenBy: string | null; // Per-intake takenBy (single person or null) dateStr: string; }[] = []; for (const med of data.medications) { // Use intakes (with per-intake takenBy) if available, fallback to blisters (legacy) const intakes = med.intakes || med.blisters.map((b) => ({ ...b, intakeUnit: null, takenBy: null as string | null, intakeRemindersEnabled: false, })); intakes.forEach((intake, intakeIdx) => { // Filter: for person-specific shares, include matching intakes plus shared-for-everyone intakes. if (data.takenBy !== "all" && intake.takenBy !== null && intake.takenBy !== data.takenBy) return; const startDate = parseLocalDateTime(intake.start); if (Number.isNaN(startDate.getTime())) return; // Use the same iteration method as buildSchedulePreview (setDate instead of adding ms) // This ensures identical timestamps even across DST changes for (let d = new Date(startDate); d <= end; d.setDate(d.getDate() + intake.every)) { const t = d.getTime(); const isPast = d < todayStart; // Use date-only timestamp for stable ID (immune to time changes) // This ensures changing intake times doesn't invalidate past dose tracking // Must match buildSchedulePreview in schedule.ts exactly const dateOnlyMs = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); // Dose ID includes person suffix if there's a per-intake takenBy const baseDoseId = `${med.id}-${intakeIdx}-${dateOnlyMs}`; const doseId = intake.takenBy ? `${baseDoseId}-${intake.takenBy}` : baseDoseId; doses.push({ id: doseId, when: t, medName: getMedDisplayName(med), usage: intake.usage, intakeUnit: intake.intakeUnit ?? null, isPast, takenBy: intake.takenBy, // Per-intake takenBy (string | null) timeStr: d.toLocaleTimeString(getSystemLocale(i18n.language), { hour: "2-digit", minute: "2-digit" }), dateStr: d.toLocaleDateString(getSystemLocale(i18n.language), { weekday: "short", day: "2-digit", month: "short", }), }); } }); } doses.sort((a, b) => a.when - b.when); // Group by date - matches groupedSchedule logic in main app type DoseInfo = (typeof doses)[number]; const days = new Map< string, { dateStr: string; date: Date; isPast: boolean; meds: Map; } >(); for (const dose of doses.slice(0, 2000)) { const day = days.get(dose.dateStr) ?? { dateStr: dose.dateStr, date: new Date(dose.when), isPast: dose.isPast, meds: new Map(), }; const medEntry = day.meds.get(dose.medName) ?? { medName: dose.medName, total: 0, doses: [], lastWhen: dose.when, }; medEntry.total += dose.usage; medEntry.doses.push(dose); medEntry.lastWhen = Math.max(medEntry.lastWhen, dose.when); day.meds.set(dose.medName, medEntry); days.set(dose.dateStr, day); } return Array.from(days.values()).map((d) => ({ dateStr: d.dateStr, date: d.date, isPast: d.isPast, meds: Array.from(d.meds.values()), })); }, [data, i18n.language]); // Split into past, today, and future - matches main app logic const pastDays = useMemo(() => schedule.filter((d) => d.isPast), [schedule]); // Separate today from future days const { todayDay, futureDays } = useMemo(() => { const today = new Date(); const todayStr = today.toLocaleDateString(getSystemLocale(i18n.language), { weekday: "short", day: "2-digit", month: "short", }); const nonPastDays = schedule.filter((d) => !d.isPast).slice(0, data?.scheduleDays ?? 30); const todayEntry = nonPastDays.find((d) => d.dateStr === todayStr); const future = nonPastDays.filter((d) => d.dateStr !== todayStr); return { todayDay: todayEntry || null, futureDays: future }; }, [schedule, data?.scheduleDays, i18n.language]); // Calculate coverage for stock status colors โ€” matches main app's calculateCoverage logic // Uses time-based automatic consumption (same as DashboardPage) for accurate stock levels const coverageByMed = useMemo(() => { if (!data) return {}; const MS_PER_DAY = 86_400_000; const now = Date.now(); const calcMode = data.stockCalculationMode ?? "automatic"; const coverage: Record = {}; for (const med of data.medications) { const intakes = med.intakes || med.blisters.map((b) => ({ ...b, intakeUnit: null, takenBy: null as string | null, intakeRemindersEnabled: false, })); // Count unique people from all intakes (for per-intake takenBy) const uniquePeople = new Set(); intakes.forEach((intake) => { if (intake.takenBy) uniquePeople.add(intake.takenBy); }); med.takenBy?.forEach((person) => uniquePeople.add(person)); const personCount = Math.max(1, uniquePeople.size || med.takenBy?.length || 1); // Calculate daily consumption rate accounting for per-intake takenBy let dailyRate = 0; intakes.forEach((intake) => { const usageForStock = convertUsageForStock(intake.usage, med, intake.intakeUnit ?? "ml"); const baseRate = intake.every > 0 ? usageForStock / intake.every : 0; if (intake?.takenBy) { dailyRate += baseRate; // Per-intake takenBy: 1 person } else { dailyRate += baseRate * personCount; // Legacy: all people } }); let consumed = 0; const stockCorrectionCutoff = med.lastStockCorrectionAt ? med.lastStockCorrectionAt : 0; if (calcMode === "automatic") { // Time-based: every scheduled dose counts as consumed once its time has passed intakes.forEach((intake, blisterIdx) => { const usageForStock = convertUsageForStock(intake.usage, med, intake.intakeUnit ?? "ml"); const blisterStart = parseLocalDateTime(intake.start).getTime(); const period = Math.max(1, intake.every) * MS_PER_DAY; let effectiveStart: number; if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) { const elapsedSinceStart = stockCorrectionCutoff - blisterStart; const periodsElapsed = Math.floor(elapsedSinceStart / period); effectiveStart = blisterStart + (periodsElapsed + 1) * period; } else { effectiveStart = blisterStart; } if (Number.isNaN(effectiveStart)) return; const intakePerson = intake?.takenBy; const fallbackPeople = med.takenBy?.length > 0 ? med.takenBy : [null]; const peopleForThisIntake = intakePerson ? [intakePerson] : fallbackPeople; let timeBasedConsumed = 0; let lastAutoConsumedDateMs = 0; if (effectiveStart <= now) { const occurrences = Math.floor((now - effectiveStart) / period) + 1; timeBasedConsumed = occurrences * usageForStock * peopleForThisIntake.length; const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period); lastAutoConsumedDateMs = new Date( lastDoseTime.getFullYear(), lastDoseTime.getMonth(), lastDoseTime.getDate() ).getTime(); } // Early intakes: future doses already marked as taken const stockCorrectionDateOnly = stockCorrectionCutoff > 0 ? new Date( new Date(stockCorrectionCutoff).getFullYear(), new Date(stockCorrectionCutoff).getMonth(), new Date(stockCorrectionCutoff).getDate() ).getTime() : 0; const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly); let earlyTakenConsumed = 0; for (const doseId of takenDoses) { const parts = doseId.split("-"); if (parts.length >= 3) { const medId = parseInt(parts[0], 10); const bIdx = parseInt(parts[1], 10); const timestamp = parseInt(parts[2], 10); if (medId === med.id && bIdx === blisterIdx && timestamp > earlyCutoff) { earlyTakenConsumed += usageForStock; } } } consumed += timeBasedConsumed + earlyTakenConsumed; }); } else { // Manual mode: only count explicitly taken doses takenDoses.forEach((doseId) => { const parts = doseId.split("-"); if (parts.length >= 3) { const medId = parseInt(parts[0], 10); const blisterIdx = parseInt(parts[1], 10); const doseTimestamp = parseInt(parts[2], 10); if (medId === med.id && intakes[blisterIdx]) { const blisterStartDate = new Date(intakes[blisterIdx].start); const blisterStartDateOnly = new Date( blisterStartDate.getFullYear(), blisterStartDate.getMonth(), blisterStartDate.getDate() ).getTime(); const afterCorrection = stockCorrectionCutoff === 0 || doseTimestamp > stockCorrectionCutoff; if (!Number.isNaN(blisterStartDateOnly) && doseTimestamp >= blisterStartDateOnly && afterCorrection) { consumed += convertUsageForStock( intakes[blisterIdx].usage, med, intakes[blisterIdx].intakeUnit ?? "ml" ); } } } }); } const totalPills = getMedTotal(med); const medsLeft = Math.max(0, totalPills - consumed); const rawDaysLeft = dailyRate > 0 ? medsLeft / dailyRate : null; const daysLeft = rawDaysLeft !== null ? Math.max(0, Math.floor(rawDaysLeft)) : null; coverage[getMedDisplayName(med)] = { daysLeft, medsLeft: Number(medsLeft.toFixed(1)), dailyUsage: dailyRate }; } return coverage; }, [data, takenDoses]); const outOfStockMedicationIds = useMemo( () => new Set( (data?.medications ?? []) .filter((med) => (coverageByMed[getMedDisplayName(med)]?.medsLeft ?? 1) <= 0) .map((med) => med.id) ), [data, coverageByMed] ); const isDoseTakenForDisplay = useCallback( (doseId: string) => { const medId = Number.parseInt(doseId.split("-")[0] ?? "", 10); if (!Number.isNaN(medId) && outOfStockMedicationIds.has(medId)) { return false; } return takenDoses.has(doseId); }, [outOfStockMedicationIds, takenDoses] ); const showMedicationOverview = data?.shareMedicationOverview === true && data?.medicationOverview !== null; const showOnlyToday = data?.shareScheduleTodayOnly === true; const sharedPersonLabel = data?.takenBy === "all" ? t("share.allPeople") : (data?.takenBy ?? ""); const pageTitle = showMedicationOverview ? `๐Ÿ’Š ${t("sharedOverview.title", { person: sharedPersonLabel })}` : `๐Ÿ’Š ${t("share.scheduleFor")} ${sharedPersonLabel}`; const renderDoseUsage = ( med: SharedScheduleData["medications"][number] | undefined, dose: { usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null } ) => formatDoseUsageLabel(med, dose.usage, dose.intakeUnit); // Helper: check if a dose is "done" (taken, per-dose dismissed, or med-level dismissed) function isDoseIdDone(doseId: string): boolean { if (isDoseTakenForDisplay(doseId)) return true; if (dismissedDoses.has(doseId)) return true; const parts = doseId.split("-"); if (parts.length >= 3) { const medId = parts[0]; const med = data?.medications.find((m) => String(m.id) === medId); if (med) { if (isDoseDismissed(doseId, med.dismissedUntil ?? undefined)) { return true; } } } return false; } // Missed past dose IDs โ€” matches DashboardPage's missedPastDoseIds logic const missedPastDoseIds = useMemo(() => { const allPastDoseIds = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id))); return allPastDoseIds.filter((id) => !isDoseIdDone(id)); }, [pastDays, isDoseIdDone]); if (loading) { return (

๐Ÿ’Š MedAssist-ng

{t("common.loading")}

{t("common.loading")}
); } if (expiredData) { const expiredPersonLabel = expiredData.takenBy === "all" ? t("share.allPeople") : expiredData.takenBy; return (

๐Ÿ’Š MedAssist-ng

โฐ

{t("share.expired.title")}

{t("share.expired.message", { takenBy: expiredPersonLabel })}

{t("share.expired.contact", { username: expiredData.ownerUsername })}

{t("share.expired.expiredOn", { date: new Date(expiredData.expiredAt).toLocaleDateString(getSystemLocale(i18n.language)), })}

); } if (error || !data) { return (

๐Ÿ’Š MedAssist-ng

{error || "Unknown error"}

); } return (

{pageTitle}

{!showOnlyToday && (() => { const periodLabel = data.scheduleDays === 30 ? t("dashboard.schedules.1month") : data.scheduleDays === 90 ? t("dashboard.schedules.3months") : t("dashboard.schedules.6months"); return (

{t("share.period")}: {periodLabel}

); })()}
{showMedicationOverview ? ( ) : null}

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

{schedule.length === 0 ? (

{t("share.noSchedule")}

) : ( <> {/* 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.map((d) => d.id)); // Really taken = all doses marked as taken by human (for green "All taken") 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) => { const med = data.medications.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; if (isDoseTakenForDisplay(d.id) || dismissedDoses.has(d.id)) return doseCount; return doseCount + 1; }, 0) ); }, 0); const hasRealMissed = missedNotDismissedCount > 0; const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr); const isCollapsed = !isManuallyExpanded; const pastMissedClass = allDoseIds.length > 0 ? "past-missed" : ""; return (
toggleDayCollapse(day.dateStr, true)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, true); }} title={isCollapsed ? t("common.expand") : t("common.collapse")} > {isCollapsed ? "โ–ถ" : "โ–ผ"} {day.dateStr} {allReallyTaken ? ( โœ“ {t("dashboard.schedules.allTaken")} ) : ( <> {hasRealMissed && ( โš ๏ธ )} {takenCount}/{allDoseIds.length} )}
{!isCollapsed && day.meds.map((item) => { const med = data.medications.find((m) => getMedDisplayName(m) === item.medName); const medCoverage = coverageByMed[item.medName]; const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false; return (
med?.imageUrl && openLightbox(med.imageUrl, getMedDisplayName(med)) } onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { if (med?.imageUrl) openLightbox(med.imageUrl, getMedDisplayName(med)); } }} >
{item.medName} {med?.genericName && ( {med.genericName} )}
{formatTotalUsageLabel(med, item.total, item.doses)}
{item.doses.map((dose) => { const isTaken = isDoseTakenForDisplay(dose.id); const isAutomaticallyTaken = isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now(); return (
{dose.timeStr} {renderDoseUsage(med, dose)} {allowsPillFormSelection(med?.packageType) && med?.pillWeightMg && ( {`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`} )}
{dose.takenBy && {dose.takenBy}} {isTaken ? ( ) : ( )}
); })}
); })}
); })} {/* Past days toggle */} {!showOnlyToday && pastDays.length > 0 && (() => { const missedCount = missedPastDoseIds.length; const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id)) ); 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 === " ") setShowPastDays(!showPastDays); }} > {showPastDays ? "โ–ผ" : "โ–ถ"} {showPastDays ? t("dashboard.schedules.hidePastDays") : t("dashboard.schedules.showPastDays")} ({t("dashboard.schedules.pastDaysCount", { count: pastDays.length })}) {missedCount > 0 ? ( โš ๏ธ {missedCount} ) : totalPastDoses.length > 0 ? ( โœ“ ) : null}
); })()} {/* Today (always visible) */} {todayDay && (() => { const day = todayDay; const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id)); const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => isDoseTakenForDisplay(id)); const takenCount = allDoseIds.filter((id) => isDoseTakenForDisplay(id)).length; const hasAutomaticTakenDose = allDoseIds.some((id) => isDoseTakenAutomatically(id)); // Today: only collapse if manually collapsed or all taken const isAutoCollapsed = allDayTaken && !hasAutomaticTakenDose; 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 med = data.medications.find((m) => getMedDisplayName(m) === item.medName); const medCoverage = coverageByMed[item.medName]; const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false; return (
med?.imageUrl && openLightbox(med.imageUrl, getMedDisplayName(med)) } onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { if (med?.imageUrl) openLightbox(med.imageUrl, getMedDisplayName(med)); } }} >
{item.medName} {med?.genericName && ( {med.genericName} )}
{formatTotalUsageLabel(med, item.total, item.doses)}
{item.doses.map((dose) => { const isTaken = isDoseTakenForDisplay(dose.id); const isAutomaticallyTaken = isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now(); const isOverdue = dose.when < Date.now() && !isTaken; return (
{dose.timeStr} {renderDoseUsage(med, dose)} {allowsPillFormSelection(med?.packageType) && med?.pillWeightMg && ( {`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`} )}
{dose.takenBy && {dose.takenBy}} {isTaken ? ( ) : ( )}
); })}
); })}
); })()} {/* Future days toggle โ€” identical to DashboardPage */} {!showOnlyToday && futureDays.length > 0 && (() => { const totalFutureDoses = futureDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id)) ); const takenFutureDoses = totalFutureDoses.filter((id) => isDoseTakenForDisplay(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} )}
); })()} {/* Future days (when expanded) โ€” identical to DashboardPage */} {!showOnlyToday && showFutureDays && futureDays.map((day) => { const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id)); const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => isDoseTakenForDisplay(id)); const takenCount = allDoseIds.filter((id) => isDoseTakenForDisplay(id)).length; // Future days: collapsed by default, manual override to expand 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 med = data.medications.find((m) => getMedDisplayName(m) === item.medName); const medCoverage = coverageByMed[item.medName]; const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false; return (
med?.imageUrl && openLightbox(med.imageUrl, getMedDisplayName(med)) } onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { if (med?.imageUrl) openLightbox(med.imageUrl, getMedDisplayName(med)); } }} >
{item.medName} {med?.genericName && ( {med.genericName} )}
{formatTotalUsageLabel(med, item.total, item.doses)}
{item.doses.map((dose) => { const isTaken = isDoseTakenForDisplay(dose.id); const isAutomaticallyTaken = isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now(); return (
{dose.timeStr} {renderDoseUsage(med, dose)} {allowsPillFormSelection(med?.packageType) && med?.pillWeightMg && ( {`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`} )}
{dose.takenBy && {dose.takenBy}} {isTaken ? ( ) : ( )}
); })}
); })}
); })} )}

{t("share.generatedBy")}{" "} {data?.sharedBy && ( <> {data.sharedBy} ยท{" "} )} MedAssist

{/* Image Lightbox */} {lightboxImage && (
{ if (e.key !== "Escape") e.stopPropagation(); }} > {lightboxImage.name} e.stopPropagation()} onKeyDown={(e) => { if (e.key !== "Escape") e.stopPropagation(); }} />
)}
); }