// ============================================================================= // 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 { NotebookPen } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useParams } from "react-router-dom"; import { useFeedback } from "../context/FeedbackContext"; import { ScheduleUsageTag } from "../features/schedule/components"; import { formatScheduleDoseUsageLabel, formatScheduleTotalUsageLabel } from "../features/schedule/formatters"; import { toggleDateInSet } from "../features/schedule/interactions"; import { loadScheduleCollapseState, saveCollapsedDaySet } from "../features/schedule/storage"; import { useEscapeKey, useModalHistory } from "../hooks"; import type { IntakeJournalEntry } from "../hooks/useIntakeJournal"; import type { ExpiredLinkData, SharedScheduleData } from "../types"; import { allowsPillFormSelection, getMedDisplayName, getMedTotal, type IntakeUnit, isLiquidContainerPackageType, isTubePackageType, type StockThresholds, } from "../types"; import { getSystemLocale } from "../utils/formatters"; import { getIntakeDailyRate, getMedicationIntakes, iterateIntakeOccurrences } from "../utils/intake-schedule"; import { convertLiquidUsageToMl } from "../utils/intake-units"; import { getStockStatus, isDoseDismissed, parseLocalDateTime } from "../utils/schedule"; import { IntakeJournalModal } from "./intake-journal/IntakeJournalModal"; import { MedicationAvatar } from "./MedicationAvatar"; import { SharedMedicationOverviewSection } from "./SharedMedicationOverviewSection"; async function readSharedJournalError(response: Response, fallbackMessage: string): Promise { try { const data = (await response.json()) as { error?: string; code?: string }; if (typeof data.error === "string" && data.error.trim().length > 0) { return data.error; } if (typeof data.code === "string" && data.code.trim().length > 0) { return data.code; } } catch { // Fall back to the supplied message when the response body is not JSON. } return fallbackMessage; } export function SharedSchedule() { const { token } = useParams<{ token: string }>(); const { t, i18n } = useTranslation(); const { showFeedback } = useFeedback(); 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 [sharedJournalDoseIdsWithNotes, setSharedJournalDoseIdsWithNotes] = useState>(new Set()); const mutationInFlightRef = useRef(0); const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null); const [sharedJournalOpen, setSharedJournalOpen] = useState(false); const [sharedJournalDoseId, setSharedJournalDoseId] = useState(null); const [sharedJournalEntry, setSharedJournalEntry] = useState(null); const [sharedJournalLoading, setSharedJournalLoading] = useState(false); const [sharedJournalSaving, setSharedJournalSaving] = useState(false); const [sharedJournalError, setSharedJournalError] = useState(null); const [showPastDays, setShowPastDays] = useState(false); const [showFutureDays, setShowFutureDays] = useState(false); const isLiquidContainerMed = (med: SharedScheduleData["medications"][number] | undefined) => isLiquidContainerPackageType(med?.packageType); const convertUsageForStock = ( usage: number, med: SharedScheduleData["medications"][number] | undefined, unit: IntakeUnit | null | undefined ): number => { if (isTubePackageType(med?.packageType)) return 0; if (!isLiquidContainerMed(med)) return usage; return convertLiquidUsageToMl(usage, unit); }; const formatDoseUsageLabel = ( med: SharedScheduleData["medications"][number] | undefined, usage: number, intakeUnit?: IntakeUnit | null ) => formatScheduleDoseUsageLabel(med, usage, t, intakeUnit); const formatTotalUsageLabel = ( med: SharedScheduleData["medications"][number] | undefined, total: number, doses?: Array<{ usage: number; intakeUnit?: IntakeUnit | null }> ) => formatScheduleTotalUsageLabel(med, total, t, doses); // 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 } = loadScheduleCollapseState( `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 = toggleDateInSet(prev, dateStr); if (token) saveCollapsedDaySet(`share_${token}_expandedDays`, next); return next; }); } else { setManuallyCollapsedDays((prev) => { const next = toggleDateInSet(prev, dateStr); if (token) saveCollapsedDaySet(`share_${token}_collapsedDays`, 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); const closeSharedJournalEditor = useCallback(() => { setSharedJournalOpen(false); setSharedJournalDoseId(null); setSharedJournalEntry(null); setSharedJournalLoading(false); setSharedJournalSaving(false); setSharedJournalError(null); }, []); useModalHistory(sharedJournalOpen, "shared-intake-journal", closeSharedJournalEditor); const openSharedJournalEditor = useCallback( async (doseId: string) => { if (!token || !data?.allowJournalNotes) { return; } setSharedJournalOpen(true); setSharedJournalDoseId(doseId); setSharedJournalEntry(null); setSharedJournalLoading(true); setSharedJournalError(null); try { const response = await fetch(`/api/share/${token}/journal/event/${encodeURIComponent(doseId)}`); if (!response.ok) { setSharedJournalEntry(null); setSharedJournalError(await readSharedJournalError(response, t("journal.errors.loadFailed"))); return; } const payload = (await response.json()) as { entry: IntakeJournalEntry }; setSharedJournalEntry(payload.entry); setSharedJournalDoseIdsWithNotes((current) => { const next = new Set(current); if (payload.entry.note?.trim()) { next.add(payload.entry.doseId); } else { next.delete(payload.entry.doseId); } return next; }); } catch { setSharedJournalEntry(null); setSharedJournalError(t("journal.errors.loadFailed")); } finally { setSharedJournalLoading(false); } }, [data?.allowJournalNotes, t, token] ); const saveSharedJournalNote = useCallback( async (note: string) => { if (!token || !sharedJournalDoseId) { setSharedJournalError(t("journal.errors.noEventSelected")); return false; } if (note.trim().length === 0) { setSharedJournalError(t("journal.errors.emptySharedNote")); return false; } setSharedJournalSaving(true); setSharedJournalError(null); try { const response = await fetch(`/api/share/${token}/journal/event/${encodeURIComponent(sharedJournalDoseId)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ note }), }); if (!response.ok) { setSharedJournalError(await readSharedJournalError(response, t("journal.errors.saveFailed"))); return false; } const payload = (await response.json()) as { entry: IntakeJournalEntry }; setSharedJournalEntry(payload.entry); setSharedJournalDoseIdsWithNotes((current) => { const next = new Set(current); if (payload.entry.note?.trim()) { next.add(payload.entry.doseId); } else { next.delete(payload.entry.doseId); } return next; }); return true; } catch { setSharedJournalError(t("journal.errors.saveFailed")); return false; } finally { setSharedJournalSaving(false); } }, [sharedJournalDoseId, t, token] ); // 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; if (mutationInFlightRef.current > 0) return; try { const res = await fetch(`/api/share/${token}/doses`); if (res.ok) { if (mutationInFlightRef.current > 0) return; const data = await res.json(); const taken = new Set(); const automatic = new Set(); const dismissed = new Set(); const journalDoseIds = new Set(); for (const d of data.doses as Array<{ doseId: string; dismissed?: boolean; skipped?: boolean; takenSource?: string; hasJournalNote?: boolean; }>) { if (d.skipped === true || d.dismissed === true) { dismissed.add(d.doseId); } else { taken.add(d.doseId); if (d.takenSource === "automatic") { automatic.add(d.doseId); } } if (d.hasJournalNote === true) { journalDoseIds.add(d.doseId); } } setTakenDoses(taken); setAutomaticTakenDoses(automatic); setDismissedDoses(dismissed); setSharedJournalDoseIdsWithNotes(journalDoseIds); } } catch { // Keep the current optimistic/shared state on transient read errors. } }, [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) { const wasTaken = takenDoses.has(doseId); const wasSkipped = dismissedDoses.has(doseId); const wasAutomatic = automaticTakenDoses.has(doseId); // Optimistic update mutationInFlightRef.current++; setTakenDoses((prev) => { const next = new Set(prev); next.add(doseId); return next; }); setDismissedDoses((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 { 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") { showFeedback({ message: t("common.outOfStockTakeBlocked"), tone: "error" }); } } 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); if (wasTaken) { next.add(doseId); } else { next.delete(doseId); } return next; }); setDismissedDoses((prev) => { const next = new Set(prev); if (wasSkipped) { next.add(doseId); } else { next.delete(doseId); } return next; }); setAutomaticTakenDoses((prev) => { const next = new Set(prev); if (wasAutomatic) { next.add(doseId); } return next; }); } finally { mutationInFlightRef.current--; loadTakenDoses(); } } async function markDoseSkipped(doseId: string) { if (takenDoses.has(doseId)) { return; } const wasTaken = takenDoses.has(doseId); const wasSkipped = dismissedDoses.has(doseId); const wasAutomatic = automaticTakenDoses.has(doseId); mutationInFlightRef.current++; setDismissedDoses((prev) => { const next = new Set(prev); next.add(doseId); return next; }); setTakenDoses((prev) => { const next = new Set(prev); next.delete(doseId); return next; }); setAutomaticTakenDoses((prev) => { const next = new Set(prev); next.delete(doseId); return next; }); try { const response = await fetch(`/api/share/${token}/doses/skip`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ doseId }), }); if (!response.ok) { throw new Error("Failed to mark shared dose as skipped"); } } catch { setDismissedDoses((prev) => { const next = new Set(prev); if (wasSkipped) { next.add(doseId); } else { next.delete(doseId); } return next; }); setTakenDoses((prev) => { const next = new Set(prev); if (wasTaken) { next.add(doseId); } return next; }); setAutomaticTakenDoses((prev) => { const next = new Set(prev); if (wasAutomatic) { next.add(doseId); } return next; }); } finally { mutationInFlightRef.current--; loadTakenDoses(); } } async function undoDoseTaken(doseId: string) { const wasAutomatic = automaticTakenDoses.has(doseId); // Optimistic update mutationInFlightRef.current++; 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; }); setAutomaticTakenDoses((prev) => { const next = new Set(prev); if (wasAutomatic) { next.add(doseId); } return next; }); } finally { mutationInFlightRef.current--; loadTakenDoses(); } } async function undoDoseSkipped(doseId: string) { const wasSkipped = dismissedDoses.has(doseId); mutationInFlightRef.current++; setDismissedDoses((prev) => { const next = new Set(prev); next.delete(doseId); return next; }); try { await fetch(`/api/share/${token}/doses/skip/${encodeURIComponent(doseId)}`, { method: "DELETE", }); } catch { setDismissedDoses((prev) => { const next = new Set(prev); if (wasSkipped) { next.add(doseId); } return next; }); } finally { mutationInFlightRef.current--; loadTakenDoses(); } } const renderDoseActionButtons = (options: { doseId: string; isTaken: boolean; isSkipped: boolean; isAutomaticallyTaken: boolean; isEmpty: boolean; }) => { const showSharedJournalAction = Boolean(data?.allowJournalNotes); const canOpenSharedJournal = showSharedJournalAction && (options.isTaken || options.isSkipped); const hasSharedJournalNote = sharedJournalDoseIdsWithNotes.has(options.doseId); const takeButton = options.isTaken ? ( ) : ( ); const skipButton = options.isSkipped ? ( ) : ( ); const journalButton = showSharedJournalAction ? ( ) : null; return ( <> {takeButton} {skipButton} {journalButton} ); }; 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]); function buildGroupedSchedule() { 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?: IntakeUnit | null; timeStr: string; isPast: boolean; takenBy: string | null; // Per-intake takenBy (single person or null) dateStr: string; }[] = []; for (const med of data.medications) { const intakes = getMedicationIntakes(med); 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; iterateIntakeOccurrences(intake, startDate, end, (d) => { 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()), })); } // Visible schedule respects share-person filtering. const schedule = useMemo(() => { return buildGroupedSchedule(); }, [data, i18n.language]); // Split into past, today, and future - matches main app logic const pastDays = useMemo(() => { const visiblePastDays = Math.max(1, data?.scheduleDays ?? 30); return schedule.filter((d) => d.isPast).slice(-visiblePastDays); }, [schedule, data?.scheduleDays]); // 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 now = Date.now(); const calcMode = data.stockCalculationMode ?? "automatic"; const coverage: Record = {}; for (const med of data.medications) { const intakes = getMedicationIntakes(med); // 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 = usageForStock * getIntakeDailyRate(intake); 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 intakeStart = parseLocalDateTime(intake.start); if (Number.isNaN(intakeStart.getTime())) 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; iterateIntakeOccurrences(intake, intakeStart, new Date(now), (occurrence) => { if (occurrence.getTime() <= stockCorrectionCutoff) return; timeBasedConsumed += usageForStock * peopleForThisIntake.length; lastAutoConsumedDateMs = new Date( occurrence.getFullYear(), occurrence.getMonth(), occurrence.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 sharedStockThresholds = useMemo(() => { if (!data?.stockThresholds) return null; return { lowStockDays: data.stockThresholds.lowStockDays, normalStockDays: data.stockThresholds.normalStockDays ?? data.stockThresholds.lowStockDays, highStockDays: data.stockThresholds.highStockDays ?? Math.max( (data.stockThresholds.normalStockDays ?? data.stockThresholds.lowStockDays) + 1, data.stockThresholds.lowStockDays + 1 ), criticalStockDays: data.stockThresholds.reminderDaysBefore ?? Math.max(1, Math.ceil(data.stockThresholds.lowStockDays / 2)), expiryWarningDays: data.stockThresholds.expiryWarningDays ?? 30, }; }, [data?.stockThresholds]); const medicationOverviewByName = useMemo(() => { const overview = new Map[number]>(); for (const item of data?.medicationOverview ?? []) { overview.set(item.name, item); } return overview; }, [data?.medicationOverview]); const emptyByOverviewName = useMemo(() => { const emptyNames = new Set(); for (const item of data?.medicationOverview ?? []) { if ((item.currentStock ?? 0) <= 0) { emptyNames.add(item.name); } } return emptyNames; }, [data?.medicationOverview]); const isDoseTakenForDisplay = useCallback((doseId: string) => takenDoses.has(doseId), [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?: IntakeUnit | 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}

