1592 lines
58 KiB
TypeScript
1592 lines
58 KiB
TypeScript
// =============================================================================
|
||
// 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 { 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 } from "../hooks";
|
||
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 { MedicationAvatar } from "./MedicationAvatar";
|
||
import { SharedMedicationOverviewSection } from "./SharedMedicationOverviewSection";
|
||
|
||
export function SharedSchedule() {
|
||
const { token } = useParams<{ token: string }>();
|
||
const { t, i18n } = useTranslation();
|
||
const [data, setData] = useState<SharedScheduleData | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [expiredData, setExpiredData] = useState<ExpiredLinkData | null>(null);
|
||
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
||
const [automaticTakenDoses, setAutomaticTakenDoses] = useState<Set<string>>(new Set());
|
||
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
|
||
const mutationInFlightRef = useRef(0);
|
||
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 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<ThemePreference>(() => {
|
||
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<HTMLDivElement>(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<Set<string>>(new Set());
|
||
const [manuallyExpandedDays, setManuallyExpandedDays] = useState<Set<string>>(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);
|
||
|
||
// 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<string>();
|
||
const automatic = new Set<string>();
|
||
const dismissed = new Set<string>();
|
||
for (const d of data.doses as Array<{
|
||
doseId: string;
|
||
dismissed?: boolean;
|
||
skipped?: boolean;
|
||
takenSource?: string;
|
||
}>) {
|
||
if (d.skipped === true || d.dismissed === true) {
|
||
dismissed.add(d.doseId);
|
||
} else {
|
||
taken.add(d.doseId);
|
||
if (d.takenSource === "automatic") {
|
||
automatic.add(d.doseId);
|
||
}
|
||
}
|
||
}
|
||
setTakenDoses(taken);
|
||
setAutomaticTakenDoses(automatic);
|
||
setDismissedDoses(dismissed);
|
||
}
|
||
} 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) {
|
||
if (dismissedDoses.has(doseId)) {
|
||
return;
|
||
}
|
||
|
||
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") {
|
||
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);
|
||
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 takeButton = options.isTaken ? (
|
||
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
|
||
{options.isAutomaticallyTaken && (
|
||
<span className="info-tooltip" data-tooltip={t("tooltips.automaticTaken")}>
|
||
🤖
|
||
</span>
|
||
)}
|
||
<span className="dose-btn-label">{t("common.undo")}</span>
|
||
<span aria-hidden="true">↩</span>
|
||
</button>
|
||
) : (
|
||
<button
|
||
className={`dose-btn take${options.isEmpty ? " out-of-stock" : ""}`}
|
||
onClick={() => markDoseTaken(options.doseId)}
|
||
disabled={options.isEmpty || options.isSkipped}
|
||
title={options.isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")}
|
||
>
|
||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||
<span aria-hidden="true">{options.isEmpty ? "⊘" : "✓"}</span>
|
||
</button>
|
||
);
|
||
|
||
const skipButton = options.isSkipped ? (
|
||
<button className="dose-btn undo skip" onClick={() => undoDoseSkipped(options.doseId)} title={t("common.undo")}>
|
||
<span className="dose-btn-label">{t("common.undo")}</span>
|
||
<span aria-hidden="true">↩</span>
|
||
</button>
|
||
) : (
|
||
<button
|
||
className="dose-btn skip"
|
||
onClick={() => markDoseSkipped(options.doseId)}
|
||
title={t("dose.markAsSkipped")}
|
||
disabled={options.isTaken}
|
||
>
|
||
<span className="dose-btn-label">{t("dose.skip")}</span>
|
||
</button>
|
||
);
|
||
|
||
return (
|
||
<>
|
||
{takeButton}
|
||
{skipButton}
|
||
</>
|
||
);
|
||
};
|
||
|
||
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<string, { medName: string; total: number; doses: DoseInfo[]; lastWhen: number }>;
|
||
}
|
||
>();
|
||
|
||
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(() => 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 now = Date.now();
|
||
const calcMode = data.stockCalculationMode ?? "automatic";
|
||
const coverage: Record<string, { daysLeft: number | null; medsLeft: number; dailyUsage: number }> = {};
|
||
|
||
for (const med of data.medications) {
|
||
const intakes = getMedicationIntakes(med);
|
||
|
||
// Count unique people from all intakes (for per-intake takenBy)
|
||
const uniquePeople = new Set<string>();
|
||
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<StockThresholds | null>(() => {
|
||
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<string, NonNullable<SharedScheduleData["medicationOverview"]>[number]>();
|
||
for (const item of data?.medicationOverview ?? []) {
|
||
overview.set(item.name, item);
|
||
}
|
||
return overview;
|
||
}, [data?.medicationOverview]);
|
||
|
||
const emptyByOverviewName = useMemo(() => {
|
||
const emptyNames = new Set<string>();
|
||
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 (
|
||
<div className="auth-container" data-theme={resolvedTheme}>
|
||
<div className="auth-card" style={{ textAlign: "center" }} aria-busy="true">
|
||
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
||
<p>{t("common.loading")}</p>
|
||
<span className="screen-reader-only">{t("common.loading")}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (expiredData) {
|
||
const expiredPersonLabel = expiredData.takenBy === "all" ? t("share.allPeople") : expiredData.takenBy;
|
||
return (
|
||
<div className="shared-schedule-page">
|
||
<div className="shared-schedule-error expired">
|
||
<h1>💊 MedAssist-ng</h1>
|
||
<div className="expired-icon">⏰</div>
|
||
<h2>{t("share.expired.title")}</h2>
|
||
<p className="expired-message">{t("share.expired.message", { takenBy: expiredPersonLabel })}</p>
|
||
<p className="expired-contact">{t("share.expired.contact", { username: expiredData.ownerUsername })}</p>
|
||
<p className="expired-date">
|
||
{t("share.expired.expiredOn", {
|
||
date: new Date(expiredData.expiredAt).toLocaleDateString(getSystemLocale(i18n.language)),
|
||
})}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error || !data) {
|
||
return (
|
||
<div className="shared-schedule-page">
|
||
<div className="shared-schedule-error">
|
||
<h1>💊 MedAssist-ng</h1>
|
||
<p className="error-message">{error || "Unknown error"}</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="shared-schedule-page">
|
||
<div className="shared-schedule-container">
|
||
<header className="shared-schedule-header">
|
||
<h1>{pageTitle}</h1>
|
||
<div className="shared-schedule-header-actions">
|
||
<div className={`theme-menu ${themeMenuOpen ? "open" : ""}`} ref={themeMenuRef}>
|
||
<button className="icon-btn" onClick={() => setThemeMenuOpen(!themeMenuOpen)} title={t("theme.title")}>
|
||
{resolvedTheme === "dark" ? "🌙" : "☀️"}
|
||
</button>
|
||
<div className="theme-dropdown">
|
||
<button
|
||
className={`theme-dropdown-item${themePreference === "light" ? " active" : ""}`}
|
||
onClick={() => {
|
||
setThemePreference("light");
|
||
setThemeMenuOpen(false);
|
||
}}
|
||
>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<circle cx="12" cy="12" r="5" />
|
||
<line x1="12" y1="1" x2="12" y2="3" />
|
||
<line x1="12" y1="21" x2="12" y2="23" />
|
||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||
<line x1="1" y1="12" x2="3" y2="12" />
|
||
<line x1="21" y1="12" x2="23" y2="12" />
|
||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
||
</svg>
|
||
{t("theme.light")}
|
||
{themePreference === "light" && <span className="theme-check">✓</span>}
|
||
</button>
|
||
<button
|
||
className={`theme-dropdown-item${themePreference === "dark" ? " active" : ""}`}
|
||
onClick={() => {
|
||
setThemePreference("dark");
|
||
setThemeMenuOpen(false);
|
||
}}
|
||
>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||
</svg>
|
||
{t("theme.dark")}
|
||
{themePreference === "dark" && <span className="theme-check">✓</span>}
|
||
</button>
|
||
<button
|
||
className={`theme-dropdown-item${themePreference === "system" ? " active" : ""}`}
|
||
onClick={() => {
|
||
setThemePreference("system");
|
||
setThemeMenuOpen(false);
|
||
}}
|
||
>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
||
<line x1="8" y1="21" x2="16" y2="21" />
|
||
<line x1="12" y1="17" x2="12" y2="21" />
|
||
</svg>
|
||
{t("theme.system")}
|
||
{themePreference === "system" && <span className="theme-check">✓</span>}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{!showOnlyToday &&
|
||
(() => {
|
||
const periodLabel =
|
||
data.scheduleDays === 30
|
||
? t("dashboard.schedules.1month")
|
||
: data.scheduleDays === 90
|
||
? t("dashboard.schedules.3months")
|
||
: t("dashboard.schedules.6months");
|
||
return (
|
||
<p className="shared-schedule-period">
|
||
{t("share.period")}: {periodLabel}
|
||
</p>
|
||
);
|
||
})()}
|
||
</header>
|
||
|
||
{showMedicationOverview ? (
|
||
<SharedMedicationOverviewSection
|
||
takenBy={sharedPersonLabel}
|
||
sharedBy={data.sharedBy}
|
||
medications={data.medicationOverview ?? []}
|
||
showTitle={false}
|
||
onMedicationImageClick={openLightbox}
|
||
/>
|
||
) : null}
|
||
|
||
<section className="shared-schedule-section" aria-label={t("dashboard.schedules.title")}>
|
||
<div className="shared-overview-section-header">
|
||
<h2>{t("dashboard.schedules.title")}</h2>
|
||
</div>
|
||
<div className="timeline">
|
||
{schedule.length === 0 ? (
|
||
<p className="shared-schedule-empty">{t("share.noSchedule")}</p>
|
||
) : (
|
||
<>
|
||
{/* 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 (
|
||
<div
|
||
key={day.dateStr}
|
||
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allReallyTaken ? "all-taken" : pastMissedClass}`}
|
||
>
|
||
<div
|
||
className="day-divider clickable"
|
||
onClick={() => toggleDayCollapse(day.dateStr, true)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, true);
|
||
}}
|
||
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
||
>
|
||
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
||
<span className="day-date">{day.dateStr}</span>
|
||
<span className="day-summary">
|
||
{allReallyTaken ? (
|
||
<span className="day-complete">✓ {t("dashboard.schedules.allTaken")}</span>
|
||
) : (
|
||
<>
|
||
{hasRealMissed && (
|
||
<span
|
||
className="day-warning"
|
||
title={t("dashboard.schedules.missedDoses", { count: missedNotDismissedCount })}
|
||
>
|
||
⚠️
|
||
</span>
|
||
)}
|
||
<span className="day-progress">
|
||
{takenCount}/{allDoseIds.length}
|
||
</span>
|
||
</>
|
||
)}
|
||
</span>
|
||
</div>
|
||
{!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 (
|
||
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
|
||
<div className="time-main">
|
||
<div className="med-name">
|
||
<div
|
||
className={med?.imageUrl ? "med-avatar clickable" : ""}
|
||
onClick={() =>
|
||
med?.imageUrl && openLightbox(med.imageUrl, getMedDisplayName(med))
|
||
}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
if (med?.imageUrl) openLightbox(med.imageUrl, getMedDisplayName(med));
|
||
}
|
||
}}
|
||
>
|
||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||
</div>
|
||
<div className="med-name-stack">
|
||
<span className="med-name-text">{item.medName}</span>
|
||
{med?.genericName && (
|
||
<span className="med-generic-inline">{med.genericName}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="tag-row">
|
||
<ScheduleUsageTag>
|
||
{formatTotalUsageLabel(med, item.total, item.doses)}
|
||
</ScheduleUsageTag>
|
||
{isLowStock && <span className="tag warning">{t("status.lowStock")}</span>}
|
||
</div>
|
||
</div>
|
||
<div className="doses-col">
|
||
{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 (
|
||
<div key={dose.id} className={doseClasses.join(" ")}>
|
||
<span className="dose-time">{dose.timeStr}</span>
|
||
<span className="dose-usage">
|
||
<span className="dose-usage-main">{renderDoseUsage(med, dose)}</span>
|
||
{allowsPillFormSelection(med?.packageType) && med?.pillWeightMg && (
|
||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||
)}
|
||
</span>
|
||
<div className="dose-checks">
|
||
<div
|
||
className={`dose-person ${isTaken ? "taken" : ""} ${isSkipped ? "skipped" : ""}`}
|
||
>
|
||
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
|
||
{renderDoseActionButtons({
|
||
doseId: dose.id,
|
||
isTaken,
|
||
isSkipped,
|
||
isAutomaticallyTaken,
|
||
isEmpty,
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
})}
|
||
{/* 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 (
|
||
<div className="past-days-header">
|
||
<div
|
||
className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedCount > 0 ? "has-missed" : ""}`}
|
||
onClick={() => {
|
||
const wasCollapsed = !showPastDays;
|
||
setShowPastDays(!showPastDays);
|
||
if (wasCollapsed) {
|
||
setTimeout(() => {
|
||
document
|
||
.querySelector(".day-block.today")
|
||
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||
}, 50);
|
||
}
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ") setShowPastDays(!showPastDays);
|
||
}}
|
||
>
|
||
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
|
||
<span className="past-days-label">
|
||
{showPastDays
|
||
? t("dashboard.schedules.hidePastDays")
|
||
: t("dashboard.schedules.showPastDays")}
|
||
</span>
|
||
<span className="past-days-count">
|
||
({t("dashboard.schedules.pastDaysCount", { count: pastDays.length })})
|
||
</span>
|
||
{missedCount > 0 ? (
|
||
<span
|
||
className="past-days-warning"
|
||
title={t("dashboard.schedules.missedDoses", { count: missedCount })}
|
||
>
|
||
⚠️ {missedCount}
|
||
</span>
|
||
) : totalPastDoses.length > 0 ? (
|
||
<span className="past-days-complete" title={t("dashboard.schedules.allTaken")}>
|
||
✓
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
{/* 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 (
|
||
<div
|
||
key={day.dateStr}
|
||
className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} today`}
|
||
>
|
||
<div
|
||
className="day-divider clickable"
|
||
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed);
|
||
}}
|
||
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
||
>
|
||
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
||
<span className="day-date">{day.dateStr}</span>
|
||
<span className="day-summary">
|
||
{allDayTaken ? (
|
||
<span className="day-complete">✓ {t("dashboard.schedules.allTaken")}</span>
|
||
) : (
|
||
<span className="day-progress">
|
||
{takenCount}/{allDoseIds.length}
|
||
</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
{!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 (
|
||
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
|
||
<div className="time-main">
|
||
<div className="med-name">
|
||
<div
|
||
className={med?.imageUrl ? "med-avatar clickable" : ""}
|
||
onClick={() =>
|
||
med?.imageUrl && openLightbox(med.imageUrl, getMedDisplayName(med))
|
||
}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
if (med?.imageUrl) openLightbox(med.imageUrl, getMedDisplayName(med));
|
||
}
|
||
}}
|
||
>
|
||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||
</div>
|
||
<div className="med-name-stack">
|
||
<span className="med-name-text">{item.medName}</span>
|
||
{med?.genericName && (
|
||
<span className="med-generic-inline">{med.genericName}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="tag-row">
|
||
<ScheduleUsageTag>
|
||
{formatTotalUsageLabel(med, item.total, item.doses)}
|
||
</ScheduleUsageTag>
|
||
{isLowStock && <span className="tag warning">{t("status.lowStock")}</span>}
|
||
</div>
|
||
</div>
|
||
<div className="doses-col">
|
||
{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 (
|
||
<div key={dose.id} className={doseClasses.join(" ")}>
|
||
<span className="dose-time">{dose.timeStr}</span>
|
||
<span className="dose-usage">
|
||
<span className="dose-usage-main">{renderDoseUsage(med, dose)}</span>
|
||
{allowsPillFormSelection(med?.packageType) && med?.pillWeightMg && (
|
||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||
)}
|
||
</span>
|
||
<div className="dose-checks">
|
||
<div
|
||
className={`dose-person ${isTaken ? "taken" : ""} ${isSkipped ? "skipped" : ""} ${isOverdue ? "overdue" : ""}`}
|
||
>
|
||
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
|
||
{renderDoseActionButtons({
|
||
doseId: dose.id,
|
||
isTaken,
|
||
isSkipped,
|
||
isAutomaticallyTaken,
|
||
isEmpty,
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* 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 (
|
||
<div className="future-days-header">
|
||
<div
|
||
className={`future-days-toggle ${showFutureDays ? "expanded" : ""}`}
|
||
onClick={() => setShowFutureDays(!showFutureDays)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ") setShowFutureDays(!showFutureDays);
|
||
}}
|
||
>
|
||
<span className="future-days-icon">{showFutureDays ? "▼" : "▶"}</span>
|
||
<span className="future-days-label">
|
||
{showFutureDays
|
||
? t("dashboard.schedules.hideFutureDays")
|
||
: t("dashboard.schedules.showFutureDays")}
|
||
</span>
|
||
<span className="future-days-count">
|
||
({t("dashboard.schedules.futureDaysCount", { count: futureDays.length })})
|
||
</span>
|
||
{takenFutureDoses > 0 && totalFutureDoses.length > 0 && (
|
||
<span className="future-days-progress">
|
||
{takenFutureDoses}/{totalFutureDoses.length}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* 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 (
|
||
<div
|
||
key={day.dateStr}
|
||
className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""}`}
|
||
>
|
||
<div
|
||
className="day-divider clickable"
|
||
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed);
|
||
}}
|
||
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
||
>
|
||
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
||
<span className="day-date">{day.dateStr}</span>
|
||
<span className="day-summary">
|
||
{allDayTaken ? (
|
||
<span className="day-complete">✓ {t("dashboard.schedules.allTaken")}</span>
|
||
) : (
|
||
<span className="day-progress">
|
||
{takenCount}/{allDoseIds.length}
|
||
</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
{!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 (
|
||
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
|
||
<div className="time-main">
|
||
<div className="med-name">
|
||
<div
|
||
className={med?.imageUrl ? "med-avatar clickable" : ""}
|
||
onClick={() =>
|
||
med?.imageUrl && openLightbox(med.imageUrl, getMedDisplayName(med))
|
||
}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
if (med?.imageUrl) openLightbox(med.imageUrl, getMedDisplayName(med));
|
||
}
|
||
}}
|
||
>
|
||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||
</div>
|
||
<div className="med-name-stack">
|
||
<span className="med-name-text">{item.medName}</span>
|
||
{med?.genericName && (
|
||
<span className="med-generic-inline">{med.genericName}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="tag-row">
|
||
<ScheduleUsageTag>
|
||
{formatTotalUsageLabel(med, item.total, item.doses)}
|
||
</ScheduleUsageTag>
|
||
{isLowStock && <span className="tag warning">{t("status.lowStock")}</span>}
|
||
</div>
|
||
</div>
|
||
<div className="doses-col">
|
||
{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 (
|
||
<div key={dose.id} className={doseClasses.join(" ")}>
|
||
<span className="dose-time">{dose.timeStr}</span>
|
||
<span className="dose-usage">
|
||
<span className="dose-usage-main">{renderDoseUsage(med, dose)}</span>
|
||
{allowsPillFormSelection(med?.packageType) && med?.pillWeightMg && (
|
||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||
)}
|
||
</span>
|
||
<div className="dose-checks">
|
||
<div
|
||
className={`dose-person ${isTaken ? "taken" : ""} ${isSkipped ? "skipped" : ""}`}
|
||
>
|
||
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
|
||
{renderDoseActionButtons({
|
||
doseId: dose.id,
|
||
isTaken,
|
||
isSkipped,
|
||
isAutomaticallyTaken,
|
||
isEmpty: true,
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
})}
|
||
</>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<footer className="shared-schedule-footer">
|
||
<p>
|
||
{t("share.generatedBy")}{" "}
|
||
{data?.sharedBy && (
|
||
<>
|
||
<strong>{data.sharedBy}</strong> ·{" "}
|
||
</>
|
||
)}
|
||
<a href="/">MedAssist</a>
|
||
</p>
|
||
</footer>
|
||
</div>
|
||
|
||
{/* Image Lightbox */}
|
||
{lightboxImage && (
|
||
<div
|
||
className="lightbox-overlay"
|
||
onClick={closeLightbox}
|
||
onKeyDown={(e) => {
|
||
if (e.key !== "Escape") e.stopPropagation();
|
||
}}
|
||
>
|
||
<button className="lightbox-close" onClick={closeLightbox}>
|
||
×
|
||
</button>
|
||
<img
|
||
src={`/api/images/${lightboxImage.url}`}
|
||
alt={lightboxImage.name}
|
||
className="lightbox-image"
|
||
onClick={(e) => e.stopPropagation()}
|
||
onKeyDown={(e) => {
|
||
if (e.key !== "Escape") e.stopPropagation();
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|