{t("share.publicAccessHelp")}

{!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 = emptyByOverviewName.has(item.medName) || (medCoverage ? medCoverage.medsLeft <= 0 : false); const medOverview = medicationOverviewByName.get(item.medName); let stockStatus = null; if (!isEmpty && sharedStockThresholds) { if (medOverview && medOverview.currentStock !== null) { stockStatus = getStockStatus( medOverview.daysLeft, medOverview.currentStock, sharedStockThresholds, med?.packageType ); } else if (medCoverage) { stockStatus = getStockStatus( medCoverage.daysLeft, medCoverage.medsLeft, sharedStockThresholds, med?.packageType ); } } const isLowStock = stockStatus?.className === "warning"; const rowClasses = ["time-row"]; if (isEmpty) rowClasses.push("med-empty"); else if (isLowStock) rowClasses.push("med-low"); 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)} {isLowStock && {t("status.lowStock")}}
{item.doses.map((dose) => { const isTaken = isDoseTakenForDisplay(dose.id); const isAutomaticallyTaken = isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now(); const isSkipped = dismissedDoses.has(dose.id); const doseClasses = ["dose-item", "past"]; if (isTaken) doseClasses.push("all-taken"); if (isEmpty) doseClasses.push("med-empty"); else if (isLowStock) doseClasses.push("med-low"); return (
{dose.timeStr} {renderDoseUsage(med, dose)} {allowsPillFormSelection(med?.packageType) && med?.pillWeightMg && ( {`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`} )}
{dose.takenBy && {dose.takenBy}} {renderDoseActionButtons({ doseId: dose.id, isTaken, isSkipped, isAutomaticallyTaken, isEmpty, })}
); })}
); })}
); })} {/* 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 && !data.allowJournalNotes; 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 = emptyByOverviewName.has(item.medName) || (medCoverage ? medCoverage.medsLeft <= 0 : false); const medOverview = medicationOverviewByName.get(item.medName); let stockStatus = null; if (!isEmpty && sharedStockThresholds) { if (medOverview && medOverview.currentStock !== null) { stockStatus = getStockStatus( medOverview.daysLeft, medOverview.currentStock, sharedStockThresholds, med?.packageType ); } else if (medCoverage) { stockStatus = getStockStatus( medCoverage.daysLeft, medCoverage.medsLeft, sharedStockThresholds, med?.packageType ); } } const isLowStock = stockStatus?.className === "warning"; const rowClasses = ["time-row"]; if (isEmpty) rowClasses.push("med-empty"); else if (isLowStock) rowClasses.push("med-low"); 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)} {isLowStock && {t("status.lowStock")}}
{item.doses.map((dose) => { const isTaken = isDoseTakenForDisplay(dose.id); const isAutomaticallyTaken = isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now(); const isSkipped = dismissedDoses.has(dose.id); const isOverdue = dose.when < Date.now() && !isTaken && !isSkipped && !isEmpty; const doseClasses = ["dose-item"]; if (isOverdue) doseClasses.push("overdue"); if (isTaken) doseClasses.push("all-taken"); if (isEmpty) doseClasses.push("med-empty"); else if (isLowStock) doseClasses.push("med-low"); return (
{dose.timeStr} {renderDoseUsage(med, dose)} {allowsPillFormSelection(med?.packageType) && med?.pillWeightMg && ( {`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`} )}
{dose.takenBy && {dose.takenBy}} {renderDoseActionButtons({ doseId: dose.id, isTaken, isSkipped, isAutomaticallyTaken, isEmpty, })}
); })}
); })}
); })()} {/* 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 = emptyByOverviewName.has(item.medName) || (medCoverage ? medCoverage.medsLeft <= 0 : false); const medOverview = medicationOverviewByName.get(item.medName); let stockStatus = null; if (!isEmpty && sharedStockThresholds) { if (medOverview && medOverview.currentStock !== null) { stockStatus = getStockStatus( medOverview.daysLeft, medOverview.currentStock, sharedStockThresholds, med?.packageType ); } else if (medCoverage) { stockStatus = getStockStatus( medCoverage.daysLeft, medCoverage.medsLeft, sharedStockThresholds, med?.packageType ); } } const isLowStock = stockStatus?.className === "warning"; const rowClasses = ["time-row"]; if (isEmpty) rowClasses.push("med-empty"); else if (isLowStock) rowClasses.push("med-low"); 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)} {isLowStock && {t("status.lowStock")}}
{item.doses.map((dose) => { const isTaken = isDoseTakenForDisplay(dose.id); const isAutomaticallyTaken = isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now(); const isSkipped = dismissedDoses.has(dose.id); const doseClasses = ["dose-item", "future"]; if (isTaken) doseClasses.push("all-taken"); if (isEmpty) doseClasses.push("med-empty"); else if (isLowStock) doseClasses.push("med-low"); return (
{dose.timeStr} {renderDoseUsage(med, dose)} {allowsPillFormSelection(med?.packageType) && med?.pillWeightMg && ( {`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`} )}
{dose.takenBy && {dose.takenBy}} {renderDoseActionButtons({ doseId: dose.id, isTaken, isSkipped, isAutomaticallyTaken, isEmpty: true, })}
); })}
); })}
); })} )}

{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(); }} />
)} undefined} allowDelete={false} />
); }