feat(frontend): add intake journal and shared note flows (#648)

* feat(backend): add intake journal APIs and share note support

* feat(frontend): add intake journal and shared note flows
This commit is contained in:
Daniel Volz
2026-05-24 14:00:30 +02:00
committed by GitHub
parent e4a1b449c6
commit c78fc43083
67 changed files with 5414 additions and 580 deletions
+94 -78
View File
@@ -12,7 +12,7 @@ import {
} from "./components";
import { AppHeader } from "./components/AppHeader";
import { AuthPage, AuthProvider, useAuth } from "./components/Auth";
import { AppProvider, UnsavedChangesProvider, useAppContext, useShareContext } from "./context";
import { AppProvider, FeedbackProvider, UnsavedChangesProvider, useAppContext, useShareContext } from "./context";
import { useScrollLock } from "./hooks/useScrollLock";
const DashboardPage = lazy(() => import("./pages/DashboardPage").then((module) => ({ default: module.DashboardPage })));
@@ -38,21 +38,72 @@ function RouteLoadingFallback() {
return <div style={{ padding: "1rem", textAlign: "center" }}>{t("common.loading")}</div>;
}
function AuthStatusCard({ theme, children }: { theme: "light" | "dark"; children: React.ReactNode }) {
return (
<div className="auth-container" data-theme={theme}>
<div className="auth-card" style={{ textAlign: "center" }}>
<h1 className="auth-title">💊 MedAssist-ng</h1>
{children}
</div>
</div>
);
}
// =============================================================================
// Main App Wrapper with Auth
// =============================================================================
export default function App() {
// Close tooltips on scroll/touch (for mobile). Keep this in the public
// wrapper too so shared links get the same tooltip behavior as the app.
useEffect(() => {
const closeAllTooltips = () => {
document.querySelectorAll(".info-tooltip.tooltip-active, .tooltip-trigger.tooltip-active").forEach((el) => {
el.classList.remove("tooltip-active");
});
};
const handleTooltipClick = (e: Event) => {
const target = e.target as HTMLElement;
const tooltipTrigger = target.closest(".info-tooltip, .tooltip-trigger") as HTMLElement | null;
if (tooltipTrigger) {
closeAllTooltips();
tooltipTrigger.classList.add("tooltip-active");
if (window.innerWidth <= 640) {
const rect = tooltipTrigger.getBoundingClientRect();
tooltipTrigger.style.setProperty("--tooltip-bottom", `${window.innerHeight - rect.top + 8}px`);
}
} else {
closeAllTooltips();
}
};
const handleTouchMove = () => {
closeAllTooltips();
};
document.addEventListener("click", handleTooltipClick, { capture: true });
document.addEventListener("touchmove", handleTouchMove, { passive: true });
document.addEventListener("scroll", handleTouchMove, { passive: true });
return () => {
document.removeEventListener("click", handleTooltipClick, { capture: true });
document.removeEventListener("touchmove", handleTouchMove);
document.removeEventListener("scroll", handleTouchMove);
};
}, []);
return (
<AuthProvider>
<Suspense fallback={<RouteLoadingFallback />}>
<Routes>
{/* Public share route - accessible without auth */}
<Route path="/share/:token/overview" element={<SharedOverviewPage />} />
<Route path="/share/:token" element={<SharedSchedule />} />
{/* All other routes go through AppRouter */}
<Route path="*" element={<AppRouter />} />
</Routes>
</Suspense>
<FeedbackProvider>
<Suspense fallback={<RouteLoadingFallback />}>
<Routes>
{/* Public share route - accessible without auth */}
<Route path="/share/:token/overview" element={<SharedOverviewPage />} />
<Route path="/share/:token" element={<SharedSchedule />} />
{/* All other routes go through AppRouter */}
<Route path="*" element={<AppRouter />} />
</Routes>
</Suspense>
</FeedbackProvider>
</AuthProvider>
);
}
@@ -73,52 +124,42 @@ function getInitialAuthTheme(): "light" | "dark" {
}
function AppRouter() {
const { t } = useTranslation();
const { user, authState, loading, authError } = useAuth();
const authTheme = getInitialAuthTheme();
// Show loading while checking auth state
if (loading) {
return (
<div className="auth-container" data-theme={authTheme}>
<div className="auth-card" style={{ textAlign: "center" }}>
<h1 className="auth-title">💊 MedAssist-ng</h1>
<p>Loading...</p>
</div>
</div>
<AuthStatusCard theme={authTheme}>
<p>{t("common.loading")}</p>
</AuthStatusCard>
);
}
// Show error if we couldn't connect to the server
if (authError) {
return (
<div className="auth-container" data-theme={authTheme}>
<div className="auth-card" style={{ textAlign: "center" }}>
<h1 className="auth-title">💊 MedAssist-ng</h1>
<div className="auth-error" style={{ marginBottom: "1rem" }}>
<strong>Connection Error</strong>
<br />
{authError}
</div>
<p style={{ fontSize: "0.9rem", color: "var(--text-muted)" }}>
Please check if the server is running and try again.
</p>
<button className="btn btn-primary" onClick={() => window.location.reload()} style={{ marginTop: "1rem" }}>
Retry
</button>
<AuthStatusCard theme={authTheme}>
<div className="auth-error" style={{ marginBottom: "1rem" }}>
<strong>{t("auth.connectionErrorTitle")}</strong>
<br />
{authError}
</div>
</div>
<p style={{ fontSize: "0.9rem", color: "var(--text-muted)" }}>{t("auth.connectionErrorHelp")}</p>
<button className="btn btn-primary" onClick={() => window.location.reload()} style={{ marginTop: "1rem" }}>
{t("common.retry")}
</button>
</AuthStatusCard>
);
}
// If auth state is null (shouldn't happen after loading, but be safe)
if (!authState) {
return (
<div className="auth-container" data-theme={authTheme}>
<div className="auth-card" style={{ textAlign: "center" }}>
<h1 className="auth-title">💊 MedAssist-ng</h1>
<p>Initializing...</p>
</div>
</div>
<AuthStatusCard theme={authTheme}>
<p>{t("common.initializing")}</p>
</AuthStatusCard>
);
}
@@ -212,12 +253,20 @@ function AppContent() {
setShareSelectedPerson,
shareSelectedDays,
setShareSelectedDays,
shareSelectedExpiryDays,
setShareSelectedExpiryDays,
shareAllowJournalNotes,
setShareAllowJournalNotes,
shareGenerating,
shareLink,
setShareLink,
shareCopied,
setShareCopied,
activeShareLinks,
activeSharesLoading,
revokingShareToken,
generateShareLink,
revokeShareLink,
copyShareLink,
closeShareDialog,
resetShareDialogState,
@@ -291,47 +340,6 @@ function AppContent() {
setShowRefillModal,
]);
// Close tooltips on scroll/touch (for mobile)
useEffect(() => {
const closeAllTooltips = () => {
document.querySelectorAll(".info-tooltip.tooltip-active, .tooltip-trigger.tooltip-active").forEach((el) => {
el.classList.remove("tooltip-active");
});
};
const handleTooltipClick = (e: Event) => {
const target = e.target as HTMLElement;
const tooltipTrigger = target.closest(".info-tooltip, .tooltip-trigger") as HTMLElement | null;
if (tooltipTrigger) {
// Close other tooltips first
closeAllTooltips();
// Toggle this one
tooltipTrigger.classList.add("tooltip-active");
// Position tooltip above the icon on mobile
if (window.innerWidth <= 640) {
const rect = tooltipTrigger.getBoundingClientRect();
// Place tooltip bottom edge just above the icon
tooltipTrigger.style.setProperty("--tooltip-bottom", `${window.innerHeight - rect.top + 8}px`);
}
} else {
closeAllTooltips();
}
};
const handleTouchMove = () => {
closeAllTooltips();
};
document.addEventListener("click", handleTooltipClick, { capture: true });
document.addEventListener("touchmove", handleTouchMove, { passive: true });
document.addEventListener("scroll", handleTouchMove, { passive: true });
return () => {
document.removeEventListener("click", handleTooltipClick, { capture: true });
document.removeEventListener("touchmove", handleTouchMove);
document.removeEventListener("scroll", handleTouchMove);
};
}, []);
// Global Escape handling in priority order.
// This keeps behavior consistent even when child modals are mocked in tests.
useEffect(() => {
@@ -602,13 +610,21 @@ function AppContent() {
onShareSelectedPersonChange={setShareSelectedPerson}
shareSelectedDays={shareSelectedDays}
onShareSelectedDaysChange={setShareSelectedDays}
shareSelectedExpiryDays={shareSelectedExpiryDays}
onShareSelectedExpiryDaysChange={setShareSelectedExpiryDays}
shareAllowJournalNotes={shareAllowJournalNotes}
onShareAllowJournalNotesChange={setShareAllowJournalNotes}
shareGenerating={shareGenerating}
shareLink={shareLink}
onShareLinkChange={setShareLink}
shareCopied={shareCopied}
onShareCopiedChange={setShareCopied}
activeShareLinks={activeShareLinks}
activeSharesLoading={activeSharesLoading}
revokingShareToken={revokingShareToken}
onClose={closeShareDialog}
onGenerateShareLink={generateShareLink}
onRevokeShareLink={revokeShareLink}
onCopyShareLink={copyShareLink}
/>
+32 -2
View File
@@ -2,6 +2,7 @@
import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useEscapeKey } from "../hooks/useEscapeKey";
import { useModalHistory } from "../hooks/useModalHistory";
import { withCorrelation } from "../utils/correlation";
import { MAX_IMAGE_UPLOAD_BYTES, resolveImageUploadError } from "../utils/image-upload";
import { log } from "../utils/logger";
@@ -32,6 +33,7 @@ interface AuthContextType {
authState: AuthState | null;
loading: boolean;
authError: string | null;
sessionExpired: boolean;
login: (username: string, password: string, rememberMe?: boolean) => Promise<void>;
register: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
@@ -64,6 +66,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [authState, setAuthState] = useState<AuthState | null>(null);
const [loading, setLoading] = useState(true);
const [authError, setAuthError] = useState<string | null>(null);
const [sessionExpired, setSessionExpired] = useState(false);
// Track if initial fetch has been done to prevent duplicate calls
const initialFetchDone = useRef(false);
@@ -113,6 +116,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// If auth is enabled and we might be logged in, check session
if (state.authEnabled) {
await refreshUser();
} else {
setSessionExpired(false);
}
setLoading(false);
} catch (err) {
@@ -138,6 +143,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (res.ok) {
const userData = await res.json();
setUser(userData);
setSessionExpired(false);
log.debug("[Auth] Session user loaded", { userId: userData.id, correlationId });
} else if (res.status === 401) {
// Access token expired - try to refresh it
@@ -150,6 +156,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (retryRes.ok) {
const userData = await retryRes.json();
setUser(userData);
setSessionExpired(false);
log.info("[Auth] Session restored after token refresh", {
userId: userData.id,
correlationId: retry.correlationId,
@@ -159,6 +166,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
log.debug("[Auth] Session refresh unavailable, clearing local user state", { correlationId });
setUser(null);
setSessionExpired(true);
} else {
log.warn("[Auth] Unexpected /auth/me response", { status: res.status, correlationId });
setUser(null);
@@ -215,6 +223,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const data = await res.json();
setUser(data.user);
setSessionExpired(false);
log.info("[Auth] Login successful", { userId: data.user?.id, username: data.user?.username, correlationId });
}
@@ -233,6 +242,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Auto-login after registration
await login(username, password);
setSessionExpired(false);
// Refresh auth state (registration might disable further registrations)
await fetchAuthState();
@@ -249,6 +259,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
log.info("[Auth] Logout requested", { userId: user?.id ?? null, correlationId });
await fetch("/api/auth/logout", init);
setUser(null);
setSessionExpired(false);
log.info("[Auth] Logout completed", { correlationId });
}
@@ -341,9 +352,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (refreshed) {
// Retry the original request with new token
res = await fetch(input, options);
if (res.ok) {
setSessionExpired(false);
}
} else {
// Refresh failed - user needs to login again
setUser(null);
setSessionExpired(true);
}
}
@@ -359,6 +374,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
authState,
loading,
authError,
sessionExpired,
login,
register,
logout,
@@ -386,7 +402,7 @@ export function LoginForm({
onSwitchToRegister?: () => void;
}) {
const { t } = useTranslation();
const { login, authState } = useAuth();
const { login, authState, sessionExpired } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false);
@@ -440,6 +456,13 @@ export function LoginForm({
{/* Local login form - only show if form login is enabled */}
{authState?.formLoginEnabled && (
<form onSubmit={handleSubmit} className="auth-form">
{sessionExpired && (
<div className="auth-error">
<strong>{t("auth.sessionExpiredTitle")}</strong>
<br />
{t("auth.sessionExpiredHelp")}
</div>
)}
{error && <div className="auth-error">{error}</div>}
<div className="form-group">
@@ -633,7 +656,14 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
const [deleteLoading, setDeleteLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const closeDeleteConfirm = useCallback(() => {
if (!deleteLoading) {
setShowDeleteConfirm(false);
}
}, [deleteLoading]);
useEscapeKey(!!onClose, onClose ?? (() => {}));
useModalHistory(showDeleteConfirm, "profile-delete-account", closeDeleteConfirm);
async function handleAvatarUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
@@ -842,7 +872,7 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
confirmLabel={t("auth.deleteAccountButton", "Yes, delete my account")}
cancelLabel={t("common.cancel", "Cancel")}
onConfirm={handleDeleteAccount}
onCancel={() => setShowDeleteConfirm(false)}
onCancel={closeDeleteConfirm}
isLoading={deleteLoading}
confirmVariant="danger"
/>
@@ -0,0 +1,161 @@
import { X } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ImportPreview } from "../context/AppContext";
import { useEscapeKey } from "../hooks/useEscapeKey";
import { useScrollLock } from "../hooks/useScrollLock";
interface ImportReviewModalProps {
isOpen: boolean;
importPreview: ImportPreview | null;
formattedExportedAt: string;
importing: boolean;
exporting: boolean;
onClose: () => void;
onBackup: () => void;
onConfirm: () => void;
}
export function ImportReviewModal({
isOpen,
importPreview,
formattedExportedAt,
importing,
exporting,
onClose,
onBackup,
onConfirm,
}: ImportReviewModalProps) {
const { t } = useTranslation();
const titleId = "import-review-modal-title";
const hasExistingData = importPreview?.warnings.replacesExistingData ?? false;
const hasWarnings = Boolean(
importPreview?.warnings.replacesExistingData ||
importPreview?.warnings.regeneratesShareLinks ||
importPreview?.warnings.containsImages ||
importPreview?.warnings.containsSensitiveData
);
useScrollLock(isOpen);
useEscapeKey(isOpen, onClose);
if (!isOpen || !importPreview) {
return null;
}
return (
<div
className="modal-overlay"
onClick={onClose}
onKeyDown={(event) => {
if (event.key !== "Escape") {
event.stopPropagation();
}
}}
>
<div
className="modal-content confirm-modal import-review-modal"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key !== "Escape") {
event.stopPropagation();
}
}}
>
<button className="modal-close" onClick={onClose} type="button" aria-label={t("common.close")}>
<X size={20} aria-hidden="true" />
</button>
<h2 id={titleId}>{t(hasExistingData ? "exportImport.confirmImport" : "exportImport.confirmImportEmpty")}</h2>
<div className="import-review-body">
<p>{t(hasExistingData ? "exportImport.reviewDescription" : "exportImport.reviewDescriptionEmpty")}</p>
<div className="import-review-summary">
<div className="action-card">
<div className="action-card-content">
<span className="action-card-title">{t("exportImport.incomingData")}</span>
<span className="action-card-desc">
{t("exportImport.summaryCounts", {
medications: importPreview.incoming.medications,
doses: importPreview.incoming.doseHistory,
refills: importPreview.incoming.refillHistory,
shares: importPreview.incoming.shareLinks,
})}
</span>
</div>
<div className="import-review-meta">
<span>{t("exportImport.formatVersion", { version: importPreview.version })}</span>
<span>{t("exportImport.exportedAt", { date: formattedExportedAt })}</span>
{importPreview.incoming.hasSettings && <span>{t("exportImport.settingsIncluded")}</span>}
{importPreview.incoming.journalEntries > 0 && (
<span>{t("exportImport.journalEntries", { count: importPreview.incoming.journalEntries })}</span>
)}
{importPreview.incoming.imageCount > 0 && (
<span>{t("exportImport.imageCount", { count: importPreview.incoming.imageCount })}</span>
)}
</div>
</div>
<div className="action-card">
<div className="action-card-content">
<span className="action-card-title">{t("exportImport.currentData")}</span>
<span className="action-card-desc">
{t("exportImport.summaryCounts", {
medications: importPreview.current.medications,
doses: importPreview.current.doseHistory,
refills: importPreview.current.refillHistory,
shares: importPreview.current.shareLinks,
})}
</span>
</div>
{importPreview.current.hasSettings && (
<span className="import-review-meta">{t("exportImport.settingsConfigured")}</span>
)}
</div>
</div>
{hasWarnings && (
<div className="import-review-warnings">
<strong>{t("exportImport.warningListTitle")}</strong>
<ul>
{importPreview.warnings.replacesExistingData && <li>{t("exportImport.warningReplaceData")}</li>}
{importPreview.warnings.regeneratesShareLinks && <li>{t("exportImport.warningShareLinks")}</li>}
{importPreview.warnings.containsImages && <li>{t("exportImport.warningImages")}</li>}
{importPreview.warnings.containsSensitiveData && <li>{t("exportImport.warningSensitive")}</li>}
</ul>
</div>
)}
{hasExistingData ? (
<p className="warning-text">{t("exportImport.confirmImportWarning")}</p>
) : (
<p className="hint-text">{t("exportImport.confirmImportEmptyMessage")}</p>
)}
<p className="hint-text">{t("exportImport.backupHint")}</p>
</div>
<div className="modal-footer import-review-footer">
<button type="button" className="ghost" onClick={onClose} disabled={importing || exporting}>
{t("exportImport.cancelButton")}
</button>
<div className="import-review-actions">
{hasExistingData && (
<button type="button" className="secondary" onClick={onBackup} disabled={exporting || importing}>
{exporting ? t("exportImport.exporting") : t("exportImport.backupFirst")}
</button>
)}
<button
type="button"
className={hasExistingData ? "danger" : "primary"}
onClick={onConfirm}
disabled={importing}
>
{importing
? t("exportImport.importing")
: t(hasExistingData ? "exportImport.confirmButton" : "exportImport.confirmButtonEmpty")}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -298,7 +298,12 @@ export function MedicationEnrichmentSection({
}
const animationFrameId = window.requestAnimationFrame(() => {
resultRefs.current.get(expandedResultCode)?.scrollIntoView({
const expandedResultElement = resultRefs.current.get(expandedResultCode);
if (typeof expandedResultElement?.scrollIntoView !== "function") {
return;
}
expandedResultElement.scrollIntoView({
block: "nearest",
inline: "nearest",
behavior: "smooth",
+131 -25
View File
@@ -11,8 +11,11 @@ import {
isLiquidContainerPackageType,
isTubePackageType,
} from "../types";
import { formatDate, formatDateTime } from "../utils/formatters";
import { formatDate, formatDateTime, toInputValue } from "../utils/formatters";
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
import { mergePersonTags, personTagsMatch } from "../utils/person-tags";
import { useAuth } from "./Auth";
import { DateTimeInput } from "./DateTimeInput";
import { MedicationAvatar } from "./MedicationAvatar";
type ReportFormat = "txt" | "md" | "pdf";
@@ -41,31 +44,53 @@ type ReportData = Record<
}
>;
type ReportDateRange = {
startDate: string;
endDate: string;
};
type ReportPreview = {
format: "txt" | "md";
content: string;
};
function getDefaultDateRange(): ReportDateRange {
const endDate = new Date();
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - 30);
return {
startDate: toInputValue(startDate),
endDate: toInputValue(endDate),
};
}
export function ReportModal({ isOpen, onClose, medications }: ReportModalProps) {
const { t } = useTranslation();
const { authFetch } = useAuth();
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [format, setFormat] = useState<ReportFormat>("pdf");
const [generating, setGenerating] = useState(false);
const [takenByFilter, setTakenByFilter] = useState<Set<string>>(new Set());
const [dateRange, setDateRange] = useState<ReportDateRange>(() => getDefaultDateRange());
const [preview, setPreview] = useState<ReportPreview | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useScrollLock(isOpen);
useEscapeKey(isOpen, onClose);
// Collect all unique "taken by" people across all medications
const allPeople = useMemo(() => {
const people = new Set<string>();
for (const med of medications) {
if (med.takenBy) {
for (const p of med.takenBy) people.add(p);
}
}
return Array.from(people).sort();
return mergePersonTags(medications.flatMap((medication) => medication.takenBy || []));
}, [medications]);
// Filtered medications based on takenBy filter
const filteredMeds = useMemo(() => {
if (takenByFilter.size === 0) return medications;
return medications.filter((m) => m.takenBy?.some((p) => takenByFilter.has(p)));
return medications.filter((medication) =>
medication.takenBy?.some((person) =>
Array.from(takenByFilter).some((filterValue) => personTagsMatch(person, filterValue))
)
);
}, [medications, takenByFilter]);
const activeMeds = useMemo(() => filteredMeds.filter((m) => !m.isObsolete), [filteredMeds]);
@@ -97,9 +122,22 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
setTakenByFilter(new Set());
setFormat("pdf");
setGenerating(false);
setDateRange(getDefaultDateRange());
setPreview(null);
setErrorMessage(null);
}
}, [isOpen]);
// biome-ignore lint/correctness/useExhaustiveDependencies: preview should reset when any report input changes while the modal is open
useEffect(() => {
if (!isOpen) {
return;
}
setPreview(null);
setErrorMessage(null);
}, [isOpen, selectedIds, takenByFilter, format, dateRange.startDate, dateRange.endDate]);
const toggleMed = useCallback((id: number) => {
setSelectedIds((prev) => {
const next = new Set(prev);
@@ -118,37 +156,59 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
}, []);
const selectedMeds = useMemo(() => filteredMeds.filter((m) => selectedIds.has(m.id)), [filteredMeds, selectedIds]);
let generateButtonLabel = t("report.generate");
if (generating) {
generateButtonLabel = t("report.generating");
} else if (preview) {
generateButtonLabel = t("report.regenerate");
}
async function handleGenerate() {
if (selectedIds.size === 0) return;
const startDate = new Date(dateRange.startDate);
const endDate = new Date(dateRange.endDate);
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime()) || endDate <= startDate) {
setErrorMessage(t("report.invalidDateRange"));
return;
}
setGenerating(true);
setErrorMessage(null);
try {
const resolvedDateRange = {
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
};
const filterArr = takenByFilter.size > 0 ? Array.from(takenByFilter) : null;
// Fetch report data from backend
const res = await fetch("/api/medications/report-data", {
const res = await authFetch("/api/medications/report-data", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
medicationIds: Array.from(selectedIds),
startDate: resolvedDateRange.startDate,
endDate: resolvedDateRange.endDate,
takenByFilter: takenByFilter.size > 0 ? Array.from(takenByFilter) : undefined,
}),
credentials: "include",
});
if (!res.ok) throw new Error("Failed to fetch report data");
const reportData = (await res.json()) as ReportData;
if (format === "pdf") {
const imageMap = await fetchMedImages(selectedMeds);
const filterArr = takenByFilter.size > 0 ? Array.from(takenByFilter) : null;
openPrintView(selectedMeds, reportData, t, imageMap, filterArr);
const imageMap = await fetchMedImages(selectedMeds, authFetch);
openPrintView(selectedMeds, reportData, t, imageMap, filterArr, resolvedDateRange);
setPreview(null);
setErrorMessage(null);
onClose();
} else {
const filterArr = takenByFilter.size > 0 ? Array.from(takenByFilter) : null;
const content = generateTextReport(selectedMeds, reportData, format, t, filterArr);
downloadFile(content, format);
const content = generateTextReport(selectedMeds, reportData, format, t, filterArr, resolvedDateRange);
setPreview({ format, content });
}
onClose();
} catch {
// Stay open on error so user can retry
setErrorMessage(t("report.error"));
} finally {
setGenerating(false);
}
@@ -177,6 +237,28 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
<h2 className="report-modal-title">{t("report.title")}</h2>
<p className="report-modal-desc">{t("report.description")}</p>
<div className="report-range">
<h4>{t("report.dateRange")}</h4>
<div className="report-range-grid">
<div className="report-range-field">
<span>{t("report.from")}</span>
<DateTimeInput
step="60"
value={dateRange.startDate}
onChange={(e) => setDateRange((prev) => ({ ...prev, startDate: e.target.value }))}
/>
</div>
<div className="report-range-field">
<span>{t("report.until")}</span>
<DateTimeInput
step="60"
value={dateRange.endDate}
onChange={(e) => setDateRange((prev) => ({ ...prev, endDate: e.target.value }))}
/>
</div>
</div>
</div>
{/* Person filter */}
{allPeople.length > 1 && (
<div className="report-person-filter">
@@ -279,6 +361,25 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
</div>
</div>
{errorMessage && <p className="report-error">{errorMessage}</p>}
{preview && (
<div className="report-preview">
<div className="report-preview-header">
<h4>{t("report.preview")}</h4>
<button
type="button"
className="ghost small"
onClick={() => downloadFile(preview.content, preview.format)}
>
{t("report.download")}
</button>
</div>
<p className="report-preview-desc">{t("report.previewDescription")}</p>
<pre className="report-preview-content">{preview.content}</pre>
</div>
)}
{/* Actions */}
<div className="report-actions">
<button type="button" className="ghost" onClick={onClose}>
@@ -290,7 +391,7 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
onClick={handleGenerate}
disabled={selectedIds.size === 0 || generating}
>
{generating ? t("report.generating") : t("report.generate")}
{generateButtonLabel}
</button>
</div>
</div>
@@ -348,7 +449,8 @@ function generateTextReport(
reportData: ReportData,
fmt: "txt" | "md",
t: TFn,
personFilter: string[] | null
personFilter: string[] | null,
dateRange: { startDate: string; endDate: string }
): string {
const lines: string[] = [];
const sep = fmt === "md" ? "---" : "═".repeat(60);
@@ -360,6 +462,7 @@ function generateTextReport(
lines.push(h1(t("report.docTitle")));
lines.push(`${t("report.docGenerated")}: ${formatDate(new Date().toISOString())}`);
lines.push(`${t("report.docRange")}: ${formatDateTime(dateRange.startDate)} - ${formatDateTime(dateRange.endDate)}`);
lines.push("");
for (const med of meds) {
@@ -483,13 +586,13 @@ function downloadFile(content: string, format: "txt" | "md") {
type ImageMap = Record<number, string>;
async function fetchMedImages(meds: Medication[]): Promise<ImageMap> {
async function fetchMedImages(meds: Medication[], authFetch: typeof fetch): Promise<ImageMap> {
const map: ImageMap = {};
const fetches = meds
.filter((m) => m.imageUrl)
.map(async (m) => {
try {
const res = await fetch(`/api/images/${m.imageUrl}`, { credentials: "include" });
const res = await authFetch(`/api/images/${m.imageUrl}`);
if (!res.ok) return;
const blob = await res.blob();
const dataUrl = await new Promise<string>((resolve) => {
@@ -511,12 +614,13 @@ function openPrintView(
reportData: ReportData,
t: TFn,
imageMap: ImageMap,
personFilter: string[] | null
personFilter: string[] | null,
dateRange: { startDate: string; endDate: string }
) {
const w = window.open("", "_blank");
if (!w) return;
const html = buildPrintHtml(meds, reportData, t, imageMap, personFilter);
const html = buildPrintHtml(meds, reportData, t, imageMap, personFilter, dateRange);
w.document.write(html);
w.document.close();
w.onload = () => setTimeout(() => w.print(), 300);
@@ -531,7 +635,8 @@ function buildPrintHtml(
reportData: ReportData,
t: TFn,
imageMap: ImageMap,
personFilter: string[] | null
personFilter: string[] | null,
dateRange: { startDate: string; endDate: string }
): string {
const sections: string[] = [];
@@ -721,6 +826,7 @@ function buildPrintHtml(
<div class="no-print print-hint">${escHtml(t("report.docPrintInstruction"))}</div>
<h1>${escHtml(t("report.docTitle"))}</h1>
<p class="subtitle">${escHtml(t("report.docGenerated"))}: ${formatDate(new Date().toISOString())}</p>
<p class="subtitle">${escHtml(t("report.docRange"))}: ${formatDateTime(dateRange.startDate)} - ${formatDateTime(dateRange.endDate)}</p>
${sections.join("\n")}
</body>
</html>`;
+152
View File
@@ -4,7 +4,11 @@
*/
import { Check, Copy, Link2, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useModalHistory } from "../hooks";
import type { ActiveShareLink } from "../hooks/useShare";
import { ConfirmModal } from "./ConfirmModal";
export interface ShareDialogProps {
show: boolean;
@@ -13,13 +17,21 @@ export interface ShareDialogProps {
onShareSelectedPersonChange: (person: string) => void;
shareSelectedDays: number;
onShareSelectedDaysChange: (days: number) => void;
shareSelectedExpiryDays: number | null;
onShareSelectedExpiryDaysChange: (days: number | null) => void;
shareAllowJournalNotes: boolean;
onShareAllowJournalNotesChange: (enabled: boolean) => void;
shareGenerating: boolean;
shareLink: string | null;
onShareLinkChange: (link: string | null) => void;
shareCopied: boolean;
onShareCopiedChange: (copied: boolean) => void;
activeShareLinks: ActiveShareLink[];
activeSharesLoading: boolean;
revokingShareToken: string | null;
onClose: () => void;
onGenerateShareLink: () => Promise<void>;
onRevokeShareLink: (token: string) => Promise<boolean>;
onCopyShareLink: () => void;
}
@@ -30,24 +42,116 @@ export function ShareDialog({
onShareSelectedPersonChange,
shareSelectedDays,
onShareSelectedDaysChange,
shareSelectedExpiryDays,
onShareSelectedExpiryDaysChange,
shareAllowJournalNotes,
onShareAllowJournalNotesChange,
shareGenerating,
shareLink,
onShareLinkChange,
shareCopied,
onShareCopiedChange,
activeShareLinks,
activeSharesLoading,
revokingShareToken,
onClose,
onGenerateShareLink,
onRevokeShareLink,
onCopyShareLink,
}: ShareDialogProps) {
const { t } = useTranslation();
const [manageLinksOpen, setManageLinksOpen] = useState(false);
const [shareToRevoke, setShareToRevoke] = useState<ActiveShareLink | null>(null);
const closeLabel = t("common.close");
const copyLabel = shareCopied ? t("share.copied") : t("share.copyLink");
const getPersonLabel = (person: string) => (person === "all" ? t("share.allPeople") : person);
const closeRevokeConfirm = useCallback(() => {
if (shareToRevoke && revokingShareToken !== shareToRevoke.token) {
setShareToRevoke(null);
}
}, [revokingShareToken, shareToRevoke]);
useModalHistory(show && Boolean(shareToRevoke), "share-revoke", closeRevokeConfirm);
useEffect(() => {
if (!show) {
setShareToRevoke(null);
}
}, [show]);
// ESC is handled by the global handler in App.tsx to avoid double history.back()
if (!show) return null;
const renderActiveShares = () => {
if (activeSharesLoading) {
return <p>{t("share.loadingActiveLinks")}</p>;
}
if (activeShareLinks.length === 0) {
return <p>{t("share.noActiveLinks")}</p>;
}
return (
<ul className="share-active-list">
{activeShareLinks.map((share) => {
const personLabel = getPersonLabel(share.takenBy);
const createdAtLabel = new Date(share.createdAt).toLocaleDateString();
const expiresAtLabel = share.expiresAt ? new Date(share.expiresAt).toLocaleDateString() : null;
return (
<li key={share.token} className="share-active-item">
<div className="share-active-copy">
<a href={`${window.location.origin}${share.shareUrl}`} className="share-link-inline">
{personLabel}
</a>
<span className="hint-text">
{expiresAtLabel
? t("share.activeLinkMetaWithExpiry", {
person: personLabel,
days: share.scheduleDays,
createdAt: createdAtLabel,
expiresAt: expiresAtLabel,
})
: t("share.activeLinkMeta", {
person: personLabel,
days: share.scheduleDays,
createdAt: createdAtLabel,
})}
{share.allowJournalNotes ? ` · ${t("share.journalNotesEnabled")}` : ""}
</span>
</div>
<button
type="button"
className="ghost"
disabled={revokingShareToken === share.token}
onClick={() => setShareToRevoke(share)}
>
{revokingShareToken === share.token ? t("share.revoking") : t("share.revoke")}
</button>
</li>
);
})}
</ul>
);
};
const renderManageLinks = () => (
<div className="share-dialog-manage">
<button
type="button"
className="share-dialog-manage-summary"
onClick={() => setManageLinksOpen((current) => !current)}
aria-expanded={manageLinksOpen}
>
<span>{t("share.manageLinksSummary", { count: activeShareLinks.length })}</span>
<span className="share-dialog-manage-count">
{manageLinksOpen ? t("common.hide") : activeShareLinks.length}
</span>
</button>
{manageLinksOpen ? <div className="share-dialog-manage-content">{renderActiveShares()}</div> : null}
</div>
);
return (
<div
className="modal-overlay"
@@ -85,6 +189,7 @@ export function ShareDialog({
return (
<div className="share-dialog-empty">
<p>{t("share.noPeople")}</p>
<div className="share-dialog-active-links">{renderManageLinks()}</div>
</div>
);
}
@@ -124,6 +229,7 @@ export function ShareDialog({
</button>
<button onClick={onClose}>{t("common.close")}</button>
</div>
<div className="share-dialog-active-links">{renderManageLinks()}</div>
</div>
);
}
@@ -159,6 +265,33 @@ export function ShareDialog({
</select>
</div>
<div className="form-group">
<label htmlFor="share-expiry-select">{t("share.selectExpiry")}</label>
<select
id="share-expiry-select"
className="select-field"
value={shareSelectedExpiryDays == null ? "never" : String(shareSelectedExpiryDays)}
onChange={(e) =>
onShareSelectedExpiryDaysChange(e.target.value === "never" ? null : Number(e.target.value))
}
>
<option value="never">{t("share.expiryNever")}</option>
<option value="7">{t("share.expiry7Days")}</option>
<option value="30">{t("share.expiry30Days")}</option>
<option value="90">{t("share.expiry90Days")}</option>
</select>
</div>
<label className="inline-checkbox" htmlFor="share-journal-notes-toggle">
<input
id="share-journal-notes-toggle"
type="checkbox"
checked={shareAllowJournalNotes}
onChange={(event) => onShareAllowJournalNotesChange(event.target.checked)}
/>
<span>{t("share.allowJournalNotes")}</span>
</label>
<div className="share-dialog-footer">
<button className="ghost" onClick={onClose}>
{t("common.close")}
@@ -167,9 +300,28 @@ export function ShareDialog({
{shareGenerating ? t("share.generating") : t("share.generateLink")}
</button>
</div>
<div className="share-dialog-active-links">{renderManageLinks()}</div>
</div>
);
})()}
{shareToRevoke && (
<ConfirmModal
title={t("share.revoke")}
message={t("share.revokeConfirm", { person: getPersonLabel(shareToRevoke.takenBy) })}
confirmLabel={revokingShareToken === shareToRevoke.token ? t("share.revoking") : t("share.revoke")}
cancelLabel={t("common.cancel")}
onConfirm={async () => {
const revoked = await onRevokeShareLink(shareToRevoke.token);
if (revoked) {
setShareToRevoke(null);
}
}}
onCancel={closeRevokeConfirm}
isLoading={revokingShareToken === shareToRevoke.token}
confirmVariant="danger"
overlayClassName="nested-confirm"
/>
)}
</div>
</div>
);
+182 -4
View File
@@ -4,14 +4,17 @@
/* 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 } from "../hooks";
import { useEscapeKey, useModalHistory } from "../hooks";
import type { IntakeJournalEntry } from "../hooks/useIntakeJournal";
import type { ExpiredLinkData, SharedScheduleData } from "../types";
import {
allowsPillFormSelection,
@@ -26,12 +29,30 @@ 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<string> {
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<SharedScheduleData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -39,8 +60,15 @@ export function SharedSchedule() {
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 [sharedJournalDoseIdsWithNotes, setSharedJournalDoseIdsWithNotes] = useState<Set<string>>(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<string | null>(null);
const [sharedJournalEntry, setSharedJournalEntry] = useState<IntakeJournalEntry | null>(null);
const [sharedJournalLoading, setSharedJournalLoading] = useState(false);
const [sharedJournalSaving, setSharedJournalSaving] = useState(false);
const [sharedJournalError, setSharedJournalError] = useState<string | null>(null);
const [showPastDays, setShowPastDays] = useState(false);
const [showFutureDays, setShowFutureDays] = useState(false);
@@ -169,6 +197,107 @@ export function SharedSchedule() {
// 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() {
@@ -194,11 +323,13 @@ export function SharedSchedule() {
const taken = new Set<string>();
const automatic = new Set<string>();
const dismissed = new Set<string>();
const journalDoseIds = new Set<string>();
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);
@@ -208,10 +339,14 @@ export function SharedSchedule() {
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.
@@ -268,7 +403,7 @@ export function SharedSchedule() {
try {
const data = (await response.json()) as { code?: string };
if (data.code === "OUT_OF_STOCK") {
alert(t("common.outOfStockTakeBlocked"));
showFeedback({ message: t("common.outOfStockTakeBlocked"), tone: "error" });
}
} catch {
// Ignore JSON parsing errors and fall back to the optimistic rollback only.
@@ -448,6 +583,9 @@ export function SharedSchedule() {
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 ? (
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
{options.isAutomaticallyTaken && (
@@ -486,10 +624,33 @@ export function SharedSchedule() {
</button>
);
const journalButton = showSharedJournalAction ? (
<span
className={!canOpenSharedJournal ? "tooltip-trigger" : undefined}
data-tooltip={!canOpenSharedJournal ? t("journal.actions.noteTakenOnly") : undefined}
>
<button
type="button"
className={`dose-btn journal${hasSharedJournalNote ? " has-note" : ""}`}
onClick={() => {
if (canOpenSharedJournal) {
void openSharedJournalEditor(options.doseId);
}
}}
disabled={!canOpenSharedJournal}
title={canOpenSharedJournal ? t("journal.actions.note") : undefined}
>
<NotebookPen size={14} aria-hidden="true" />
<span className="dose-btn-label">{t("journal.actions.note")}</span>
</button>
</span>
) : null;
return (
<>
{takeButton}
{skipButton}
{journalButton}
</>
);
};
@@ -641,7 +802,10 @@ export function SharedSchedule() {
}, [data, i18n.language]);
// Split into past, today, and future - matches main app logic
const pastDays = useMemo(() => schedule.filter((d) => d.isPast), [schedule]);
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(() => {
@@ -901,6 +1065,7 @@ export function SharedSchedule() {
<div className="shared-schedule-container">
<header className="shared-schedule-header">
<h1>{pageTitle}</h1>
<p className="shared-schedule-boundary">{t("share.publicAccessHelp")}</p>
<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")}>
@@ -1226,7 +1391,7 @@ export function SharedSchedule() {
const hasAutomaticTakenDose = allDoseIds.some((id) => isDoseTakenAutomatically(id));
// Today: only collapse if manually collapsed or all taken
const isAutoCollapsed = allDayTaken && !hasAutomaticTakenDose;
const isAutoCollapsed = allDayTaken && !hasAutomaticTakenDose && !data.allowJournalNotes;
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isManuallyCollapsed = manuallyCollapsedDays.has(day.dateStr);
const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed;
@@ -1582,6 +1747,19 @@ export function SharedSchedule() {
/>
</div>
)}
<IntakeJournalModal
isOpen={sharedJournalOpen}
entry={sharedJournalEntry}
isLoading={sharedJournalLoading}
isSaving={sharedJournalSaving}
isDeleting={false}
error={sharedJournalError}
onClose={closeSharedJournalEditor}
onSave={saveSharedJournalNote}
onDelete={() => undefined}
allowDelete={false}
/>
</div>
);
}
+6 -2
View File
@@ -12,6 +12,7 @@ import { formatNumber } from "../utils";
import { getSystemLocale } from "../utils/formatters";
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
import { getLiquidCountUnitLabel } from "../utils/intake-units";
import { personTagsMatch } from "../utils/person-tags";
import { getStockStatus } from "../utils/schedule";
export interface UserFilterModalProps {
@@ -72,7 +73,10 @@ export function UserFilterModal({
if (!selectedUser) return null;
const userMeds = meds.filter((m) => !m.isObsolete && (m.takenBy || []).includes(selectedUser));
const userMeds = meds.filter(
(medication) =>
!medication.isObsolete && (medication.takenBy || []).some((person) => personTagsMatch(person, selectedUser))
);
return (
<div
@@ -110,7 +114,7 @@ export function UserFilterModal({
// Get intakes relevant to this person
const personIntakes = getMedicationIntakes(med).filter(
(intake) => intake.takenBy === null || intake.takenBy === selectedUser
(intake) => intake.takenBy === null || personTagsMatch(intake.takenBy, selectedUser)
);
return (
+3 -1
View File
@@ -7,8 +7,10 @@ export { DateInput } from "./DateInput";
export { DateTimeInput } from "./DateTimeInput";
export { default as ExportModal } from "./ExportModal";
export { FormNumberStepper } from "./FormNumberStepper";
export { ImportReviewModal } from "./ImportReviewModal";
export { IntakeJournalHistoryModal } from "./intake-journal/IntakeJournalHistoryModal";
export { IntakeJournalModal } from "./intake-journal/IntakeJournalModal";
export type { LightboxProps } from "./Lightbox";
export { Lightbox } from "./Lightbox";
export type { MedDetailModalProps } from "./MedDetailModal";
export { MedDetailModal } from "./MedDetailModal";
@@ -0,0 +1,191 @@
import { useTranslation } from "react-i18next";
import { useEscapeKey } from "../../hooks/useEscapeKey";
import type { IntakeJournalEntry, IntakeJournalHistoryFilters } from "../../hooks/useIntakeJournal";
import { useScrollLock } from "../../hooks/useScrollLock";
import type { Medication } from "../../types";
import { formatDateTime, getNumericLocale } from "../../utils/formatters";
import { DateTimeInput } from "../DateTimeInput";
import { MedicationAvatar } from "../MedicationAvatar";
interface IntakeJournalHistoryModalProps {
isOpen: boolean;
entries: IntakeJournalEntry[];
filters: IntakeJournalHistoryFilters;
medications: Medication[];
isLoading: boolean;
error: string | null;
onClose: () => void;
onFilterChange: (patch: Partial<IntakeJournalHistoryFilters>) => void;
onReload: () => Promise<void> | void;
onResetFilters: () => void;
onReopen: (doseId: string) => Promise<void> | void;
}
function formatDisplayDateTime(value: string | null): string | null {
if (!value) {
return null;
}
return formatDateTime(value, getNumericLocale());
}
function getJournalSourceLabel(entry: IntakeJournalEntry, t: ReturnType<typeof useTranslation>["t"]): string {
if (entry.takenSource === "automatic") {
return t("journal.context.sourceAutomaticReminder");
}
return entry.markedBy ? t("journal.context.sourceSharedLink") : t("journal.context.sourceOwnerApp");
}
export function IntakeJournalHistoryModal({
isOpen,
entries,
filters,
medications,
isLoading,
error,
onClose,
onFilterChange,
onReload,
onResetFilters,
onReopen,
}: IntakeJournalHistoryModalProps) {
const { t } = useTranslation();
useScrollLock(isOpen);
useEscapeKey(isOpen, onClose);
if (!isOpen) {
return null;
}
let listContent: React.ReactNode;
if (isLoading) {
listContent = <div className="journal-modal-state">{t("journal.history.loading")}</div>;
} else if (entries.length === 0) {
listContent = <div className="journal-modal-state">{t("journal.history.empty")}</div>;
} else {
listContent = entries.map((entry) => (
<article key={entry.doseTrackingId} className="journal-history-entry">
<div className="journal-history-entry-main">
<div className="journal-history-entry-header">
<MedicationAvatar name={entry.medicationName} size="sm" />
<div>
<strong>{entry.medicationName}</strong>
<p>{formatDisplayDateTime(entry.scheduledFor) ?? t("common.notAvailable")}</p>
</div>
</div>
<p className="journal-history-note">{entry.note ?? t("journal.history.noNote")}</p>
<div className="journal-history-meta">
<span>{t(entry.dismissed ? "journal.context.statusSkipped" : "journal.context.statusTaken")}</span>
<span>{getJournalSourceLabel(entry, t)}</span>
{entry.updatedAt && (
<span>
{t("journal.history.updatedAt", {
date: formatDisplayDateTime(entry.updatedAt) ?? entry.updatedAt,
})}
</span>
)}
</div>
</div>
<button type="button" className="primary small" onClick={() => void onReopen(entry.doseId)}>
{t("journal.history.reopen")}
</button>
</article>
));
}
return (
<div
className="modal-overlay"
onClick={onClose}
onKeyDown={(event) => {
if (event.key !== "Escape") {
event.stopPropagation();
}
}}
>
<div
className="modal-content journal-history-modal"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key !== "Escape") {
event.stopPropagation();
}
}}
>
<button type="button" className="modal-close" onClick={onClose} aria-label={t("common.close")}>
×
</button>
<div className="journal-modal-header">
<h2>{t("journal.history.title")}</h2>
<p>{t("journal.history.description")}</p>
</div>
<div className="journal-history-filters">
<label className="journal-field" htmlFor="journal-history-medication">
<span>{t("journal.history.filters.medication")}</span>
<select
id="journal-history-medication"
className="select-field"
value={filters.medicationId ?? "all"}
onChange={(event) => {
const value = event.target.value;
onFilterChange({ medicationId: value === "all" ? null : Number(value) });
}}
>
<option value="all">{t("journal.history.filters.allMedications")}</option>
{medications.map((medication) => (
<option key={medication.id} value={medication.id}>
{medication.name}
</option>
))}
</select>
</label>
<div className="journal-field journal-date-filter">
<span>{t("journal.history.filters.from")}</span>
<DateTimeInput
value={filters.from}
onChange={(event) => onFilterChange({ from: event.target.value })}
step="60"
aria-label={t("journal.history.filters.from")}
placeholder={t("journal.history.filters.fromPlaceholder")}
/>
</div>
<div className="journal-field journal-date-filter">
<span>{t("journal.history.filters.to")}</span>
<DateTimeInput
value={filters.to}
onChange={(event) => onFilterChange({ to: event.target.value })}
step="60"
aria-label={t("journal.history.filters.to")}
placeholder={t("journal.history.filters.toPlaceholder")}
/>
</div>
</div>
<div className="journal-history-toolbar">
<button type="button" className="ghost small" onClick={onResetFilters}>
{t("journal.history.resetFilters")}
</button>
<button type="button" className="ghost small" onClick={() => void onReload()} disabled={isLoading}>
{t("journal.history.reload")}
</button>
</div>
{error && <div className="journal-inline-error">{error}</div>}
<div className="journal-history-list">{listContent}</div>
<div className="modal-footer journal-modal-footer">
<div className="footer-right">
<button type="button" className="ghost" onClick={onClose}>
{t("common.close")}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,231 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useEscapeKey } from "../../hooks/useEscapeKey";
import type { IntakeJournalEntry } from "../../hooks/useIntakeJournal";
import { useScrollLock } from "../../hooks/useScrollLock";
import { formatDateTime, getNumericLocale } from "../../utils/formatters";
import { MedicationAvatar } from "../MedicationAvatar";
interface IntakeJournalModalProps {
isOpen: boolean;
entry: IntakeJournalEntry | null;
isLoading: boolean;
isSaving: boolean;
isDeleting: boolean;
error: string | null;
onClose: () => void;
onSave: (note: string) => Promise<boolean> | boolean;
onDelete: () => Promise<void> | void;
allowDelete?: boolean;
}
function formatDisplayDateTime(value: string | null): string | null {
if (!value) {
return null;
}
return formatDateTime(value, getNumericLocale());
}
function getJournalSourceLabel(entry: IntakeJournalEntry, t: ReturnType<typeof useTranslation>["t"]): string {
if (entry.takenSource === "automatic") {
return t("journal.context.sourceAutomaticReminder");
}
return entry.markedBy ? t("journal.context.sourceSharedLink") : t("journal.context.sourceOwnerApp");
}
export function IntakeJournalModal({
isOpen,
entry,
isLoading,
isSaving,
isDeleting,
error,
onClose,
onSave,
onDelete,
allowDelete = true,
}: IntakeJournalModalProps) {
const { t } = useTranslation();
const [note, setNote] = useState("");
const [showSavedState, setShowSavedState] = useState(false);
const activeDoseTrackingIdRef = useRef<number | null>(null);
const wasSavingRef = useRef(false);
useScrollLock(isOpen);
useEscapeKey(isOpen, onClose);
useEffect(() => {
if (!isOpen) {
setNote("");
setShowSavedState(false);
activeDoseTrackingIdRef.current = null;
wasSavingRef.current = false;
return;
}
if (!entry) {
return;
}
setNote(entry.note ?? "");
if (activeDoseTrackingIdRef.current !== entry.doseTrackingId) {
activeDoseTrackingIdRef.current = entry.doseTrackingId;
setShowSavedState(false);
}
}, [entry, isOpen]);
useEffect(() => {
if (!isOpen) {
wasSavingRef.current = false;
return;
}
if (isSaving) {
setShowSavedState(false);
wasSavingRef.current = true;
return;
}
if (wasSavingRef.current) {
wasSavingRef.current = false;
if (entry && !error && note === (entry.note ?? "")) {
setShowSavedState(true);
}
}
}, [entry, error, isOpen, isSaving, note]);
if (!isOpen) {
return null;
}
const handleSave = async () => {
const saved = await onSave(note);
if (saved) {
onClose();
}
};
const scheduledForLabel = formatDisplayDateTime(entry?.scheduledFor ?? null);
const takenAtLabel = formatDisplayDateTime(entry?.takenAt ?? null);
const title = entry?.note ? t("journal.editor.editTitle") : t("journal.editor.addTitle");
const saveLabel = showSavedState ? t("common.saved") : t("common.save");
let bodyContent: React.ReactNode;
if (isLoading) {
bodyContent = <div className="journal-modal-state">{t("journal.editor.loading")}</div>;
} else if (entry) {
bodyContent = (
<>
<div className="journal-event-card">
<div className="journal-event-medication">
<MedicationAvatar name={entry.medicationName} size="sm" />
<div>
<strong>{entry.medicationName}</strong>
<p>{entry.dismissed ? t("journal.context.statusSkipped") : t("journal.context.statusTaken")}</p>
</div>
</div>
<div className="journal-event-grid">
<div>
<span>{t("journal.context.scheduledFor")}</span>
<strong>{scheduledForLabel ?? t("common.notAvailable")}</strong>
</div>
<div>
<span>{t("journal.context.takenAt")}</span>
<strong>{takenAtLabel ?? t("journal.context.notRecorded")}</strong>
</div>
<div>
<span>{t("journal.context.markedBy")}</span>
<strong>{entry.markedBy ?? t("journal.context.self")}</strong>
</div>
<div>
<span>{t("journal.context.source")}</span>
<strong>{getJournalSourceLabel(entry, t)}</strong>
</div>
</div>
</div>
<label className="journal-field" htmlFor="journal-note-input">
<span>{t("journal.editor.noteLabel")}</span>
<textarea
id="journal-note-input"
className="journal-note-input"
rows={7}
value={note}
onChange={(event) => {
setNote(event.target.value);
setShowSavedState(false);
}}
placeholder={t("journal.editor.notePlaceholder")}
maxLength={4000}
/>
</label>
{error && <div className="journal-inline-error">{error}</div>}
</>
);
} else {
bodyContent = <div className="journal-modal-state">{error ?? t("journal.errors.loadFailed")}</div>;
}
return (
<div
className="modal-overlay"
onClick={onClose}
onKeyDown={(event) => {
if (event.key !== "Escape") {
event.stopPropagation();
}
}}
>
<div
className="modal-content journal-modal"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key !== "Escape") {
event.stopPropagation();
}
}}
>
<button type="button" className="modal-close" onClick={onClose} aria-label={t("common.close")}>
×
</button>
<div className="journal-modal-header">
<h2>{title}</h2>
<p>{t("journal.editor.description")}</p>
</div>
{bodyContent}
<div className="modal-footer journal-modal-footer">
<div className="footer-left">
{allowDelete && (
<button
type="button"
className="ghost"
onClick={() => void onDelete()}
disabled={isLoading || isSaving || isDeleting || !entry?.note}
>
{isDeleting ? t("journal.editor.deleting") : t("common.delete")}
</button>
)}
</div>
<div className="footer-right">
<button type="button" className="ghost" onClick={onClose} disabled={isSaving || isDeleting}>
{t("common.cancel")}
</button>
<button
type="button"
className="primary"
onClick={() => void handleSave()}
disabled={isLoading || isSaving || isDeleting || !entry}
>
{saveLabel}
</button>
</div>
</div>
</div>
</div>
);
}
+183 -21
View File
@@ -2,7 +2,15 @@ import type React from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "../components/Auth";
import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, useShare } from "../hooks";
import {
useCollapsedDays,
useDoses,
useIntakeJournal,
useMedications,
useRefill,
useSettings,
useShare,
} from "../hooks";
import {
type Coverage,
type FormState,
@@ -13,7 +21,9 @@ import {
} from "../types";
import { getSystemLocale, setDefaultFormattingTimezone } from "../utils/formatters";
import { log } from "../utils/logger";
import { mergePersonTags } from "../utils/person-tags";
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds, getStockStatus } from "../utils/schedule";
import { useFeedback } from "./FeedbackContext";
import { ShareContextProvider } from "./ShareContext";
// =============================================================================
@@ -44,6 +54,34 @@ export type GroupedDay = {
meds: DayMedEntry[];
};
export type ImportPreview = {
version: string;
exportedAt: string;
includeSensitiveData: boolean;
incoming: {
medications: number;
doseHistory: number;
refillHistory: number;
shareLinks: number;
journalEntries: number;
imageCount: number;
hasSettings: boolean;
};
current: {
medications: number;
doseHistory: number;
refillHistory: number;
shareLinks: number;
hasSettings: boolean;
};
warnings: {
replacesExistingData: boolean;
regeneratesShareLinks: boolean;
containsImages: boolean;
containsSensitiveData: boolean;
};
};
export interface AppContextValue {
// From useMedications
meds: Medication[];
@@ -87,6 +125,29 @@ export interface AppContextValue {
undoDoseTaken: (doseId: string) => Promise<void>;
undoDoseSkipped: (doseId: string) => Promise<void>;
// From useIntakeJournal
journalEditorOpen: boolean;
journalHistoryOpen: boolean;
journalTargetDoseId: string | null;
journalEvent: ReturnType<typeof useIntakeJournal>["journalEvent"];
journalEventLoading: boolean;
journalEventSaving: boolean;
journalEventDeleting: boolean;
journalEventError: string | null;
journalHistoryEntries: ReturnType<typeof useIntakeJournal>["journalHistoryEntries"];
journalHistoryFilters: ReturnType<typeof useIntakeJournal>["journalHistoryFilters"];
journalHistoryLoading: boolean;
journalHistoryError: string | null;
openJournalEditor: (doseId: string) => Promise<void>;
closeJournalEditor: () => void;
saveJournalNote: (note: string) => Promise<boolean>;
deleteJournalNote: () => Promise<boolean>;
openJournalHistory: () => void;
closeJournalHistory: () => void;
setJournalHistoryFilters: (patch: Partial<ReturnType<typeof useIntakeJournal>["journalHistoryFilters"]>) => void;
reloadJournalHistory: () => Promise<void>;
reopenJournalHistoryEntry: (doseId: string) => Promise<void>;
// From useCollapsedDays
manuallyCollapsedDays: Set<string>;
manuallyExpandedDays: Set<string>;
@@ -99,13 +160,21 @@ export interface AppContextValue {
setShareSelectedPerson: React.Dispatch<React.SetStateAction<string>>;
shareSelectedDays: number;
setShareSelectedDays: React.Dispatch<React.SetStateAction<number>>;
shareSelectedExpiryDays: number | null;
setShareSelectedExpiryDays: React.Dispatch<React.SetStateAction<number | null>>;
shareAllowJournalNotes: boolean;
setShareAllowJournalNotes: React.Dispatch<React.SetStateAction<boolean>>;
shareGenerating: boolean;
shareLink: string | null;
setShareLink: React.Dispatch<React.SetStateAction<string | null>>;
shareCopied: boolean;
setShareCopied: React.Dispatch<React.SetStateAction<boolean>>;
activeShareLinks: ReturnType<typeof useShare>["activeShareLinks"];
activeSharesLoading: boolean;
revokingShareToken: string | null;
openShareDialog: () => void;
generateShareLink: () => Promise<void>;
revokeShareLink: (token: string) => Promise<boolean>;
copyShareLink: () => void;
closeShareDialog: () => void;
resetShareDialogState: () => void;
@@ -188,6 +257,8 @@ export interface AppContextValue {
setShowImportConfirm: React.Dispatch<React.SetStateAction<boolean>>;
pendingImportData: unknown;
setPendingImportData: React.Dispatch<React.SetStateAction<unknown>>;
importPreview: ImportPreview | null;
setImportPreview: React.Dispatch<React.SetStateAction<ImportPreview | null>>;
importResult: {
medications: number;
doses: number;
@@ -245,12 +316,14 @@ function userStorageKey(userId: number | undefined, key: string): string {
export function AppProvider({ children }: { children: React.ReactNode }) {
const { i18n } = useTranslation();
const { user } = useAuth();
const { user, authFetch } = useAuth();
const { showFeedback } = useFeedback();
// Compose hooks
const medications = useMedications();
const settingsHook = useSettings();
const doses = useDoses();
const intakeJournal = useIntakeJournal();
const collapsed = useCollapsedDays(user?.id);
const share = useShare();
const refill = useRefill();
@@ -295,6 +368,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
const [showExportModal, setShowExportModal] = useState(false);
const [showImportConfirm, setShowImportConfirm] = useState(false);
const [pendingImportData, setPendingImportData] = useState<unknown>(null);
const [importPreview, setImportPreview] = useState<ImportPreview | null>(null);
const [importResult, setImportResult] = useState<{
medications: number;
doses: number;
@@ -326,6 +400,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
medications.clearMedicationsState();
settingsHook.resetSettingsState();
doses.clearDosesState();
intakeJournal.resetJournalState();
refill.clearRefillState();
share.resetShareDialogState();
@@ -351,6 +426,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
settingsHook.loadSettings,
doses.clearDosesState,
doses.loadTakenDoses,
intakeJournal.resetJournalState,
refill.clearRefillState,
share.resetShareDialogState,
]);
@@ -442,8 +518,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
);
const existingPeople = useMemo(() => {
const allPeople = medications.meds.flatMap((m) => m.takenBy || []);
return [...new Set(allPeople)].filter(Boolean).sort();
return mergePersonTags(medications.meds.flatMap((medication) => medication.takenBy || []));
}, [medications.meds]);
// Get worst stock status for a day's medications
@@ -658,9 +733,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
async (includeImages: boolean = true) => {
setExporting(true);
try {
const res = await fetch(`/api/export?includeSensitive=true&includeImages=${includeImages}`, {
credentials: "include",
});
const res = await authFetch(`/api/export?includeSensitive=true&includeImages=${includeImages}`);
if (!res.ok) throw new Error("Export failed");
const data = await res.json();
@@ -682,7 +755,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
}
setExporting(false);
},
[t, user?.username]
[authFetch, t, user?.username]
);
// Handle file selection for import
@@ -692,24 +765,64 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
reader.onload = async (event) => {
try {
const data = JSON.parse(event.target?.result as string);
if (!data.version || !data.exportedAt) {
alert(t("exportImport.invalidFile"));
setPendingImportData(null);
setImportPreview(null);
showFeedback({ message: t("exportImport.invalidFile"), tone: "error" });
return;
}
const res = await authFetch("/api/import/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const text = await res.text();
let previewResponse: { error?: string; preview?: ImportPreview } = {};
try {
previewResponse = text ? JSON.parse(text) : {};
} catch {
log.error("Import preview response parse error:", text);
showFeedback({
message: `${t("exportImport.importError")}: Server returned invalid response`,
tone: "error",
});
return;
}
if (!res.ok || !previewResponse.preview) {
setPendingImportData(null);
setImportPreview(null);
if (previewResponse.error === "Invalid import data format") {
showFeedback({ message: t("exportImport.invalidFile"), tone: "error" });
return;
}
showFeedback({
message: `${t("exportImport.importError")}: ${previewResponse.error || `HTTP ${res.status}`}`,
tone: "error",
});
return;
}
setImportResult(null);
setPendingImportData(data);
setImportPreview(previewResponse.preview);
setShowImportConfirm(true);
} catch {
alert(t("exportImport.invalidFile"));
setPendingImportData(null);
setImportPreview(null);
showFeedback({ message: t("exportImport.invalidFile"), tone: "error" });
}
};
reader.readAsText(file);
// Reset file input
e.target.value = "";
},
[t]
[authFetch, showFeedback, t]
);
// Confirm and execute import
@@ -719,10 +832,9 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
setShowImportConfirm(false);
try {
const res = await fetch("/api/import", {
const res = await authFetch("/api/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(pendingImportData),
});
@@ -744,12 +856,18 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
data = text ? JSON.parse(text) : {};
} catch {
log.error("Import response parse error:", text);
alert(`${t("exportImport.importError")}: Server returned invalid response`);
showFeedback({
message: `${t("exportImport.importError")}: Server returned invalid response`,
tone: "error",
});
return;
}
if (!res.ok) {
alert(`${t("exportImport.importError")}: ${data.error || `HTTP ${res.status}`}`);
showFeedback({
message: `${t("exportImport.importError")}: ${data.error || `HTTP ${res.status}`}`,
tone: "error",
});
return;
}
@@ -768,12 +886,13 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
doses.loadTakenDoses();
} catch (err) {
log.error("Import error:", err);
alert(t("exportImport.importError"));
showFeedback({ message: t("exportImport.importError"), tone: "error" });
} finally {
setPendingImportData(null);
setImportPreview(null);
setImporting(false);
}
setPendingImportData(null);
setImporting(false);
}, [pendingImportData, t, medications, settingsHook, doses]);
}, [authFetch, pendingImportData, t, medications, settingsHook, doses, showFeedback]);
// Compute settingsChanged
const settingsChanged = useMemo(() => {
@@ -815,13 +934,21 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
setShareSelectedPerson: share.setShareSelectedPerson,
shareSelectedDays: share.shareSelectedDays,
setShareSelectedDays: share.setShareSelectedDays,
shareSelectedExpiryDays: share.shareSelectedExpiryDays,
setShareSelectedExpiryDays: share.setShareSelectedExpiryDays,
shareAllowJournalNotes: share.shareAllowJournalNotes,
setShareAllowJournalNotes: share.setShareAllowJournalNotes,
shareGenerating: share.shareGenerating,
shareLink: share.shareLink,
setShareLink: share.setShareLink,
shareCopied: share.shareCopied,
setShareCopied: share.setShareCopied,
activeShareLinks: share.activeShareLinks,
activeSharesLoading: share.activeSharesLoading,
revokingShareToken: share.revokingShareToken,
openShareDialog,
generateShareLink: share.generateShareLink,
revokeShareLink: share.revokeShareLink,
copyShareLink: share.copyShareLink,
closeShareDialog: share.closeShareDialog,
resetShareDialogState: share.resetShareDialogState,
@@ -865,6 +992,29 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
undoDoseTaken: doses.undoDoseTaken,
undoDoseSkipped: doses.undoDoseSkipped,
// From useIntakeJournal
journalEditorOpen: intakeJournal.journalEditorOpen,
journalHistoryOpen: intakeJournal.journalHistoryOpen,
journalTargetDoseId: intakeJournal.journalTargetDoseId,
journalEvent: intakeJournal.journalEvent,
journalEventLoading: intakeJournal.journalEventLoading,
journalEventSaving: intakeJournal.journalEventSaving,
journalEventDeleting: intakeJournal.journalEventDeleting,
journalEventError: intakeJournal.journalEventError,
journalHistoryEntries: intakeJournal.journalHistoryEntries,
journalHistoryFilters: intakeJournal.journalHistoryFilters,
journalHistoryLoading: intakeJournal.journalHistoryLoading,
journalHistoryError: intakeJournal.journalHistoryError,
openJournalEditor: intakeJournal.openJournalEditor,
closeJournalEditor: intakeJournal.closeJournalEditor,
saveJournalNote: intakeJournal.saveJournalNote,
deleteJournalNote: intakeJournal.deleteJournalNote,
openJournalHistory: intakeJournal.openJournalHistory,
closeJournalHistory: intakeJournal.closeJournalHistory,
setJournalHistoryFilters: intakeJournal.setJournalHistoryFilters,
reloadJournalHistory: intakeJournal.reloadJournalHistory,
reopenJournalHistoryEntry: intakeJournal.reopenJournalHistoryEntry,
// From useCollapsedDays
manuallyCollapsedDays: collapsed.manuallyCollapsedDays,
manuallyExpandedDays: collapsed.manuallyExpandedDays,
@@ -877,13 +1027,21 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
setShareSelectedPerson: share.setShareSelectedPerson,
shareSelectedDays: share.shareSelectedDays,
setShareSelectedDays: share.setShareSelectedDays,
shareSelectedExpiryDays: share.shareSelectedExpiryDays,
setShareSelectedExpiryDays: share.setShareSelectedExpiryDays,
shareAllowJournalNotes: share.shareAllowJournalNotes,
setShareAllowJournalNotes: share.setShareAllowJournalNotes,
shareGenerating: share.shareGenerating,
shareLink: share.shareLink,
setShareLink: share.setShareLink,
shareCopied: share.shareCopied,
setShareCopied: share.setShareCopied,
activeShareLinks: share.activeShareLinks,
activeSharesLoading: share.activeSharesLoading,
revokingShareToken: share.revokingShareToken,
openShareDialog,
generateShareLink: share.generateShareLink,
revokeShareLink: share.revokeShareLink,
copyShareLink: share.copyShareLink,
closeShareDialog: share.closeShareDialog,
resetShareDialogState: share.resetShareDialogState,
@@ -970,6 +1128,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
setShowImportConfirm,
pendingImportData,
setPendingImportData,
importPreview,
setImportPreview,
importResult,
setImportResult,
handleExport,
@@ -981,6 +1141,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
medications,
settingsHook,
doses,
intakeJournal,
collapsed,
share,
refill,
@@ -1017,6 +1178,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
showExportModal,
showImportConfirm,
pendingImportData,
importPreview,
importResult,
handleExport,
handleImportFileSelect,
+103
View File
@@ -0,0 +1,103 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
export type FeedbackTone = "info" | "success" | "warning" | "error";
type FeedbackNotice = {
id: number;
message: string;
tone: FeedbackTone;
durationMs: number;
};
type FeedbackContextValue = {
showFeedback: (options: { message: string; tone?: FeedbackTone; durationMs?: number }) => void;
dismissFeedback: (id: number) => void;
clearFeedback: () => void;
};
const noop = () => {};
const defaultValue: FeedbackContextValue = {
showFeedback: noop,
dismissFeedback: noop,
clearFeedback: noop,
};
const FeedbackContext = createContext<FeedbackContextValue>(defaultValue);
export function FeedbackProvider({ children }: { children: React.ReactNode }) {
const { t } = useTranslation();
const [notices, setNotices] = useState<FeedbackNotice[]>([]);
const nextIdRef = useRef(1);
const timeoutMapRef = useRef<Map<number, number>>(new Map());
const dismissFeedback = useCallback((id: number) => {
const timeoutId = timeoutMapRef.current.get(id);
if (typeof timeoutId === "number") {
window.clearTimeout(timeoutId);
timeoutMapRef.current.delete(id);
}
setNotices((current) => current.filter((notice) => notice.id !== id));
}, []);
const clearFeedback = useCallback(() => {
for (const timeoutId of timeoutMapRef.current.values()) {
window.clearTimeout(timeoutId);
}
timeoutMapRef.current.clear();
setNotices([]);
}, []);
const showFeedback = useCallback(
({ message, tone = "info", durationMs = 5000 }: { message: string; tone?: FeedbackTone; durationMs?: number }) => {
const id = nextIdRef.current++;
setNotices((current) => [...current, { id, message, tone, durationMs }].slice(-3));
const timeoutId = window.setTimeout(() => {
dismissFeedback(id);
}, durationMs);
timeoutMapRef.current.set(id, timeoutId);
},
[dismissFeedback]
);
useEffect(() => () => clearFeedback(), [clearFeedback]);
const value = useMemo(
() => ({
showFeedback,
dismissFeedback,
clearFeedback,
}),
[showFeedback, dismissFeedback, clearFeedback]
);
return (
<FeedbackContext.Provider value={value}>
{children}
<div className="app-feedback-stack" aria-live="polite" aria-atomic="false">
{notices.map((notice) => (
<div
key={notice.id}
className={`app-feedback app-feedback-${notice.tone}`}
role={notice.tone === "error" ? "alert" : "status"}
>
<div className="app-feedback-message">{notice.message}</div>
<button
type="button"
className="app-feedback-close"
onClick={() => dismissFeedback(notice.id)}
aria-label={t("common.close")}
>
×
</button>
</div>
))}
</div>
</FeedbackContext.Provider>
);
}
export function useFeedback() {
return useContext(FeedbackContext);
}
+8
View File
@@ -7,13 +7,21 @@ type ShareContextValue = {
setShareSelectedPerson: React.Dispatch<React.SetStateAction<string>>;
shareSelectedDays: number;
setShareSelectedDays: React.Dispatch<React.SetStateAction<number>>;
shareSelectedExpiryDays: number | null;
setShareSelectedExpiryDays: React.Dispatch<React.SetStateAction<number | null>>;
shareAllowJournalNotes: boolean;
setShareAllowJournalNotes: React.Dispatch<React.SetStateAction<boolean>>;
shareGenerating: boolean;
shareLink: string | null;
setShareLink: React.Dispatch<React.SetStateAction<string | null>>;
shareCopied: boolean;
setShareCopied: React.Dispatch<React.SetStateAction<boolean>>;
activeShareLinks: import("../hooks/useShare").ActiveShareLink[];
activeSharesLoading: boolean;
revokingShareToken: string | null;
openShareDialog: () => void;
generateShareLink: () => Promise<void>;
revokeShareLink: (token: string) => Promise<boolean>;
copyShareLink: () => void;
closeShareDialog: () => void;
resetShareDialogState: () => void;
+2
View File
@@ -2,6 +2,8 @@
export type { AppContextValue, DayMedEntry, DoseInfo, GroupedDay } from "./AppContext";
export { AppProvider, useAppContext } from "./AppContext";
export type { FeedbackTone } from "./FeedbackContext";
export { FeedbackProvider, useFeedback } from "./FeedbackContext";
export type { ShareContextValue } from "./ShareContext";
export { ShareContextProvider, useShareContext } from "./ShareContext";
export { UnsavedChangesProvider, useUnsavedChanges } from "./UnsavedChangesContext";
+2
View File
@@ -5,6 +5,8 @@ export { useCollapsedDays } from "./useCollapsedDays";
export type { UseDosesReturn } from "./useDoses";
export { useDoses } from "./useDoses";
export { useEscapeKey } from "./useEscapeKey";
export type { IntakeJournalEntry, IntakeJournalHistoryFilters, UseIntakeJournalReturn } from "./useIntakeJournal";
export { useIntakeJournal } from "./useIntakeJournal";
export {
createMedicationEnrichmentState,
MEDICATION_ENRICHMENT_INITIAL_LIMIT,
+25 -15
View File
@@ -4,6 +4,8 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "../components/Auth";
import { useFeedback } from "../context/FeedbackContext";
export interface UseDosesReturn {
takenDoses: Set<string>;
@@ -25,6 +27,8 @@ export interface UseDosesReturn {
export function useDoses(): UseDosesReturn {
const { t } = useTranslation();
const { authFetch } = useAuth();
const { showFeedback } = useFeedback();
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
const [takenDoseTimestamps, setTakenDoseTimestamps] = useState<Map<string, number>>(new Map());
const [takenDoseSources, setTakenDoseSources] = useState<Map<string, "manual" | "automatic">>(new Map());
@@ -48,7 +52,7 @@ export function useDoses(): UseDosesReturn {
if (mutationInFlightRef.current > 0) return;
try {
const res = await fetch("/api/doses/taken", { credentials: "include" });
const res = await authFetch("/api/doses/taken");
if (res.ok) {
// Double-check no mutation started while we were fetching
if (mutationInFlightRef.current > 0) return;
@@ -79,7 +83,7 @@ export function useDoses(): UseDosesReturn {
} catch {
// Don't reset on error - keep current state
}
}, [clearDosesState]);
}, [authFetch, clearDosesState]);
// Poll for taken doses from server (works with or without auth)
useEffect(() => {
@@ -164,15 +168,14 @@ export function useDoses(): UseDosesReturn {
// Send to server
try {
const response = await fetch("/api/doses/taken", {
const response = await authFetch("/api/doses/taken", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ doseId }),
});
if (!response.ok) {
if ((await getErrorCode(response)) === "OUT_OF_STOCK") {
alert(t("common.outOfStockTakeBlocked"));
showFeedback({ message: t("common.outOfStockTakeBlocked"), tone: "error" });
}
throw new Error("Failed to mark dose as taken");
}
@@ -220,7 +223,17 @@ export function useDoses(): UseDosesReturn {
loadTakenDoses();
}
},
[dismissedDoses, getErrorCode, loadTakenDoses, t, takenDoseSources, takenDoseTimestamps, takenDoses]
[
authFetch,
dismissedDoses,
getErrorCode,
loadTakenDoses,
showFeedback,
t,
takenDoseSources,
takenDoseTimestamps,
takenDoses,
]
);
const markDoseSkipped = useCallback(
@@ -257,10 +270,9 @@ export function useDoses(): UseDosesReturn {
});
try {
const response = await fetch("/api/doses/skip", {
const response = await authFetch("/api/doses/skip", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ doseId }),
});
if (!response.ok) {
@@ -302,7 +314,7 @@ export function useDoses(): UseDosesReturn {
loadTakenDoses();
}
},
[dismissedDoses, loadTakenDoses, takenDoseSources, takenDoseTimestamps, takenDoses]
[authFetch, dismissedDoses, loadTakenDoses, takenDoseSources, takenDoseTimestamps, takenDoses]
);
const undoDoseTaken = useCallback(
@@ -330,9 +342,8 @@ export function useDoses(): UseDosesReturn {
// Send to server
try {
await fetch(`/api/doses/taken/${encodeURIComponent(doseId)}`, {
await authFetch(`/api/doses/taken/${encodeURIComponent(doseId)}`, {
method: "DELETE",
credentials: "include",
});
} catch {
// Revert on error
@@ -361,7 +372,7 @@ export function useDoses(): UseDosesReturn {
loadTakenDoses();
}
},
[loadTakenDoses, takenDoseSources, takenDoseTimestamps]
[authFetch, loadTakenDoses, takenDoseSources, takenDoseTimestamps]
);
const undoDoseSkipped = useCallback(
@@ -376,9 +387,8 @@ export function useDoses(): UseDosesReturn {
});
try {
await fetch(`/api/doses/skip/${encodeURIComponent(doseId)}`, {
await authFetch(`/api/doses/skip/${encodeURIComponent(doseId)}`, {
method: "DELETE",
credentials: "include",
});
} catch {
setDismissedDoses((prev) => {
@@ -393,7 +403,7 @@ export function useDoses(): UseDosesReturn {
loadTakenDoses();
}
},
[dismissedDoses, loadTakenDoses]
[authFetch, dismissedDoses, loadTakenDoses]
);
return {
+339
View File
@@ -0,0 +1,339 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "../components/Auth";
import { useModalHistory } from "./useModalHistory";
export type IntakeJournalEntry = {
doseTrackingId: number;
doseId: string;
medicationId: number;
medicationName: string;
scheduledFor: string;
takenAt: string | null;
dismissed: boolean;
takenSource: "manual" | "automatic";
markedBy: string | null;
note: string | null;
updatedAt: string | null;
createdAt: string | null;
};
export type IntakeJournalHistoryFilters = {
medicationId: number | null;
from: string;
to: string;
limit: number;
};
export interface UseIntakeJournalReturn {
journalEditorOpen: boolean;
journalHistoryOpen: boolean;
journalTargetDoseId: string | null;
journalEvent: IntakeJournalEntry | null;
journalEventLoading: boolean;
journalEventSaving: boolean;
journalEventDeleting: boolean;
journalEventError: string | null;
journalHistoryEntries: IntakeJournalEntry[];
journalHistoryFilters: IntakeJournalHistoryFilters;
journalHistoryLoading: boolean;
journalHistoryError: string | null;
resetJournalState: () => void;
openJournalEditor: (doseId: string) => Promise<void>;
closeJournalEditor: () => void;
saveJournalNote: (note: string) => Promise<boolean>;
deleteJournalNote: () => Promise<boolean>;
openJournalHistory: () => void;
closeJournalHistory: () => void;
setJournalHistoryFilters: (patch: Partial<IntakeJournalHistoryFilters>) => void;
reloadJournalHistory: () => Promise<void>;
reopenJournalHistoryEntry: (doseId: string) => Promise<void>;
}
const DEFAULT_HISTORY_FILTERS: IntakeJournalHistoryFilters = {
medicationId: null,
from: "",
to: "",
limit: 100,
};
async function readErrorMessage(response: Response, fallbackMessage: string): Promise<string> {
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;
}
function buildHistoryQuery(filters: IntakeJournalHistoryFilters): string {
const params = new URLSearchParams();
if (typeof filters.medicationId === "number") {
params.set("medicationId", String(filters.medicationId));
}
if (filters.from.trim().length > 0) {
params.set("from", filters.from.trim());
}
if (filters.to.trim().length > 0) {
params.set("to", filters.to.trim());
}
params.set("limit", String(filters.limit));
const query = params.toString();
return query.length > 0 ? `?${query}` : "";
}
export function useIntakeJournal(): UseIntakeJournalReturn {
const { authFetch } = useAuth();
const { t } = useTranslation();
const [journalEditorOpen, setJournalEditorOpen] = useState(false);
const [journalHistoryOpen, setJournalHistoryOpen] = useState(false);
const [journalTargetDoseId, setJournalTargetDoseId] = useState<string | null>(null);
const [journalEvent, setJournalEvent] = useState<IntakeJournalEntry | null>(null);
const [journalEventLoading, setJournalEventLoading] = useState(false);
const [journalEventSaving, setJournalEventSaving] = useState(false);
const [journalEventDeleting, setJournalEventDeleting] = useState(false);
const [journalEventError, setJournalEventError] = useState<string | null>(null);
const [journalHistoryEntries, setJournalHistoryEntries] = useState<IntakeJournalEntry[]>([]);
const [journalHistoryFilters, setJournalHistoryFiltersState] =
useState<IntakeJournalHistoryFilters>(DEFAULT_HISTORY_FILTERS);
const [journalHistoryLoading, setJournalHistoryLoading] = useState(false);
const [journalHistoryError, setJournalHistoryError] = useState<string | null>(null);
const resetJournalState = useCallback(() => {
setJournalEditorOpen(false);
setJournalHistoryOpen(false);
setJournalTargetDoseId(null);
setJournalEvent(null);
setJournalEventLoading(false);
setJournalEventSaving(false);
setJournalEventDeleting(false);
setJournalEventError(null);
setJournalHistoryEntries([]);
setJournalHistoryFiltersState(DEFAULT_HISTORY_FILTERS);
setJournalHistoryLoading(false);
setJournalHistoryError(null);
}, []);
const loadJournalEvent = useCallback(
async (doseId: string) => {
setJournalEventLoading(true);
setJournalEventError(null);
try {
const response = await authFetch(`/api/intake-journal/event/${encodeURIComponent(doseId)}`);
if (!response.ok) {
const message = await readErrorMessage(response, t("journal.errors.loadFailed"));
setJournalEvent(null);
setJournalEventError(message);
return;
}
const data = (await response.json()) as { entry: IntakeJournalEntry };
setJournalEvent(data.entry);
} catch {
setJournalEvent(null);
setJournalEventError(t("journal.errors.loadFailed"));
} finally {
setJournalEventLoading(false);
}
},
[authFetch, t]
);
const loadJournalHistory = useCallback(
async (filters: IntakeJournalHistoryFilters) => {
setJournalHistoryLoading(true);
setJournalHistoryError(null);
try {
const response = await authFetch(`/api/intake-journal${buildHistoryQuery(filters)}`);
if (!response.ok) {
const message = await readErrorMessage(response, t("journal.errors.historyFailed"));
setJournalHistoryEntries([]);
setJournalHistoryError(message);
return;
}
const data = (await response.json()) as { entries: IntakeJournalEntry[] };
setJournalHistoryEntries(Array.isArray(data.entries) ? data.entries : []);
} catch {
setJournalHistoryEntries([]);
setJournalHistoryError(t("journal.errors.historyFailed"));
} finally {
setJournalHistoryLoading(false);
}
},
[authFetch, t]
);
useEffect(() => {
if (!journalHistoryOpen) {
return;
}
void loadJournalHistory(journalHistoryFilters);
}, [journalHistoryFilters, journalHistoryOpen, loadJournalHistory]);
const openJournalEditor = useCallback(
async (doseId: string) => {
setJournalHistoryOpen(false);
setJournalEditorOpen(true);
setJournalTargetDoseId(doseId);
setJournalEvent(null);
await loadJournalEvent(doseId);
},
[loadJournalEvent]
);
const closeJournalEditor = useCallback(() => {
setJournalEditorOpen(false);
setJournalTargetDoseId(null);
setJournalEvent(null);
setJournalEventError(null);
setJournalEventLoading(false);
setJournalEventSaving(false);
setJournalEventDeleting(false);
}, []);
const saveJournalNote = useCallback(
async (note: string) => {
if (!journalTargetDoseId) {
setJournalEventError(t("journal.errors.noEventSelected"));
return false;
}
setJournalEventSaving(true);
setJournalEventError(null);
try {
const response = await authFetch(`/api/intake-journal/event/${encodeURIComponent(journalTargetDoseId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note }),
});
if (!response.ok) {
const message = await readErrorMessage(response, t("journal.errors.saveFailed"));
setJournalEventError(message);
return false;
}
const data = (await response.json()) as { entry: IntakeJournalEntry };
setJournalEvent(data.entry);
if (journalHistoryOpen) {
void loadJournalHistory(journalHistoryFilters);
}
return true;
} catch {
setJournalEventError(t("journal.errors.saveFailed"));
return false;
} finally {
setJournalEventSaving(false);
}
},
[authFetch, journalHistoryFilters, journalHistoryOpen, journalTargetDoseId, loadJournalHistory, t]
);
const deleteJournalNote = useCallback(async () => {
if (!journalTargetDoseId) {
setJournalEventError(t("journal.errors.noEventSelected"));
return false;
}
setJournalEventDeleting(true);
setJournalEventError(null);
try {
const response = await authFetch(`/api/intake-journal/event/${encodeURIComponent(journalTargetDoseId)}`, {
method: "DELETE",
});
if (!response.ok) {
const message = await readErrorMessage(response, t("journal.errors.deleteFailed"));
setJournalEventError(message);
return false;
}
setJournalEvent((previous) =>
previous ? { ...previous, note: null, updatedAt: null, createdAt: null } : previous
);
if (journalHistoryOpen) {
void loadJournalHistory(journalHistoryFilters);
}
return true;
} catch {
setJournalEventError(t("journal.errors.deleteFailed"));
return false;
} finally {
setJournalEventDeleting(false);
}
}, [authFetch, journalHistoryFilters, journalHistoryOpen, journalTargetDoseId, loadJournalHistory, t]);
const openJournalHistory = useCallback(() => {
setJournalEditorOpen(false);
setJournalHistoryOpen(true);
setJournalHistoryError(null);
}, []);
const closeJournalHistory = useCallback(() => {
setJournalHistoryOpen(false);
setJournalHistoryError(null);
}, []);
useModalHistory(journalEditorOpen, "intake-journal-editor", closeJournalEditor);
useModalHistory(journalHistoryOpen, "intake-journal-history", closeJournalHistory);
const updateJournalHistoryFilters = useCallback((patch: Partial<IntakeJournalHistoryFilters>) => {
setJournalHistoryFiltersState((previous) => ({
...previous,
...patch,
}));
}, []);
const reloadJournalHistory = useCallback(async () => {
await loadJournalHistory(journalHistoryFilters);
}, [journalHistoryFilters, loadJournalHistory]);
const reopenJournalHistoryEntry = useCallback(
async (doseId: string) => {
setJournalHistoryOpen(false);
await openJournalEditor(doseId);
},
[openJournalEditor]
);
return {
journalEditorOpen,
journalHistoryOpen,
journalTargetDoseId,
journalEvent,
journalEventLoading,
journalEventSaving,
journalEventDeleting,
journalEventError,
journalHistoryEntries,
journalHistoryFilters,
journalHistoryLoading,
journalHistoryError,
resetJournalState,
openJournalEditor,
closeJournalEditor,
saveJournalNote,
deleteJournalNote,
openJournalHistory,
closeJournalHistory,
setJournalHistoryFilters: updateJournalHistoryFilters,
reloadJournalHistory,
reopenJournalHistoryEntry,
};
}
+3 -1
View File
@@ -12,6 +12,7 @@ import {
} from "../types";
import { toDateValue, toTimeValue } from "../utils/formatters";
import { normalizeWeekdays } from "../utils/intake-schedule";
import { personTagsMatch } from "../utils/person-tags";
export const defaultBlister = (): FormBlister => {
const now = new Date();
@@ -488,7 +489,8 @@ export function useMedicationForm(): UseMedicationFormReturn {
const addTakenByPerson = useCallback(
(name: string) => {
const trimmed = name.trim();
if (trimmed && trimmed.length <= FIELD_LIMITS.takenBy.max && !form.takenBy.includes(trimmed)) {
const alreadyExists = form.takenBy.some((person) => personTagsMatch(person, trimmed));
if (trimmed && trimmed.length <= FIELD_LIMITS.takenBy.max && !alreadyExists) {
setForm((prev) => ({ ...prev, takenBy: [...prev.takenBy, trimmed] }));
}
setTakenByInput("");
+10 -9
View File
@@ -1,4 +1,5 @@
import { useCallback, useState } from "react";
import { useAuth } from "../components/Auth";
import type { Medication } from "../types";
export interface UseMedicationsReturn {
@@ -16,6 +17,7 @@ export interface UseMedicationsReturn {
}
export function useMedications(): UseMedicationsReturn {
const { authFetch } = useAuth();
const [meds, setMeds] = useState<Medication[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
@@ -30,20 +32,20 @@ export function useMedications(): UseMedicationsReturn {
const loadMeds = useCallback(() => {
setLoading(true);
fetch("/api/medications?includeObsolete=true", { credentials: "include" })
authFetch("/api/medications?includeObsolete=true")
.then((res) => res.json())
.then((data) => setMeds(Array.isArray(data) ? data : []))
.catch(() => setMeds([]))
.finally(() => setLoading(false));
}, []);
}, [authFetch]);
const deleteMed = useCallback(
async (id: number, editingId: number | null, resetForm: () => void) => {
await fetch(`/api/medications/${id}`, { method: "DELETE", credentials: "include" }).catch(() => null);
await authFetch(`/api/medications/${id}`, { method: "DELETE" }).catch(() => null);
if (editingId === id) resetForm();
loadMeds();
},
[loadMeds]
[authFetch, loadMeds]
);
const uploadMedImage = useCallback(
@@ -53,10 +55,9 @@ export function useMedications(): UseMedicationsReturn {
formData.append("file", file);
try {
const res = await fetch(`/api/medications/${medId}/image`, {
const res = await authFetch(`/api/medications/${medId}/image`, {
method: "POST",
body: formData,
credentials: "include",
});
if (!res.ok) {
let code = "UNKNOWN";
@@ -86,15 +87,15 @@ export function useMedications(): UseMedicationsReturn {
setUploadingImage(false);
}
},
[loadMeds]
[authFetch, loadMeds]
);
const deleteMedImage = useCallback(
async (medId: number) => {
await fetch(`/api/medications/${medId}/image`, { method: "DELETE", credentials: "include" }).catch(() => null);
await authFetch(`/api/medications/${medId}/image`, { method: "DELETE" }).catch(() => null);
loadMeds();
},
[loadMeds]
[authFetch, loadMeds]
);
return {
+4 -3
View File
@@ -19,14 +19,15 @@ export function useModalHistory(isOpen: boolean, modalKey: string, onClose: () =
useEffect(() => {
if (!isOpen) return;
const handlePopState = () => {
const handlePopState = (event: PopStateEvent) => {
if (pushedRef.current) {
pushedRef.current = false;
onClose();
event.stopImmediatePropagation();
}
};
window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState);
window.addEventListener("popstate", handlePopState, { capture: true });
return () => window.removeEventListener("popstate", handlePopState, true);
}, [isOpen, onClose]);
}
+20 -17
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { useAuth } from "../components/Auth";
import type { Coverage, FormState, Medication, RefillEntry } from "../types";
import {
getMedTotal,
@@ -55,6 +56,7 @@ export interface UseRefillReturn {
}
export function useRefill(): UseRefillReturn {
const { authFetch } = useAuth();
// Refill state
const [showRefillModal, setShowRefillModal] = useState(false);
const [refillPacks, setRefillPacks] = useState(1);
@@ -93,19 +95,22 @@ export function useRefill(): UseRefillReturn {
}, [resetRefillForm]);
// Load refill history for a medication
const loadRefillHistory = useCallback(async (medId: number) => {
try {
const res = await fetch(`/api/medications/${medId}/refills`, { credentials: "include" });
if (res.ok) {
const data = await res.json();
setRefillHistory(Array.isArray(data) ? data : data.refills || []);
} else {
const loadRefillHistory = useCallback(
async (medId: number) => {
try {
const res = await authFetch(`/api/medications/${medId}/refills`);
if (res.ok) {
const data = await res.json();
setRefillHistory(Array.isArray(data) ? data : data.refills || []);
} else {
setRefillHistory([]);
}
} catch {
setRefillHistory([]);
}
} catch {
setRefillHistory([]);
}
}, []);
},
[authFetch]
);
// Submit a refill
const submitRefill = useCallback(
@@ -119,10 +124,9 @@ export function useRefill(): UseRefillReturn {
if (refillPacks < 1 && refillLoose < 1) return;
setRefillSaving(true);
try {
const res = await fetch(`/api/medications/${medId}/refill`, {
const res = await authFetch(`/api/medications/${medId}/refill`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
packsAdded: refillPacks,
loosePillsAdded: refillLoose,
@@ -162,7 +166,7 @@ export function useRefill(): UseRefillReturn {
}
setRefillSaving(false);
},
[refillPacks, refillLoose, showRefillModal, loadRefillHistory]
[authFetch, refillPacks, refillLoose, showRefillModal, loadRefillHistory]
);
// Submit a stock correction - user says how many pills they have RIGHT NOW
@@ -282,10 +286,9 @@ export function useRefill(): UseRefillReturn {
}
// Use the PATCH endpoint - it sets stockAdjustment, looseTablets, AND lastStockCorrectionAt
const res = await fetch(`/api/medications/${medId}/stock-adjustment`, {
const res = await authFetch(`/api/medications/${medId}/stock-adjustment`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(patchBody),
});
if (res.ok) {
@@ -301,7 +304,7 @@ export function useRefill(): UseRefillReturn {
}
setEditStockSaving(false);
},
[editStockFullBlisters, editStockPartialBlisterPills, editStockLoosePills, showEditStockModal]
[authFetch, editStockFullBlisters, editStockPartialBlisterPills, editStockLoosePills, showEditStockModal]
);
const openRefillModal = useCallback(() => {
@@ -28,6 +28,27 @@ export function useScheduleController() {
markDoseSkipped: ctx.markDoseSkipped,
undoDoseTaken: ctx.undoDoseTaken,
undoDoseSkipped: ctx.undoDoseSkipped,
journalEditorOpen: ctx.journalEditorOpen,
journalHistoryOpen: ctx.journalHistoryOpen,
journalTargetDoseId: ctx.journalTargetDoseId,
journalEvent: ctx.journalEvent,
journalEventLoading: ctx.journalEventLoading,
journalEventSaving: ctx.journalEventSaving,
journalEventDeleting: ctx.journalEventDeleting,
journalEventError: ctx.journalEventError,
journalHistoryEntries: ctx.journalHistoryEntries,
journalHistoryFilters: ctx.journalHistoryFilters,
journalHistoryLoading: ctx.journalHistoryLoading,
journalHistoryError: ctx.journalHistoryError,
openJournalEditor: ctx.openJournalEditor,
closeJournalEditor: ctx.closeJournalEditor,
saveJournalNote: ctx.saveJournalNote,
deleteJournalNote: ctx.deleteJournalNote,
openJournalHistory: ctx.openJournalHistory,
closeJournalHistory: ctx.closeJournalHistory,
setJournalHistoryFilters: ctx.setJournalHistoryFilters,
reloadJournalHistory: ctx.reloadJournalHistory,
reopenJournalHistoryEntry: ctx.reopenJournalHistoryEntry,
manuallyCollapsedDays: ctx.manuallyCollapsedDays,
manuallyExpandedDays: ctx.manuallyExpandedDays,
toggleDayCollapse: ctx.toggleDayCollapse,
+144 -29
View File
@@ -3,12 +3,25 @@
// =============================================================================
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "../components/Auth";
import { useFeedback } from "../context/FeedbackContext";
import type { Medication } from "../types";
import { withCorrelation } from "../utils/correlation";
import { log } from "../utils/logger";
const SHARE_ALL_VALUE = "all";
export interface ActiveShareLink {
token: string;
takenBy: string;
scheduleDays: number;
createdAt: string;
expiresAt: string | null;
allowJournalNotes: boolean;
shareUrl: string;
}
export interface UseShareReturn {
showShareDialog: boolean;
sharePeople: string[];
@@ -16,54 +29,96 @@ export interface UseShareReturn {
setShareSelectedPerson: React.Dispatch<React.SetStateAction<string>>;
shareSelectedDays: number;
setShareSelectedDays: React.Dispatch<React.SetStateAction<number>>;
shareSelectedExpiryDays: number | null;
setShareSelectedExpiryDays: React.Dispatch<React.SetStateAction<number | null>>;
shareAllowJournalNotes: boolean;
setShareAllowJournalNotes: React.Dispatch<React.SetStateAction<boolean>>;
shareGenerating: boolean;
shareLink: string | null;
setShareLink: React.Dispatch<React.SetStateAction<string | null>>;
shareCopied: boolean;
setShareCopied: React.Dispatch<React.SetStateAction<boolean>>;
activeShareLinks: ActiveShareLink[];
activeSharesLoading: boolean;
revokingShareToken: string | null;
openShareDialog: (meds: Medication[]) => void;
generateShareLink: () => Promise<void>;
revokeShareLink: (token: string) => Promise<boolean>;
copyShareLink: () => void;
closeShareDialog: () => void;
resetShareDialogState: () => void;
}
export function useShare(): UseShareReturn {
const { authFetch } = useAuth();
const { t } = useTranslation();
const { showFeedback } = useFeedback();
const [showShareDialog, setShowShareDialog] = useState(false);
const [sharePeople, setSharePeople] = useState<string[]>([]);
const [shareSelectedPerson, setShareSelectedPerson] = useState<string>("");
const [shareSelectedDays, setShareSelectedDays] = useState<number>(30);
const [shareSelectedExpiryDays, setShareSelectedExpiryDays] = useState<number | null>(null);
const [shareAllowJournalNotes, setShareAllowJournalNotes] = useState(false);
const [shareGenerating, setShareGenerating] = useState(false);
const [shareLink, setShareLink] = useState<string | null>(null);
const [shareCopied, setShareCopied] = useState(false);
const [activeShareLinks, setActiveShareLinks] = useState<ActiveShareLink[]>([]);
const [activeSharesLoading, setActiveSharesLoading] = useState(false);
const [revokingShareToken, setRevokingShareToken] = useState<string | null>(null);
const openShareDialog = useCallback((meds: Medication[]) => {
setShowShareDialog(true);
window.history.pushState({ modal: "share" }, "");
setShareLink(null);
setShareCopied(false);
setShareSelectedPerson("");
setShareSelectedDays(30);
const loadActiveShareLinks = useCallback(async () => {
setActiveSharesLoading(true);
try {
const response = await authFetch("/api/share");
const data = await response.json().catch(() => ({}));
if (!response.ok || !Array.isArray(data?.shareLinks)) {
setActiveShareLinks([]);
log.warn("[ShareDialog] Failed to load active share links", { status: response.status });
return;
}
// Include both per-intake assignments and legacy medication-level assignments.
const uniquePeople = [
...new Set(
meds.flatMap((medication) => [
...(medication.intakes
?.map((intake) => intake.takenBy)
.filter((person): person is string => Boolean(person)) ?? []),
...(medication.takenBy || []),
])
),
]
.filter(Boolean)
.sort();
setSharePeople(uniquePeople.length > 0 ? [SHARE_ALL_VALUE, ...uniquePeople] : []);
log.info("[ShareDialog] Opened", { medicationCount: meds.length, personCount: uniquePeople.length });
if (uniquePeople.length > 0) {
setShareSelectedPerson(uniquePeople[0]);
setActiveShareLinks(data.shareLinks);
} catch (error) {
setActiveShareLinks([]);
log.error("[ShareDialog] Active share list request threw error", { error });
} finally {
setActiveSharesLoading(false);
}
}, []);
}, [authFetch]);
const openShareDialog = useCallback(
(meds: Medication[]) => {
setShowShareDialog(true);
window.history.pushState({ modal: "share" }, "");
setShareLink(null);
setShareCopied(false);
setShareSelectedPerson("");
setShareSelectedDays(30);
setShareSelectedExpiryDays(null);
setShareAllowJournalNotes(false);
void loadActiveShareLinks();
// Include both per-intake assignments and legacy medication-level assignments.
const uniquePeople = [
...new Set(
meds.flatMap((medication) => [
...(medication.intakes
?.map((intake) => intake.takenBy)
.filter((person): person is string => Boolean(person)) ?? []),
...(medication.takenBy || []),
])
),
]
.filter(Boolean)
.sort();
setSharePeople(uniquePeople.length > 0 ? [SHARE_ALL_VALUE, ...uniquePeople] : []);
log.info("[ShareDialog] Opened", { medicationCount: meds.length, personCount: uniquePeople.length });
if (uniquePeople.length > 0) {
setShareSelectedPerson(uniquePeople[0]);
}
},
[loadActiveShareLinks]
);
const generateShareLink = useCallback(async () => {
if (!shareSelectedPerson) {
@@ -82,19 +137,24 @@ export function useShare(): UseShareReturn {
body: JSON.stringify({
takenBy: shareSelectedPerson,
scheduleDays: shareSelectedDays,
expiryDays: shareSelectedExpiryDays,
allowJournalNotes: shareAllowJournalNotes,
}),
},
"fe-share"
);
const res = await fetch("/api/share", init);
const res = await authFetch("/api/share", init);
if (res.ok) {
const data = await res.json();
const fullUrl = `${window.location.origin}/share/${data.token}`;
setShareLink(fullUrl);
void loadActiveShareLinks();
log.info("[ShareDialog] Share link ready", {
person: shareSelectedPerson,
days: shareSelectedDays,
expiryDays: shareSelectedExpiryDays,
allowJournalNotes: shareAllowJournalNotes,
reused: Boolean(data.reused),
correlationId,
});
@@ -106,15 +166,57 @@ export function useShare(): UseShareReturn {
error: err.error,
correlationId,
});
alert(err.error || "Failed to generate share link");
showFeedback({
message: err.error || t("share.generateFailed"),
tone: "error",
});
}
} catch (error) {
log.error("[ShareDialog] Share link request threw error", { person: shareSelectedPerson, error });
alert("Failed to generate share link");
showFeedback({ message: t("share.generateFailed"), tone: "error" });
} finally {
setShareGenerating(false);
}
}, [shareSelectedPerson, shareSelectedDays]);
}, [
authFetch,
loadActiveShareLinks,
shareAllowJournalNotes,
shareSelectedExpiryDays,
shareSelectedPerson,
shareSelectedDays,
showFeedback,
t,
]);
const revokeShareLink = useCallback(
async (token: string) => {
setRevokingShareToken(token);
try {
const response = await authFetch(`/api/share/${token}`, { method: "DELETE" });
if (!response.ok) {
const data = await response.json().catch(() => ({}));
showFeedback({
message: data.error || t("share.revokeFailed"),
tone: "error",
});
return false;
}
setActiveShareLinks((current) => current.filter((share) => share.token !== token));
if (shareLink?.endsWith(`/share/${token}`)) {
setShareLink(null);
setShareCopied(false);
}
return true;
} catch {
showFeedback({ message: t("share.revokeFailed"), tone: "error" });
return false;
} finally {
setRevokingShareToken(null);
}
},
[authFetch, shareLink, showFeedback, t]
);
const copyShareLink = useCallback(() => {
if (shareLink) {
@@ -168,6 +270,11 @@ export function useShare(): UseShareReturn {
setShowShareDialog(false);
setShareLink(null);
setShareCopied(false);
setShareSelectedExpiryDays(null);
setShareAllowJournalNotes(false);
setActiveShareLinks([]);
setActiveSharesLoading(false);
setRevokingShareToken(null);
}, []);
return {
@@ -177,13 +284,21 @@ export function useShare(): UseShareReturn {
setShareSelectedPerson,
shareSelectedDays,
setShareSelectedDays,
shareSelectedExpiryDays,
setShareSelectedExpiryDays,
shareAllowJournalNotes,
setShareAllowJournalNotes,
shareGenerating,
shareLink,
setShareLink,
shareCopied,
setShareCopied,
activeShareLinks,
activeSharesLoading,
revokingShareToken,
openShareDialog,
generateShareLink,
revokeShareLink,
copyShareLink,
closeShareDialog,
resetShareDialogState,
+113 -1
View File
@@ -102,6 +102,64 @@
"needsRefill": "Nachfüllen nötig"
}
},
"journal": {
"actions": {
"note": "Notiz",
"noteTakenOnly": "Notizen funktionieren nur für genommene oder uebersprungene Dosen.",
"history": "Journal-Verlauf",
"historyShort": "Journal"
},
"editor": {
"addTitle": "Journal-Notiz hinzufügen",
"editTitle": "Journal-Notiz bearbeiten",
"description": "Halte fest, was bei dieser Einnahme passiert ist, ohne den bestehenden Einnahme- oder Überspringen-Status zu ändern.",
"loading": "Journal-Eintrag wird geladen...",
"noteLabel": "Journal-Notiz",
"notePlaceholder": "Was möchtest du zu dieser Einnahme festhalten?",
"saving": "Speichern...",
"deleting": "Löschen..."
},
"history": {
"title": "Journal-Verlauf",
"description": "Durchsuche gespeicherte Einnahme-Notizen nach Medikament oder Zeitraum und öffne einen Eintrag erneut im Bearbeitungsmodus.",
"loading": "Journal-Verlauf wird geladen...",
"empty": "Keine Journal-Einträge passen zu den aktuellen Filtern.",
"noNote": "Keine Notiz gespeichert.",
"reload": "Neu laden",
"resetFilters": "Filter zurücksetzen",
"reopen": "Notiz erneut öffnen",
"updatedAt": "Aktualisiert {{date}}",
"filters": {
"medication": "Medikament",
"allMedications": "Alle Medikamente",
"from": "Von",
"to": "Bis",
"fromPlaceholder": "Startdatum",
"toPlaceholder": "Enddatum"
}
},
"context": {
"scheduledFor": "Geplant für",
"takenAt": "Eingenommen um",
"markedBy": "Markiert von",
"source": "Markiert ueber",
"sourceOwnerApp": "Haupt-App",
"sourceSharedLink": "Geteilter Einnahme-Link",
"sourceAutomaticReminder": "Automatische Erinnerungslogik",
"statusTaken": "Eingenommen",
"statusSkipped": "Übersprungen",
"notRecorded": "Nicht erfasst",
"self": "Du"
},
"errors": {
"loadFailed": "Der Journal-Eintrag konnte nicht geladen werden.",
"historyFailed": "Der Journal-Verlauf konnte nicht geladen werden.",
"saveFailed": "Die Journal-Notiz konnte nicht gespeichert werden.",
"deleteFailed": "Die Journal-Notiz konnte nicht gelöscht werden.",
"emptySharedNote": "Geteilte Links koennen Journal-Notizen nicht leeren. Gib eine Notiz ein oder schliesse den Dialog.",
"noEventSelected": "Es ist kein Journal-Eintrag ausgewählt."
}
},
"table": {
"name": "Name",
"pills": "Tabletten",
@@ -604,10 +662,16 @@
"deleteAccount": "Konto löschen",
"deleteAccountConfirmTitle": "Konto löschen?",
"deleteAccountConfirmText": "Dadurch werden dein Konto und alle deine Daten (Medikamente, Einstellungen, Verlauf) dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"deleteAccountButton": "Ja, mein Konto löschen"
"deleteAccountButton": "Ja, mein Konto löschen",
"connectionErrorTitle": "Verbindungsfehler",
"connectionErrorHelp": "Bitte prüfe, ob der Server läuft, und versuche es erneut.",
"sessionExpiredTitle": "Sitzung abgelaufen",
"sessionExpiredHelp": "Bitte melde dich erneut an, um mit deiner Besitzersitzung fortzufahren."
},
"common": {
"loading": "Wird geladen...",
"initializing": "Initialisierung...",
"retry": "Erneut versuchen",
"sending": "Wird gesendet...",
"sent": "Gesendet!",
"sendFailed": "Senden fehlgeschlagen",
@@ -632,6 +696,7 @@
"back": "Zurück",
"cancel": "Abbrechen",
"close": "Schließen",
"hide": "Ausblenden",
"edit": "Bearbeiten",
"view": "Ansehen",
"delete": "Löschen",
@@ -676,6 +741,13 @@
"allPeople": "Alle",
"selectPerson": "Person auswählen",
"selectPeriod": "Zeitraum auswählen",
"selectExpiry": "Link-Ablauf",
"allowJournalNotes": "Diesem geteilten Link das Anzeigen und Bearbeiten von Journal-Notizen erlauben",
"journalNotesEnabled": "Journal anzeigen/bearbeiten erlaubt",
"expiryNever": "Laeuft nicht ab",
"expiry7Days": "Laeuft in 7 Tagen ab",
"expiry30Days": "Laeuft in 30 Tagen ab",
"expiry90Days": "Laeuft in 90 Tagen ab",
"generateLink": "Link generieren",
"generating": "Wird generiert...",
"generateAnother": "Weiteren Link generieren",
@@ -685,9 +757,21 @@
"copyLink": "Link kopieren",
"copyOverviewLink": "Übersichts-Link kopieren",
"copied": "In Zwischenablage kopiert!",
"activeLinksTitle": "Aktive Teilen-Links",
"loadingActiveLinks": "Aktive Teilen-Links werden geladen...",
"noActiveLinks": "Noch keine aktiven Teilen-Links.",
"manageLinksSummary": "Aktive Teilen-Links verwalten",
"generateFailed": "Freigabelink konnte nicht erstellt werden",
"revokeFailed": "Freigabelink konnte nicht widerrufen werden",
"activeLinkMeta": "{{days}} Tage, erstellt {{createdAt}}",
"activeLinkMetaWithExpiry": "{{days}} Tage, erstellt {{createdAt}}, Ablauf {{expiresAt}}",
"revoke": "Widerrufen",
"revoking": "Wird widerrufen...",
"revokeConfirm": "Den aktiven Teilen-Link fuer {{person}} widerrufen?",
"noPeople": "Keine Medikamente mit 'Eingenommen von' zugewiesen. Füge zuerst eine Person zu einem Medikament hinzu.",
"scheduleFor": "Zeitplan für",
"period": "Zeitraum",
"publicAccessHelp": "Dieser Teilen-Link zeigt nur den ausgewaehlten Zeitplan und geteilte Dosisaktionen. Einstellungen und voller Kontozugriff bleiben in der Haupt-App.",
"noSchedule": "Keine geplanten Einnahmen gefunden.",
"generatedBy": "Erstellt von",
"notFound": "Teilen-Link nicht gefunden",
@@ -755,6 +839,24 @@
"confirmImportEmpty": "Daten importieren?",
"confirmImportEmptyMessage": "Alle Medikamente, Einnahmehistorie, Einstellungen und Teilen-Links aus der ausgewählten Datei werden importiert.",
"confirmButtonEmpty": "Importieren",
"reviewDescription": "Prüfe den validierten Sicherungsinhalt, bevor deine aktuellen Installationsdaten ersetzt werden.",
"reviewDescriptionEmpty": "Prüfe den validierten Sicherungsinhalt, bevor er in diese Installation importiert wird.",
"incomingData": "Importdatei",
"currentData": "Aktuelle Daten",
"summaryCounts": "{{medications}} Medikamente, {{doses}} Dosen, {{refills}} Nachfüllungen, {{shares}} Teilen-Links",
"formatVersion": "Formatversion: {{version}}",
"exportedAt": "Exportiert am: {{date}}",
"settingsIncluded": "Einstellungen enthalten",
"settingsConfigured": "Einstellungen aktuell konfiguriert",
"journalEntries": "{{count}} Journaleinträge",
"imageCount": "{{count}} eingebettete Bilder",
"warningListTitle": "Warnungen",
"warningReplaceData": "Deine aktuellen Medikamente, die Einnahmehistorie, Einstellungen und Teilen-Links werden ersetzt.",
"warningShareLinks": "Importierte Teilen-Links erhalten beim Wiederherstellen aus Sicherheitsgründen neue Tokens.",
"warningImages": "Eingebettete Bilder vergrößern den Import und können die Wiederherstellung verlängern.",
"warningSensitive": "Diese Sicherung enthält sensible Benachrichtigungsdaten.",
"backupFirst": "Aktuelle Sicherung zuerst herunterladen",
"backupHint": "Empfohlen: exportiere zuerst deine aktuellen Daten, bevor du den Import bestätigst.",
"cancelButton": "Abbrechen",
"exportSuccess": "Daten erfolgreich exportiert",
"importSuccess": "Daten erfolgreich importiert",
@@ -836,6 +938,9 @@
"button": "Bericht",
"title": "Medikamentenbericht",
"description": "Erstelle ein Dokument mit detaillierten Medikamenteninformationen für deinen Arzt oder deine persönlichen Unterlagen.",
"dateRange": "Zeitraum",
"from": "Von",
"until": "Bis",
"selectAll": "Alle auswählen",
"deselectAll": "Alle abwählen",
"activeMeds": "Aktive Medikamente",
@@ -845,12 +950,19 @@
"formatMd": "Markdown (.md)",
"formatPdf": "PDF (Drucken)",
"generate": "Erstellen",
"regenerate": "Vorschau aktualisieren",
"generating": "Wird erstellt...",
"download": "Herunterladen",
"preview": "Vorschau",
"previewDescription": "Prüfe den generierten Bericht vor dem Export.",
"invalidDateRange": "Wähle einen gültigen Zeitraum.",
"error": "Der Bericht konnte nicht erstellt werden. Bitte versuche es erneut.",
"noSelection": "Wähle mindestens ein Medikament aus",
"filterByPerson": "Bericht für",
"allPeople": "Alle Personen",
"docTitle": "Medikamentenbericht",
"docGenerated": "Erstellt am",
"docRange": "Berichtszeitraum",
"docGeneral": "Allgemein",
"docCommercialName": "Handelsname",
"docGenericName": "Wirkstoff",
+113 -1
View File
@@ -102,6 +102,64 @@
"needsRefill": "Needs refill"
}
},
"journal": {
"actions": {
"note": "Note",
"noteTakenOnly": "Notes are only available for taken or skipped doses.",
"history": "Journal history",
"historyShort": "Journal"
},
"editor": {
"addTitle": "Add journal note",
"editTitle": "Edit journal note",
"description": "Capture what happened for this intake without changing the existing take or skip status.",
"loading": "Loading journal entry...",
"noteLabel": "Journal note",
"notePlaceholder": "What should you remember about this intake?",
"saving": "Saving...",
"deleting": "Deleting..."
},
"history": {
"title": "Journal history",
"description": "Browse saved intake notes by medication or date, then reopen an entry in edit mode.",
"loading": "Loading journal history...",
"empty": "No journal entries match the current filters.",
"noNote": "No note saved.",
"reload": "Reload",
"resetFilters": "Reset filters",
"reopen": "Reopen note",
"updatedAt": "Updated {{date}}",
"filters": {
"medication": "Medication",
"allMedications": "All medications",
"from": "From",
"to": "To",
"fromPlaceholder": "Start date",
"toPlaceholder": "End date"
}
},
"context": {
"scheduledFor": "Scheduled for",
"takenAt": "Taken at",
"markedBy": "Marked by",
"source": "Marked via",
"sourceOwnerApp": "Main app",
"sourceSharedLink": "Shared intake link",
"sourceAutomaticReminder": "Automatic reminder logic",
"statusTaken": "Taken",
"statusSkipped": "Skipped",
"notRecorded": "Not recorded",
"self": "You"
},
"errors": {
"loadFailed": "Journal entry could not be loaded.",
"historyFailed": "Journal history could not be loaded.",
"saveFailed": "Journal note could not be saved.",
"deleteFailed": "Journal note could not be deleted.",
"emptySharedNote": "Shared links cannot clear journal notes. Enter a note or close the dialog.",
"noEventSelected": "No journal entry is selected."
}
},
"table": {
"name": "Name",
"pills": "Pills",
@@ -604,10 +662,16 @@
"deleteAccount": "Delete Account",
"deleteAccountConfirmTitle": "Delete Account?",
"deleteAccountConfirmText": "This will permanently delete your account and all your data (medications, settings, history). This action cannot be undone.",
"deleteAccountButton": "Yes, delete my account"
"deleteAccountButton": "Yes, delete my account",
"connectionErrorTitle": "Connection Error",
"connectionErrorHelp": "Please check if the server is running and try again.",
"sessionExpiredTitle": "Session expired",
"sessionExpiredHelp": "Please sign in again to continue your owner session."
},
"common": {
"loading": "Loading...",
"initializing": "Initializing...",
"retry": "Retry",
"sending": "Sending...",
"sent": "Sent!",
"sendFailed": "Failed to send",
@@ -632,6 +696,7 @@
"back": "Back",
"cancel": "Cancel",
"close": "Close",
"hide": "Hide",
"edit": "Edit",
"view": "View",
"delete": "Delete",
@@ -676,6 +741,13 @@
"allPeople": "Everyone",
"selectPerson": "Select person",
"selectPeriod": "Select time period",
"selectExpiry": "Link expiry",
"allowJournalNotes": "Allow this shared link to view and edit journal notes",
"journalNotesEnabled": "Journal view/edit enabled",
"expiryNever": "Never expires",
"expiry7Days": "Expires in 7 days",
"expiry30Days": "Expires in 30 days",
"expiry90Days": "Expires in 90 days",
"generateLink": "Generate Link",
"generating": "Generating...",
"generateAnother": "Generate another link",
@@ -685,9 +757,21 @@
"copyLink": "Copy Link",
"copyOverviewLink": "Copy Overview Link",
"copied": "Copied to clipboard!",
"activeLinksTitle": "Active share links",
"loadingActiveLinks": "Loading active share links...",
"noActiveLinks": "No active share links yet.",
"manageLinksSummary": "Manage active share links",
"generateFailed": "Failed to generate share link",
"revokeFailed": "Failed to revoke share link",
"activeLinkMeta": "{{days}} days, created {{createdAt}}",
"activeLinkMetaWithExpiry": "{{days}} days, created {{createdAt}}, expires {{expiresAt}}",
"revoke": "Revoke",
"revoking": "Revoking...",
"revokeConfirm": "Revoke the active share link for {{person}}?",
"noPeople": "No medications with 'Taken by' assigned. Add a person to a medication first.",
"scheduleFor": "Schedule for",
"period": "Period",
"publicAccessHelp": "This shared link only exposes the selected schedule and shared dose actions. Owner settings and full account access stay in the main app.",
"noSchedule": "No scheduled doses found.",
"generatedBy": "Generated by",
"notFound": "Share link not found",
@@ -755,6 +839,24 @@
"confirmImportEmpty": "Import Data?",
"confirmImportEmptyMessage": "This will import all medications, dose history, settings, and share links from the selected file.",
"confirmButtonEmpty": "Import",
"reviewDescription": "Review the validated backup contents before replacing your current installation data.",
"reviewDescriptionEmpty": "Review the validated backup contents before importing them into this installation.",
"incomingData": "Import file",
"currentData": "Current data",
"summaryCounts": "{{medications}} medications, {{doses}} doses, {{refills}} refills, {{shares}} share links",
"formatVersion": "Format version: {{version}}",
"exportedAt": "Exported at: {{date}}",
"settingsIncluded": "Settings included",
"settingsConfigured": "Settings currently configured",
"journalEntries": "{{count}} journal entries",
"imageCount": "{{count}} embedded images",
"warningListTitle": "Warnings",
"warningReplaceData": "Your current medications, dose history, settings, and share links will be replaced.",
"warningShareLinks": "Imported share links will get new tokens during restore for security.",
"warningImages": "Embedded images increase import size and may take longer to restore.",
"warningSensitive": "This backup includes sensitive notification data.",
"backupFirst": "Download current backup first",
"backupHint": "Recommended: export your current data before confirming the import.",
"cancelButton": "Cancel",
"exportSuccess": "Data exported successfully",
"importSuccess": "Data imported successfully",
@@ -836,6 +938,9 @@
"button": "Report",
"title": "Medication Report",
"description": "Generate a document with detailed medication information for your doctor or personal records.",
"dateRange": "Date range",
"from": "From",
"until": "Until",
"selectAll": "Select all",
"deselectAll": "Deselect all",
"activeMeds": "Active Medications",
@@ -845,12 +950,19 @@
"formatMd": "Markdown (.md)",
"formatPdf": "PDF (Print)",
"generate": "Generate",
"regenerate": "Refresh preview",
"generating": "Generating...",
"download": "Download",
"preview": "Preview",
"previewDescription": "Review the generated report before exporting it.",
"invalidDateRange": "Choose a valid date range.",
"error": "Could not generate the report. Please try again.",
"noSelection": "Select at least one medication",
"filterByPerson": "Report for",
"allPeople": "Everyone",
"docTitle": "Medication Report",
"docGenerated": "Generated on",
"docRange": "Report range",
"docGeneral": "General",
"docCommercialName": "Commercial Name",
"docGenericName": "Generic Name",
+1
View File
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./styles.css";
import "./styles/intake-journal.css";
import "./styles/modals-base.css";
import "./styles/share-dialog.css";
import "./styles/medication-workflows.css";
+137 -19
View File
@@ -3,11 +3,13 @@ import { Archive, Bell, ClipboardList, NotebookPen, Share2 } from "lucide-react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { ConfirmModal, MedicationAvatar } from "../components";
import { ConfirmModal, IntakeJournalHistoryModal, IntakeJournalModal, MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
import { DashboardReminderSection } from "../components/dashboard/DashboardReminderSection";
import { DashboardStatusSection } from "../components/dashboard/DashboardStatusSection";
import { useAppContext } from "../context";
import { useFeedback } from "../context/FeedbackContext";
import { useModalHistory } from "../hooks";
import {
allowsPillFormSelection,
getMedDisplayName,
@@ -75,7 +77,8 @@ const EMPTY_DOSE_SET = new Set<string>();
export function DashboardPage() {
const { t, i18n } = useTranslation();
const { user } = useAuth();
const { user, authFetch } = useAuth();
const { showFeedback } = useFeedback();
const location = useLocation();
const {
meds,
@@ -112,6 +115,26 @@ export function DashboardPage() {
openUserFilter,
openShareDialog,
openScheduleLightbox,
journalEditorOpen,
journalHistoryOpen,
journalEvent,
journalEventLoading,
journalEventSaving,
journalEventDeleting,
journalEventError,
journalHistoryEntries,
journalHistoryFilters,
journalHistoryLoading,
journalHistoryError,
openJournalEditor,
closeJournalEditor,
saveJournalNote,
deleteJournalNote,
openJournalHistory,
closeJournalHistory,
setJournalHistoryFilters,
reloadJournalHistory,
reopenJournalHistoryEntry,
stockThresholds,
loadMeds,
loadSettings,
@@ -121,6 +144,21 @@ export function DashboardPage() {
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
const [obsoleteCandidate, setObsoleteCandidate] = useState<{ id: number; name: string } | null>(null);
const notificationFocusAppliedRef = useRef<string | null>(null);
const closeClearMissedConfirm = useCallback(() => {
if (!clearingMissed) {
setShowClearMissedConfirm(false);
}
}, [clearingMissed]);
const closeObsoleteConfirm = useCallback(() => {
setShowObsoleteConfirm(false);
setObsoleteCandidate(null);
}, []);
useModalHistory(showClearMissedConfirm, "dashboard-clear-missed", closeClearMissedConfirm);
useModalHistory(showObsoleteConfirm, "dashboard-obsolete", closeObsoleteConfirm);
const effectiveSkippedDoses =
skippedDoses instanceof Set ? skippedDoses : dismissedDoses instanceof Set ? dismissedDoses : EMPTY_DOSE_SET;
const canManageSkippedDoses = typeof markDoseSkipped === "function" && typeof undoDoseSkipped === "function";
@@ -333,9 +371,8 @@ export function DashboardPage() {
setClearingMissed(true);
try {
const res = await fetch("/api/medications/dismiss-until", {
const res = await authFetch("/api/medications/dismiss-until", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
@@ -344,14 +381,37 @@ export function DashboardPage() {
}
await loadMeds();
setShowClearMissedConfirm(false);
alert(t("dashboard.schedules.clearMissedSuccess", { count: missedCount }));
showFeedback({
message: t("dashboard.schedules.clearMissedSuccess", { count: missedCount }),
tone: "success",
});
} catch {
alert(t("common.saveFailed"));
showFeedback({ message: t("common.saveFailed"), tone: "error" });
} finally {
setClearingMissed(false);
}
};
const handleSaveJournalNote = async (note: string) => {
return saveJournalNote(note);
};
const handleDeleteJournalNote = async () => {
const deleted = await deleteJournalNote();
if (deleted) {
closeJournalEditor();
}
};
const handleResetJournalFilters = () => {
setJournalHistoryFilters({
medicationId: null,
from: "",
to: "",
limit: 100,
});
};
const renderDoseActionButtons = (options: {
doseId: string;
isTaken: boolean;
@@ -359,6 +419,7 @@ export function DashboardPage() {
isAutomaticallyTaken: boolean;
isEmpty: boolean;
}) => {
const journalUnavailable = !(options.isTaken || options.isSkipped);
const takeButton = options.isTaken ? (
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
{options.isAutomaticallyTaken && (
@@ -381,8 +442,35 @@ export function DashboardPage() {
</button>
);
const journalButton = (
<span
className={journalUnavailable ? "tooltip-trigger" : undefined}
data-tooltip={journalUnavailable ? t("journal.actions.noteTakenOnly") : undefined}
>
<button
type="button"
className="dose-btn journal"
onClick={() => {
if (!journalUnavailable) {
void openJournalEditor(options.doseId);
}
}}
title={!journalUnavailable ? t("journal.actions.note") : undefined}
disabled={journalUnavailable}
>
<NotebookPen size={14} aria-hidden="true" />
<span className="dose-btn-label">{t("journal.actions.note")}</span>
</button>
</span>
);
if (!canManageSkippedDoses) {
return takeButton;
return (
<>
{takeButton}
{journalButton}
</>
);
}
const skipButton = options.isSkipped ? (
@@ -405,6 +493,7 @@ export function DashboardPage() {
<>
{takeButton}
{skipButton}
{journalButton}
</>
);
};
@@ -417,22 +506,20 @@ export function DashboardPage() {
const handleConfirmMarkObsolete = async () => {
if (!obsoleteCandidate) return;
try {
const res = await fetch(`/api/medications/${obsoleteCandidate.id}/obsolete`, {
const res = await authFetch(`/api/medications/${obsoleteCandidate.id}/obsolete`, {
method: "POST",
credentials: "include",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await loadMeds();
setShowObsoleteConfirm(false);
setObsoleteCandidate(null);
} catch {
alert(t("common.saveFailed"));
showFeedback({ message: t("common.saveFailed"), tone: "error" });
}
};
const handleCancelMarkObsolete = () => {
setShowObsoleteConfirm(false);
setObsoleteCandidate(null);
closeObsoleteConfirm();
};
const getDiscreteUnitLabel = (packageType: string | undefined, count: number) => {
@@ -619,10 +706,9 @@ export function DashboardPage() {
};
});
const stockRes = await fetch("/api/reminder/send-email", {
const stockRes = await authFetch("/api/reminder/send-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
email: settings.notificationEmail,
lowStock,
@@ -647,10 +733,9 @@ export function DashboardPage() {
};
});
const prescriptionRes = await fetch("/api/reminder/send-prescription", {
const prescriptionRes = await authFetch("/api/reminder/send-prescription", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
email: settings.notificationEmail,
prescriptionLow,
@@ -913,6 +998,17 @@ export function DashboardPage() {
<option value={90}>{t("dashboard.schedules.3months")}</option>
<option value={180}>{t("dashboard.schedules.6months")}</option>
</select>
<button
type="button"
className="ghost journal-history-button"
onClick={openJournalHistory}
aria-label={t("journal.actions.history")}
title={t("journal.actions.history")}
>
<ClipboardList size={16} aria-hidden="true" />
<span className="journal-history-label-full">{t("journal.actions.history")}</span>
<span className="journal-history-label-short">{t("journal.actions.historyShort")}</span>
</button>
{meds.some((m) => m.takenBy && m.takenBy.length > 0) && (
<button
className="ghost share-btn icon-only tooltip-trigger"
@@ -1229,9 +1325,7 @@ export function DashboardPage() {
confirmLabel={t("dashboard.schedules.clearMissedConfirm")}
cancelLabel={t("dashboard.schedules.clearMissedCancel")}
onConfirm={() => void clearMissedDoses(missedPastDoseIds.length)}
onCancel={() => {
if (!clearingMissed) setShowClearMissedConfirm(false);
}}
onCancel={closeClearMissedConfirm}
isLoading={clearingMissed}
confirmVariant="warning"
/>
@@ -1741,6 +1835,30 @@ export function DashboardPage() {
})}
</div>
)}
<IntakeJournalModal
isOpen={journalEditorOpen}
entry={journalEvent}
isLoading={journalEventLoading}
isSaving={journalEventSaving}
isDeleting={journalEventDeleting}
error={journalEventError}
onClose={closeJournalEditor}
onSave={handleSaveJournalNote}
onDelete={handleDeleteJournalNote}
/>
<IntakeJournalHistoryModal
isOpen={journalHistoryOpen}
entries={journalHistoryEntries}
filters={journalHistoryFilters}
medications={meds}
isLoading={journalHistoryLoading}
error={journalHistoryError}
onClose={closeJournalHistory}
onFilterChange={setJournalHistoryFilters}
onReload={reloadJournalHistory}
onResetFilters={handleResetJournalFilters}
onReopen={reopenJournalHistoryEntry}
/>
</article>
</section>
</div>
+19 -14
View File
@@ -11,6 +11,7 @@ import { MedicationDialogs } from "../components/medications/MedicationDialogs";
import { MedicationEditCoordinator } from "../components/medications/MedicationEditCoordinator";
import { MedicationListSection } from "../components/medications/MedicationListSection";
import { useAppContext, useUnsavedChanges } from "../context";
import { useFeedback } from "../context/FeedbackContext";
import {
MEDICATION_ENRICHMENT_INITIAL_LIMIT,
MEDICATION_ENRICHMENT_LIMIT_STEP,
@@ -222,7 +223,8 @@ async function getMedicationEnrichmentErrorMessage(
export function MedicationsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const { t } = useTranslation();
const { user } = useAuth();
const { user, authFetch } = useAuth();
const { showFeedback } = useFeedback();
const {
meds,
saving,
@@ -274,6 +276,7 @@ export function MedicationsPage() {
);
const [viewMode, setViewMode] = useState<"grid" | "form">(pendingEditTransition ? "form" : "grid");
const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null);
const closeLightbox = useCallback(() => setLightboxImage(null), []);
const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general");
// Mobile modal state (declared early because it's used in useEffect below)
@@ -394,9 +397,7 @@ export function MedicationsPage() {
try {
const params = new URLSearchParams({ q: trimmedQuery, limit: String(limit) });
const response = await fetch(`/api/medication-enrichment/search?${params.toString()}`, {
credentials: "include",
});
const response = await authFetch(`/api/medication-enrichment/search?${params.toString()}`);
if (!response.ok) {
throw new Error(
@@ -458,7 +459,7 @@ export function MedicationsPage() {
}));
}
},
[medicationEnrichment.query, medicationEnrichment.results, t]
[authFetch, medicationEnrichment.query, medicationEnrichment.results, t]
);
const handlePendingMedicationImageSelection = useCallback(
@@ -489,6 +490,8 @@ export function MedicationsPage() {
const [readOnlyView, setReadOnlyView] = useState(false);
const [showReportModal, setShowReportModal] = useState(false);
useModalHistory(showReportModal, "report", () => setShowReportModal(false));
useModalHistory(!!lightboxImage, "medication-image-lightbox", closeLightbox);
useModalHistory(showUnsavedConfirm, "medication-unsaved-confirm", handleCancelClose);
const [showNameValidation, setShowNameValidation] = useState(false);
useEffect(() => {
@@ -517,13 +520,13 @@ export function MedicationsPage() {
const loadAllMeds = useCallback(async () => {
try {
const res = await fetch("/api/medications?includeObsolete=true", { credentials: "include" });
const res = await authFetch("/api/medications?includeObsolete=true");
const data = (await res.json()) as unknown;
setAllMeds(Array.isArray(data) ? (data as Medication[]) : []);
} catch {
setAllMeds([]);
}
}, []);
}, [authFetch]);
useEffect(() => {
void loadAllMeds();
@@ -617,7 +620,7 @@ export function MedicationsPage() {
}));
try {
const response = await fetch("/api/medication-enrichment/enrich", {
const response = await authFetch("/api/medication-enrichment/enrich", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -627,7 +630,6 @@ export function MedicationsPage() {
code: result.code,
source: result.source,
}),
credentials: "include",
});
if (!response.ok) {
@@ -699,7 +701,7 @@ export function MedicationsPage() {
}));
}
},
[form, medicationEnrichment.query, setForm, t]
[authFetch, form, medicationEnrichment.query, setForm, t]
);
const handleMedicationEnrichmentStrengthApply = useCallback(
@@ -1018,7 +1020,7 @@ export function MedicationsPage() {
async function markMedicationObsolete(id: number) {
try {
await fetch(`/api/medications/${id}/obsolete`, { method: "POST", credentials: "include" });
await authFetch(`/api/medications/${id}/obsolete`, { method: "POST" });
if (editingId === id) {
handleResetForm();
}
@@ -1031,7 +1033,7 @@ export function MedicationsPage() {
async function reactivateMedication(id: number) {
try {
await fetch(`/api/medications/${id}/reactivate`, { method: "POST", credentials: "include" });
await authFetch(`/api/medications/${id}/reactivate`, { method: "POST" });
loadMeds();
await loadAllMeds();
} catch {
@@ -1229,7 +1231,10 @@ export function MedicationsPage() {
}
} catch (err) {
log.error("Save error:", err);
alert(err instanceof Error && err.message ? err.message : t("common.saveFailed"));
showFeedback({
message: err instanceof Error && err.message ? err.message : t("common.saveFailed"),
tone: "error",
});
}
setSaving(false);
@@ -2314,7 +2319,7 @@ export function MedicationsPage() {
onCancelDelete={handleCancelDelete}
showEditModal={showEditModal}
lightboxImage={lightboxImage}
onCloseLightbox={() => setLightboxImage(null)}
onCloseLightbox={closeLightbox}
showReportModal={showReportModal}
onCloseReportModal={() => setShowReportModal(false)}
medications={allMeds}
+3 -5
View File
@@ -33,7 +33,7 @@ function userStorageKey(userId: number | undefined, key: string): string {
export function PlannerPage() {
const { t } = useTranslation();
const { user } = useAuth();
const { user, authFetch } = useAuth();
const { meds, settings, openMedDetail } = useAppContext();
// Local state for planner
@@ -90,10 +90,9 @@ export function PlannerPage() {
e.preventDefault();
setPlannerLoading(true);
const body = { startDate: toIsoString(range.start), endDate: toIsoString(range.end), includeUntilStart };
const rows = (await fetch("/api/medications/usage", {
const rows = (await authFetch("/api/medications/usage", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body),
})
.then((res) => res.json())
@@ -158,10 +157,9 @@ export function PlannerPage() {
setPlannerEmailResult(null);
try {
const res = await fetch("/api/planner/send-email", {
const res = await authFetch("/api/planner/send-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
email: settings.notificationEmail,
from: range.start,
+145 -30
View File
@@ -1,12 +1,13 @@
/* biome-ignore-all lint/style/noNestedTernary: schedule timeline branches are intentionally explicit */
import { Archive, Bell } from "lucide-react";
import { useState } from "react";
import { Archive, Bell, ClipboardList, NotebookPen } from "lucide-react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfirmModal, MedicationAvatar } from "../components";
import { ConfirmModal, IntakeJournalHistoryModal, IntakeJournalModal, MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
import { useFeedback } from "../context/FeedbackContext";
import { ScheduleUsageTag } from "../features/schedule/components";
import { formatScheduleDoseUsageLabel, formatScheduleTotalUsageLabel } from "../features/schedule/formatters";
import { useScheduleController } from "../hooks";
import { useModalHistory, useScheduleController } from "../hooks";
import type { Coverage, IntakeUnit } from "../types";
import { getMedDisplayName, isLiquidContainerPackageType, isTubePackageType } from "../types";
import { buildClearMissedPayload, isDoseDismissed } from "../utils/schedule";
@@ -71,7 +72,8 @@ function getDoseId(baseId: string, person: string | null): string {
export function SchedulePage() {
const { t } = useTranslation();
const { user } = useAuth();
const { user, authFetch } = useAuth();
const { showFeedback } = useFeedback();
const {
meds,
settings,
@@ -96,12 +98,46 @@ export function SchedulePage() {
openUserFilter,
missedPastDoseIds,
loadMeds,
journalEditorOpen,
journalHistoryOpen,
journalEvent,
journalEventLoading,
journalEventSaving,
journalEventDeleting,
journalEventError,
journalHistoryEntries,
journalHistoryFilters,
journalHistoryLoading,
journalHistoryError,
openJournalEditor,
closeJournalEditor,
saveJournalNote,
deleteJournalNote,
openJournalHistory,
closeJournalHistory,
setJournalHistoryFilters,
reloadJournalHistory,
reopenJournalHistoryEntry,
} = useScheduleController();
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
const [clearingMissed, setClearingMissed] = useState(false);
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
const [obsoleteCandidate, setObsoleteCandidate] = useState<{ id: number; name: string } | null>(null);
const closeClearMissedConfirm = useCallback(() => {
if (!clearingMissed) {
setShowClearMissedConfirm(false);
}
}, [clearingMissed]);
const closeObsoleteConfirm = useCallback(() => {
setShowObsoleteConfirm(false);
setObsoleteCandidate(null);
}, []);
useModalHistory(showClearMissedConfirm, "schedule-clear-missed", closeClearMissedConfirm);
useModalHistory(showObsoleteConfirm, "schedule-obsolete", closeObsoleteConfirm);
const isDoseTakenForDisplay = (doseId: string) => takenDoses.has(doseId);
const shouldHideNoScheduleStatusForTube = (
@@ -118,9 +154,8 @@ export function SchedulePage() {
setClearingMissed(true);
try {
const res = await fetch("/api/medications/dismiss-until", {
const res = await authFetch("/api/medications/dismiss-until", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
@@ -129,14 +164,37 @@ export function SchedulePage() {
}
await loadMeds();
setShowClearMissedConfirm(false);
alert(t("dashboard.schedules.clearMissedSuccess", { count: missedCount }));
showFeedback({
message: t("dashboard.schedules.clearMissedSuccess", { count: missedCount }),
tone: "success",
});
} catch {
alert(t("common.saveFailed"));
showFeedback({ message: t("common.saveFailed"), tone: "error" });
} finally {
setClearingMissed(false);
}
};
const handleSaveJournalNote = async (note: string) => {
return saveJournalNote(note);
};
const handleDeleteJournalNote = async () => {
const deleted = await deleteJournalNote();
if (deleted) {
closeJournalEditor();
}
};
const handleResetJournalFilters = () => {
setJournalHistoryFilters({
medicationId: null,
from: "",
to: "",
limit: 100,
});
};
const requestMarkObsolete = (med: { id: number; name: string }) => {
setObsoleteCandidate(med);
setShowObsoleteConfirm(true);
@@ -145,22 +203,20 @@ export function SchedulePage() {
const handleConfirmMarkObsolete = async () => {
if (!obsoleteCandidate) return;
try {
const res = await fetch(`/api/medications/${obsoleteCandidate.id}/obsolete`, {
const res = await authFetch(`/api/medications/${obsoleteCandidate.id}/obsolete`, {
method: "POST",
credentials: "include",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await loadMeds();
setShowObsoleteConfirm(false);
setObsoleteCandidate(null);
} catch {
alert(t("common.saveFailed"));
showFeedback({ message: t("common.saveFailed"), tone: "error" });
}
};
const handleCancelMarkObsolete = () => {
setShowObsoleteConfirm(false);
setObsoleteCandidate(null);
closeObsoleteConfirm();
};
const formatDoseUsageLabel = (
@@ -182,6 +238,7 @@ export function SchedulePage() {
isAutomaticallyTaken: boolean;
isEmpty: boolean;
}) => {
const journalUnavailable = !(options.isTaken || options.isSkipped);
const takeButton = options.isTaken ? (
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
{options.isAutomaticallyTaken && (
@@ -220,10 +277,33 @@ export function SchedulePage() {
</button>
);
const journalButton = (
<span
className={journalUnavailable ? "tooltip-trigger" : undefined}
data-tooltip={journalUnavailable ? t("journal.actions.noteTakenOnly") : undefined}
>
<button
type="button"
className="dose-btn journal"
onClick={() => {
if (!journalUnavailable) {
void openJournalEditor(options.doseId);
}
}}
title={!journalUnavailable ? t("journal.actions.note") : undefined}
disabled={journalUnavailable}
>
<NotebookPen size={14} aria-hidden="true" />
<span className="dose-btn-label">{t("journal.actions.note")}</span>
</button>
</span>
);
return (
<>
{takeButton}
{skipButton}
{journalButton}
</>
);
};
@@ -233,19 +313,32 @@ export function SchedulePage() {
<article className="card schedule-full">
<div className="card-head">
<h2>{t("dashboard.schedules.title")}</h2>
<select
className="select-field schedule-days-select"
value={scheduleDays}
onChange={(e) => {
const val = Number(e.target.value);
setScheduleDays(val);
if (user?.id) localStorage.setItem(userStorageKey(user.id, "scheduleDays"), String(val));
}}
>
<option value={30}>{t("dashboard.schedules.1month")}</option>
<option value={90}>{t("dashboard.schedules.3months")}</option>
<option value={180}>{t("dashboard.schedules.6months")}</option>
</select>
<div className="card-head-actions">
<select
className="select-field schedule-days-select"
value={scheduleDays}
onChange={(e) => {
const val = Number(e.target.value);
setScheduleDays(val);
if (user?.id) localStorage.setItem(userStorageKey(user.id, "scheduleDays"), String(val));
}}
>
<option value={30}>{t("dashboard.schedules.1month")}</option>
<option value={90}>{t("dashboard.schedules.3months")}</option>
<option value={180}>{t("dashboard.schedules.6months")}</option>
</select>
<button
type="button"
className="ghost journal-history-button"
onClick={openJournalHistory}
aria-label={t("journal.actions.history")}
title={t("journal.actions.history")}
>
<ClipboardList size={16} aria-hidden="true" />
<span className="journal-history-label-full">{t("journal.actions.history")}</span>
<span className="journal-history-label-short">{t("journal.actions.historyShort")}</span>
</button>
</div>
</div>
<div className="timeline">
{/* Past days (when expanded) — rendered above toggle */}
@@ -482,9 +575,7 @@ export function SchedulePage() {
confirmLabel={t("dashboard.schedules.clearMissedConfirm")}
cancelLabel={t("dashboard.schedules.clearMissedCancel")}
onConfirm={() => void clearMissedDoses(missedPastDoseIds.length)}
onCancel={() => {
if (!clearingMissed) setShowClearMissedConfirm(false);
}}
onCancel={closeClearMissedConfirm}
isLoading={clearingMissed}
confirmVariant="warning"
/>
@@ -630,6 +721,30 @@ export function SchedulePage() {
);
})}
</div>
<IntakeJournalModal
isOpen={journalEditorOpen}
entry={journalEvent}
isLoading={journalEventLoading}
isSaving={journalEventSaving}
isDeleting={journalEventDeleting}
error={journalEventError}
onClose={closeJournalEditor}
onSave={handleSaveJournalNote}
onDelete={handleDeleteJournalNote}
/>
<IntakeJournalHistoryModal
isOpen={journalHistoryOpen}
entries={journalHistoryEntries}
filters={journalHistoryFilters}
medications={meds}
isLoading={journalHistoryLoading}
error={journalHistoryError}
onClose={closeJournalHistory}
onFilterChange={setJournalHistoryFilters}
onReload={reloadJournalHistory}
onResetFilters={handleResetJournalFilters}
onReopen={reopenJournalHistoryEntry}
/>
</article>
</section>
);
+37 -38
View File
@@ -1,12 +1,15 @@
/* biome-ignore-all lint/a11y/noLabelWithoutControl: settings rows use label-styled text with adjacent custom toggle controls */
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfirmModal, ExportModal } from "../components";
import { ExportModal, ImportReviewModal } from "../components";
import { useAuth } from "../components/Auth";
import { useAppContext } from "../context";
import { useModalHistory } from "../hooks";
import { getSystemLocale, withFormattingTimezone } from "../utils/formatters";
export function SettingsPage() {
const { t, i18n } = useTranslation();
const { authFetch } = useAuth();
const [apiKeyToken, setApiKeyToken] = useState("");
const [apiKeyGenerating, setApiKeyGenerating] = useState(false);
const [apiKeyCopied, setApiKeyCopied] = useState(false);
@@ -37,15 +40,32 @@ export function SettingsPage() {
showImportConfirm,
setShowImportConfirm,
setPendingImportData,
importPreview,
setImportPreview,
handleImportConfirm,
importResult,
setImportResult,
meds,
} = useAppContext();
const [timezoneTouched, setTimezoneTouched] = useState(false);
const [timezoneDraft, setTimezoneDraft] = useState("");
const hasExistingData = meds.length > 0;
const formattedImportPreviewDate = importPreview
? new Date(importPreview.exportedAt).toLocaleString(getSystemLocale(i18n.language))
: "";
const closeExportModal = useCallback(() => {
setShowExportModal(false);
}, [setShowExportModal]);
const closeImportReview = useCallback(() => {
setShowImportConfirm(false);
setPendingImportData(null);
setImportPreview(null);
}, [setImportPreview, setPendingImportData, setShowImportConfirm]);
useModalHistory(showExportModal, "export-options", closeExportModal);
useModalHistory(showImportConfirm, "import-review", closeImportReview);
let emailUnavailableReason: string | null = null;
if (settingsLoadError === "auth") {
emailUnavailableReason = t("settings.email.loadErrorAuth");
@@ -63,10 +83,9 @@ export function SettingsPage() {
setApiKeyCopied(false);
try {
const response = await fetch("/api/auth/api-keys", {
const response = await authFetch("/api/auth/api-keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
name: "Default API Key",
scope: "write",
@@ -195,10 +214,9 @@ export function SettingsPage() {
onChange={(e) => {
const lang = e.target.value;
i18n.changeLanguage(lang);
fetch("/api/settings/language", {
authFetch("/api/settings/language", {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ language: lang }),
});
}}
@@ -1142,38 +1160,19 @@ export function SettingsPage() {
</div>
)}
{/* Import Confirmation Modal */}
{showImportConfirm && (
<ConfirmModal
title={t(hasExistingData ? "exportImport.confirmImport" : "exportImport.confirmImportEmpty")}
message={
hasExistingData ? (
<>
<p style={{ marginBottom: "12px" }}>{t("exportImport.confirmImportMessage")}</p>
<p className="warning-text"> {t("exportImport.confirmImportWarning")}</p>
</>
) : (
<p>{t("exportImport.confirmImportEmptyMessage")}</p>
)
}
confirmLabel={t(hasExistingData ? "exportImport.confirmButton" : "exportImport.confirmButtonEmpty")}
cancelLabel={t("exportImport.cancelButton")}
onConfirm={handleImportConfirm}
onCancel={() => {
setShowImportConfirm(false);
setPendingImportData(null);
}}
confirmVariant={hasExistingData ? "danger" : "primary"}
/>
)}
<ImportReviewModal
isOpen={showImportConfirm}
importPreview={importPreview}
formattedExportedAt={formattedImportPreviewDate}
importing={importing}
exporting={exporting}
onClose={closeImportReview}
onBackup={() => handleExport(true)}
onConfirm={handleImportConfirm}
/>
{/* Export Options Modal */}
<ExportModal
isOpen={showExportModal}
onClose={() => setShowExportModal(false)}
onExport={handleExport}
exporting={exporting}
/>
<ExportModal isOpen={showExportModal} onClose={closeExportModal} onExport={handleExport} exporting={exporting} />
</section>
);
}
+1
View File
@@ -5,6 +5,7 @@
Add new shared styles to the focused partial that owns the relevant domain.
============================================================================= */
@import url("./styles/foundation.css");
@import url("./styles/feedback.css");
@import url("./styles/app-surfaces.css");
@import url("./styles/settings-surfaces.css");
@import url("./styles/modal-detail.css");
+91
View File
@@ -284,6 +284,37 @@ a.about-version-link:hover {
margin: 0 0 1.25rem;
}
.report-range {
margin-bottom: 1.25rem;
}
.report-range h4 {
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted);
margin: 0 0 0.5rem;
}
.report-range-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.report-range-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.report-range-field .date-input-wrapper {
width: 100%;
}
/* Person filter */
.report-person-filter {
margin-bottom: 1.25rem;
@@ -448,6 +479,60 @@ a.about-version-link:hover {
display: none;
}
.report-error {
margin: 0 0 1rem;
padding: 0.75rem 0.9rem;
border-radius: 10px;
background: color-mix(in srgb, var(--danger-bg, #fee2e2) 75%, transparent);
color: var(--danger-text, #b91c1c);
font-size: 0.9rem;
}
.report-preview {
margin-bottom: 1.25rem;
padding: 0.9rem;
border: 1px solid var(--border-primary);
border-radius: 10px;
background: color-mix(in srgb, var(--bg-secondary) 75%, var(--bg-tertiary));
}
.report-preview-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.35rem;
}
.report-preview-header h4 {
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted);
margin: 0;
}
.report-preview-desc {
margin: 0 0 0.75rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.report-preview-content {
margin: 0;
padding: 0.85rem;
border-radius: 8px;
background: var(--bg-primary);
border: 1px solid var(--border-secondary);
max-height: 280px;
overflow: auto;
font-size: 0.82rem;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
/* Actions */
.report-actions {
display: flex;
@@ -456,3 +541,9 @@ a.about-version-link:hover {
padding-top: 0.75rem;
border-top: 1px solid var(--border-primary);
}
@media (max-width: 720px) {
.report-range-grid {
grid-template-columns: 1fr;
}
}
+197 -30
View File
@@ -2919,48 +2919,74 @@ button.has-validation-error {
.time-row {
grid-template-columns: 1fr;
gap: 0.5rem;
min-width: 0;
}
.doses-col {
flex-direction: row;
flex-wrap: wrap;
flex-direction: column;
flex-wrap: nowrap;
width: 100%;
min-width: 0;
}
.dose-item {
flex: 1 1 auto;
min-width: 140px;
gap: 0.35rem;
padding: 0.35rem 0.3rem;
display: grid;
grid-template-columns: minmax(3.75rem, auto) minmax(0, 1fr) auto;
align-items: center;
width: 100%;
min-width: 0;
gap: 0.45rem;
padding: 0.55rem 0.6rem;
}
.dose-time {
min-width: 42px;
padding-left: 0.2rem;
min-width: 0;
padding-left: 0;
white-space: nowrap;
}
.dose-usage {
line-height: 1.15;
min-width: 0;
}
.dose-checks {
gap: 2px;
grid-column: 1 / -1;
width: 100%;
min-width: 0;
margin-left: 0;
gap: 0.3rem;
align-items: stretch;
}
.dose-item .reminder-icon {
justify-self: end;
}
.dose-person {
gap: 4px;
padding: 1px 4px;
width: 100%;
min-width: 0;
justify-content: flex-end;
gap: 0.35rem;
padding: 0.28rem 0.35rem;
}
.dose-person .person-name {
flex: 1 1 auto;
min-width: 0;
max-width: 5.6rem;
margin-right: 0.35rem;
max-width: none;
margin-right: 0;
}
.dose-person > .tooltip-trigger {
display: inline-flex;
flex-shrink: 0;
}
.dose-person .dose-btn {
height: 22px;
min-height: 22px;
padding: 0 5px;
height: 26px;
min-height: 26px;
padding: 0 0.5rem;
font-size: 0.72rem;
}
@@ -2975,31 +3001,172 @@ button.has-validation-error {
.day-block {
padding: 0.75rem;
min-width: 0;
}
/* Use more horizontal space for schedule cards on phones */
.dashboard-schedules-section > .card {
padding-inline: 0.35rem;
overflow: visible;
.timeline,
.time-main,
.time-main .med-name,
.tag-row {
width: 100%;
min-width: 0;
max-width: 100%;
}
/* Keep header controls aligned like other dashboard cards */
.dashboard-schedules-section .card-head {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
padding-inline: 0.65rem;
.time-main .med-name {
overflow-wrap: anywhere;
}
/* Keep schedule controls readable without exceeding phone width. */
.dashboard-schedules-section > .card,
.schedule-full {
width: 100%;
min-width: 0;
overflow: hidden;
}
.dashboard-schedules-section .card-head,
.schedule-full .card-head {
align-items: stretch;
}
.dashboard-schedules-section .card-head h2,
.schedule-full .card-head h2 {
width: 100%;
}
.dashboard-schedules-section .card-head-actions {
margin-left: auto;
display: flex;
flex-wrap: wrap;
width: 100%;
min-width: 0;
margin-left: 0;
gap: 0.5rem;
align-items: stretch;
}
.schedule-full .card-head-actions {
display: flex;
flex-wrap: wrap;
width: 100%;
min-width: 0;
gap: 0.5rem;
align-items: stretch;
}
.dashboard-schedules-section .schedule-days-select,
.schedule-full .schedule-days-select {
flex: 1 1 7.5rem;
width: auto;
min-width: 0;
max-width: none;
}
.dashboard-schedules-section .journal-history-button,
.schedule-full .journal-history-button {
flex: 1 1 7.5rem;
height: 2.75rem;
min-height: 2.75rem;
min-width: 0;
justify-content: center;
padding-block: 0;
}
.dashboard-schedules-section .journal-history-button span,
.schedule-full .journal-history-button span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.dashboard-schedules-section .journal-history-label-full,
.schedule-full .journal-history-label-full {
display: none;
}
.dashboard-schedules-section .journal-history-label-short,
.schedule-full .journal-history-label-short {
display: inline;
}
.dashboard-schedules-section .share-btn.icon-only {
flex: 0 0 2.75rem;
width: 2.75rem;
height: 2.75rem;
min-width: 2.75rem;
min-height: 2.75rem;
padding: 0;
align-self: stretch;
}
@media (max-width: 380px) {
.dashboard-schedules-section .schedule-days-select,
.schedule-full .schedule-days-select {
flex-basis: 100%;
}
.dashboard-schedules-section .journal-history-button {
flex-basis: calc(100% - 3.25rem);
}
}
.dashboard-schedules-section .day-block,
.schedule-full .day-block {
width: 100%;
min-width: 0;
max-width: 100%;
margin-inline: 0;
}
.day-divider {
min-width: 0;
}
.day-date {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.day-summary {
flex-shrink: 0;
white-space: nowrap;
}
.past-days-header,
.future-days-header {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
min-width: 0;
}
.past-days-toggle,
.future-days-toggle,
.clear-missed-btn {
width: 100%;
min-width: 0;
}
.past-days-label,
.future-days-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.past-days-warning,
.past-days-complete,
.future-days-progress {
margin-left: auto;
flex-shrink: 0;
}
.dashboard-schedules-section .day-block {
margin-inline: -0.1rem;
.clear-missed-btn {
display: inline-flex;
align-items: center;
justify-content: center;
}
.status-chip {
+75
View File
@@ -0,0 +1,75 @@
.app-feedback-stack {
position: fixed;
right: 1rem;
bottom: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
width: min(24rem, calc(100vw - 2rem));
z-index: 2100;
pointer-events: none;
}
.app-feedback {
pointer-events: auto;
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.9rem 1rem;
border-radius: 12px;
border: 1px solid var(--border-primary);
background: var(--bg-secondary);
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.24);
color: var(--text-primary);
backdrop-filter: blur(10px);
}
.app-feedback-info {
border-color: var(--accent);
background: color-mix(in srgb, var(--bg-secondary) 82%, var(--accent-bg));
}
.app-feedback-success {
border-color: var(--success);
background: color-mix(in srgb, var(--bg-secondary) 82%, var(--success-bg));
}
.app-feedback-warning {
border-color: var(--warning);
background: color-mix(in srgb, var(--bg-secondary) 82%, var(--warning-bg));
}
.app-feedback-error {
border-color: var(--danger);
background: color-mix(in srgb, var(--bg-secondary) 82%, var(--danger-bg));
}
.app-feedback-message {
flex: 1;
font-size: 0.95rem;
line-height: 1.45;
}
.app-feedback-close {
padding: 0;
border: none;
background: transparent;
color: inherit;
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
opacity: 0.7;
}
.app-feedback-close:hover {
opacity: 1;
}
@media (max-width: 640px) {
.app-feedback-stack {
right: 0.75rem;
left: 0.75rem;
bottom: 0.75rem;
width: auto;
}
}
+1 -1
View File
@@ -108,7 +108,7 @@ body.modal-open {
}
.page {
max-width: 1200px;
max-width: 1440px;
margin: 0 auto;
padding: 2.5rem 1.5rem 1.5rem;
overflow-x: hidden;
+234
View File
@@ -0,0 +1,234 @@
/* =============================================================================
Intake Journal Modals
Owns the focused owner-only journal editor and history overlays.
============================================================================= */
.journal-modal,
.journal-history-modal {
max-width: 720px;
}
.journal-history-button {
display: inline-flex;
align-items: center;
gap: 0.45rem;
white-space: nowrap;
}
.journal-history-label-short {
display: none;
}
.dose-btn.journal {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid var(--border-primary);
background: #ffffff;
color: #1e293b;
}
.dose-btn.journal:hover:not(:disabled) {
background: #f4f7fb;
}
.dose-btn.journal:disabled {
background: var(--bg-tertiary);
border-color: var(--border-primary);
color: var(--text-secondary);
filter: none;
}
.journal-modal-header {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 1rem;
padding-right: 2.5rem;
}
.journal-modal-header h2 {
margin: 0;
}
.journal-modal-header p {
margin: 0;
color: var(--text-secondary);
font-size: 0.92rem;
line-height: 1.5;
}
.journal-modal-state {
padding: 1rem;
border: 1px dashed var(--border-primary);
border-radius: 10px;
color: var(--text-secondary);
margin-bottom: 1rem;
}
.journal-event-card {
border: 1px solid var(--border-primary);
border-radius: 12px;
padding: 1rem;
background: var(--bg-primary);
margin-bottom: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.journal-event-medication {
display: flex;
align-items: center;
gap: 0.75rem;
}
.journal-event-medication p {
margin: 0.2rem 0 0;
color: var(--text-secondary);
font-size: 0.85rem;
}
.journal-event-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.journal-event-grid span,
.journal-field span {
display: block;
font-size: 0.82rem;
font-weight: 600;
margin-bottom: 0.35rem;
color: var(--text-secondary);
}
.journal-event-grid strong {
display: block;
font-size: 0.92rem;
color: var(--text-primary);
}
.journal-field {
display: block;
margin-bottom: 1rem;
}
.journal-note-input,
.journal-history-modal .select-field {
width: 100%;
border: 1px solid var(--border-primary);
border-radius: 10px;
background: var(--bg-input);
color: var(--text-primary);
padding: 0.8rem 0.9rem;
font: inherit;
}
.journal-note-input {
resize: vertical;
min-height: 10rem;
line-height: 1.5;
}
.journal-inline-error {
margin-bottom: 1rem;
padding: 0.8rem 0.9rem;
border-radius: 10px;
border: 1px solid rgba(248, 113, 113, 0.3);
background: rgba(127, 29, 29, 0.18);
color: var(--danger);
font-size: 0.9rem;
}
.journal-history-filters {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.journal-date-filter .date-input-wrapper,
.journal-date-filter .date-input-display {
width: 100%;
}
.journal-history-toolbar {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-bottom: 1rem;
}
.journal-history-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
}
.journal-history-entry {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 1rem;
border-radius: 12px;
border: 1px solid var(--border-primary);
background: var(--bg-primary);
align-items: flex-start;
}
.journal-history-entry-main {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.journal-history-entry-header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.journal-history-entry-header p,
.journal-history-meta {
margin: 0;
color: var(--text-secondary);
font-size: 0.84rem;
}
.journal-history-note {
margin: 0;
white-space: pre-wrap;
line-height: 1.55;
color: var(--text-primary);
}
.journal-history-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 0.85rem;
}
.journal-modal-footer {
padding: 1rem 0 0;
border-top: 1px solid var(--border-primary);
}
@media (max-width: 720px) {
.journal-history-filters,
.journal-event-grid {
grid-template-columns: 1fr;
}
.journal-history-entry {
flex-direction: column;
}
.journal-history-entry > button {
width: 100%;
}
}
+146 -2
View File
@@ -10,7 +10,7 @@
}
.shared-schedule-container {
max-width: 800px;
max-width: 1180px;
margin: 0 auto;
}
@@ -97,6 +97,14 @@
margin-bottom: 0.5rem;
}
.shared-schedule-boundary {
max-width: 34rem;
margin: 0 auto;
color: var(--text-secondary);
font-size: 0.95rem;
line-height: 1.5;
}
.shared-schedule-period {
color: var(--text-secondary);
font-size: 1rem;
@@ -127,6 +135,10 @@
padding: 0.5rem 0;
}
.shared-schedule-page .tooltip-trigger > .dose-btn:disabled {
pointer-events: none;
}
.med-name-stack {
display: flex;
flex-direction: column;
@@ -326,17 +338,127 @@
@media (max-width: 600px) {
.shared-schedule-page {
padding: 1rem;
padding: 0.75rem;
overflow-x: hidden;
}
.shared-schedule-container,
.shared-schedule-section,
.shared-schedule-section .timeline,
.shared-schedule-section .day-block,
.shared-schedule-section .time-row,
.shared-schedule-section .time-main,
.shared-schedule-section .doses-col,
.shared-schedule-section .dose-item,
.shared-schedule-section .dose-checks,
.shared-schedule-section .dose-person {
width: 100%;
min-width: 0;
max-width: 100%;
}
.shared-schedule-header {
margin-bottom: 1.5rem;
padding-top: 0.25rem;
text-align: left;
}
.shared-schedule-header-actions {
top: 0;
right: 0;
}
.shared-schedule-header h1 {
font-size: 1.25rem;
line-height: 1.18;
padding-right: 3rem;
overflow-wrap: anywhere;
}
.shared-schedule-boundary {
margin-inline: 0;
font-size: 0.9rem;
}
.shared-schedule-period {
margin: 0.75rem 0 0;
font-size: 0.9rem;
}
.shared-timeline {
padding: 1rem;
}
.shared-schedule-section .timeline {
gap: 0.85rem;
}
.shared-schedule-section .day-block {
overflow: hidden;
border-radius: 12px;
padding: 0.75rem;
}
.shared-schedule-section .time-row {
gap: 0.65rem;
}
.shared-schedule-section .time-main .med-name {
overflow-wrap: anywhere;
}
.shared-schedule-section .doses-col {
gap: 0.55rem;
overflow: hidden;
}
.shared-schedule-section .dose-item {
grid-template-columns: minmax(3.5rem, auto) minmax(0, 1fr);
gap: 0.45rem 0.6rem;
padding: 0.55rem 0.6rem;
overflow: hidden;
}
.shared-schedule-section .dose-checks {
grid-column: 1 / -1;
overflow: visible;
}
.shared-schedule-section .dose-person {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto auto;
align-items: center;
gap: 0.4rem;
padding: 0.4rem;
overflow: visible;
}
.shared-schedule-section .dose-person .person-name {
grid-column: 1 / -1;
justify-self: stretch;
max-width: 100%;
margin-right: 0;
}
.shared-schedule-section .dose-person > .dose-btn,
.shared-schedule-section .dose-person > .tooltip-trigger {
min-width: 0;
margin-left: 0;
justify-self: end;
}
.shared-schedule-section .dose-person > .tooltip-trigger {
display: inline-flex;
justify-self: end;
max-width: 100%;
}
.shared-schedule-section .dose-person .dose-btn {
height: 28px;
min-height: 28px;
padding-inline: 0.55rem;
}
.shared-overview-table-wrap {
display: none;
}
@@ -346,6 +468,28 @@
}
}
@media (max-width: 640px) {
.shared-schedule-page .tooltip-trigger[data-tooltip]::after,
.shared-schedule-page .tooltip-trigger[data-tooltip]::before {
display: none;
}
.shared-schedule-page .tooltip-trigger.tooltip-active[data-tooltip]::after {
display: block;
position: fixed;
top: auto;
bottom: var(--tooltip-bottom, 50%);
left: 16px;
right: 16px;
transform: none;
width: auto;
max-width: none;
white-space: normal;
text-align: center;
z-index: 10000;
}
}
/* ── Desktop Edit Panel (two-column layout) ── */
.edit-sidebar {
display: none;
+62
View File
@@ -1290,4 +1290,66 @@
border: 1px solid var(--border-primary);
}
.import-review-modal {
max-width: 560px;
}
.import-review-modal h2 {
margin-bottom: 16px;
padding-right: 2rem;
}
.import-review-body {
display: grid;
gap: 16px;
}
.import-review-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.import-review-summary .action-card {
align-items: stretch;
}
.import-review-meta {
display: grid;
gap: 6px;
font-size: 0.9rem;
}
.import-review-warnings {
display: grid;
gap: 8px;
}
.import-review-warnings ul {
display: grid;
gap: 6px;
margin: 0;
padding-left: 1.25rem;
}
.import-review-footer {
justify-content: space-between;
gap: 12px;
padding: 1rem 0 0;
border-top: none;
}
.import-review-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
@media (max-width: 640px) {
.import-review-summary {
grid-template-columns: 1fr;
}
}
/* Modal base styles moved to styles/modals-base.css */
+76
View File
@@ -66,6 +66,82 @@
border-color: var(--accent);
}
.share-dialog-active-links {
margin-top: 1rem;
}
.share-dialog-manage {
border: 1px solid var(--border-primary);
border-radius: 10px;
background: var(--bg-secondary);
overflow: hidden;
}
.share-dialog-manage-summary {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.85rem 1rem;
font-weight: 600;
color: var(--text-primary);
background: transparent;
border: 0;
cursor: pointer;
text-align: left;
}
.share-dialog-manage-count {
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary);
}
.share-dialog-manage-content {
padding: 0 1rem 1rem;
border-top: 1px solid var(--border-primary);
}
.share-active-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.share-active-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
padding-top: 0.75rem;
}
.share-active-item + .share-active-item {
border-top: 1px solid var(--border-primary);
}
.share-active-copy {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.share-link-inline {
font-weight: 600;
color: var(--accent);
text-decoration: none;
word-break: break-word;
}
.share-link-inline:hover {
text-decoration: underline;
}
.share-dialog-footer {
display: flex;
gap: 0.75rem;
+33
View File
@@ -3,11 +3,34 @@ import { MemoryRouter, useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import App from "../App";
const appTranslations: Record<string, string> = {
"auth.connectionErrorTitle": "Connection Error",
"auth.connectionErrorHelp": "Please check if the server is running and try again.",
"common.initializing": "Initializing...",
"common.loading": "Loading...",
"common.retry": "Retry",
};
vi.mock("react-i18next", async () => {
const actual = await vi.importActual<typeof import("react-i18next")>("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => appTranslations[key] ?? key,
i18n: {
language: "en",
changeLanguage: vi.fn(),
},
}),
};
});
type AuthStateMock = {
user: { id: number; username: string } | null;
authState: { authEnabled: boolean; needsSetup: boolean } | null;
loading: boolean;
authError: string | null;
sessionExpired?: boolean;
};
let authMock: AuthStateMock = {
@@ -15,6 +38,7 @@ let authMock: AuthStateMock = {
authState: { authEnabled: false, needsSetup: false },
loading: false,
authError: null,
sessionExpired: false,
};
let appContextMock: Record<string, unknown>;
@@ -156,12 +180,20 @@ describe("App", () => {
setShareSelectedPerson: vi.fn(),
shareSelectedDays: 7,
setShareSelectedDays: vi.fn(),
shareSelectedExpiryDays: null,
setShareSelectedExpiryDays: vi.fn(),
shareAllowJournalNotes: false,
setShareAllowJournalNotes: vi.fn(),
shareGenerating: false,
shareLink: null,
setShareLink: vi.fn(),
shareCopied: false,
setShareCopied: vi.fn(),
activeShareLinks: [],
activeSharesLoading: false,
revokingShareToken: null,
generateShareLink: vi.fn(),
revokeShareLink: vi.fn(),
copyShareLink: vi.fn(),
closeShareDialog: vi.fn(),
resetShareDialogState: vi.fn(),
@@ -215,6 +247,7 @@ describe("App", () => {
);
expect(screen.getByText("Connection Error")).toBeInTheDocument();
expect(screen.getByText("Please check if the server is running and try again.")).toBeInTheDocument();
expect(screen.getByText("Backend is unreachable")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
});
@@ -132,6 +132,7 @@ describe("AuthProvider", () => {
await waitFor(() => {
expect(result.current.user).toBeNull();
expect(result.current.sessionExpired).toBe(true);
});
});
@@ -865,6 +866,28 @@ describe("AuthProvider methods", () => {
});
expect(result.current.user).toBeNull();
expect(result.current.sessionExpired).toBe(false);
});
it("marks the session as expired when refreshUser cannot recover from 401", async () => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false, formLoginEnabled: true }) })
.mockResolvedValueOnce({ ok: false, status: 401 })
.mockResolvedValueOnce({ ok: false, status: 401 });
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await act(async () => {
await result.current.refreshUser();
});
expect(result.current.user).toBeNull();
expect(result.current.sessionExpired).toBe(true);
});
it("updateProfile throws default message when backend has no error field", async () => {
@@ -0,0 +1,96 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ImportReviewModal } from "../../components/ImportReviewModal";
vi.mock("react-i18next", async () => {
const actual = await vi.importActual<typeof import("react-i18next")>("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => key,
}),
};
});
const importPreview = {
version: "1.6",
exportedAt: "2026-05-21T10:00:00.000Z",
includeSensitiveData: true,
incoming: {
medications: 1,
doseHistory: 2,
refillHistory: 3,
shareLinks: 4,
journalEntries: 1,
imageCount: 1,
hasSettings: true,
},
current: {
medications: 5,
doseHistory: 6,
refillHistory: 7,
shareLinks: 8,
hasSettings: true,
},
warnings: {
replacesExistingData: true,
regeneratesShareLinks: true,
containsImages: true,
containsSensitiveData: true,
},
};
describe("ImportReviewModal", () => {
it("stays closed without an open preview", () => {
const { container } = render(
<ImportReviewModal
isOpen={false}
importPreview={importPreview}
formattedExportedAt="May 21, 2026"
importing={false}
exporting={false}
onClose={vi.fn()}
onBackup={vi.fn()}
onConfirm={vi.fn()}
/>
);
expect(container.firstChild).toBeNull();
});
it("supports overlay, Escape, backup, and confirm actions", () => {
const onClose = vi.fn();
const onBackup = vi.fn();
const onConfirm = vi.fn();
const { container } = render(
<ImportReviewModal
isOpen={true}
importPreview={importPreview}
formattedExportedAt="May 21, 2026"
importing={false}
exporting={false}
onClose={onClose}
onBackup={onBackup}
onConfirm={onConfirm}
/>
);
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("exportImport.confirmImport")).toBeInTheDocument();
fireEvent.click(container.querySelector(".modal-content") as Element);
expect(onClose).not.toHaveBeenCalled();
fireEvent.click(screen.getByText("exportImport.backupFirst"));
expect(onBackup).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByText("exportImport.confirmButton"));
expect(onConfirm).toHaveBeenCalledTimes(1);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.click(container.querySelector(".modal-overlay") as Element);
expect(onClose).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,93 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { IntakeJournalModal } from "../../components/intake-journal/IntakeJournalModal";
import type { IntakeJournalEntry } from "../../hooks/useIntakeJournal";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock("../../components/MedicationAvatar", () => ({
MedicationAvatar: ({ name }: { name: string }) => <div>{name}</div>,
}));
function buildEntry(overrides: Partial<IntakeJournalEntry> = {}): IntakeJournalEntry {
return {
doseTrackingId: 1,
doseId: "1-0-1760000000000-pillamn",
medicationId: 1,
medicationName: "Liquid Container",
scheduledFor: "2026-05-17T11:55:00.000Z",
takenAt: "2026-05-17T19:23:00.000Z",
dismissed: false,
takenSource: "manual",
markedBy: "pillamn",
note: "",
updatedAt: null,
createdAt: null,
...overrides,
};
}
describe("IntakeJournalModal", () => {
it("closes after a successful save", async () => {
const onSave = vi.fn(async () => true);
const onClose = vi.fn();
const onDelete = vi.fn();
const entry = buildEntry();
render(
<IntakeJournalModal
isOpen
entry={entry}
isLoading={false}
isSaving={false}
isDeleting={false}
error={null}
onClose={onClose}
onSave={onSave}
onDelete={onDelete}
/>
);
fireEvent.change(screen.getByLabelText("journal.editor.noteLabel"), {
target: { value: "Shared note" },
});
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
expect(onSave).toHaveBeenCalledWith("Shared note");
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it("keeps the modal open when save fails", async () => {
const onSave = vi.fn(async () => false);
const onClose = vi.fn();
const entry = buildEntry();
render(
<IntakeJournalModal
isOpen
entry={entry}
isLoading={false}
isSaving={false}
isDeleting={false}
error={null}
onClose={onClose}
onSave={onSave}
onDelete={vi.fn()}
/>
);
fireEvent.change(screen.getByLabelText("journal.editor.noteLabel"), {
target: { value: "Shared note" },
});
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith("Shared note");
});
expect(onClose).not.toHaveBeenCalled();
});
});
+110 -57
View File
@@ -4,6 +4,28 @@ import ReportModal from "../../components/ReportModal";
import type { Medication } from "../../types";
import { formatDate, formatDateTime } from "../../utils/formatters";
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
vi.mock("../../components/Auth", () => ({
useAuth: () => ({ authFetch: authFetchMock }),
}));
function getPreviewContent() {
const preview = document.querySelector(".report-preview-content");
if (!(preview instanceof HTMLElement)) {
throw new Error("Expected report preview content to be rendered");
}
return preview.textContent ?? "";
}
function expectPreviewToBeVisible() {
const preview = document.querySelector(".report-preview");
if (!(preview instanceof HTMLElement)) {
throw new Error("Expected report preview to be rendered");
}
expect(preview).toBeInTheDocument();
}
function createMedication(overrides: Partial<Medication> = {}): Medication {
return {
id: 1,
@@ -24,6 +46,7 @@ function createMedication(overrides: Partial<Medication> = {}): Medication {
describe("ReportModal", () => {
beforeEach(() => {
vi.clearAllMocks();
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
});
it("renders and closes when cancel is clicked", () => {
@@ -35,35 +58,41 @@ describe("ReportModal", () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
it("generates text report and closes modal", async () => {
it("generates txt and md previews in-app without closing the modal", async () => {
const onClose = vi.fn();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: async () => ({
1: {
dosesTaken: 2,
dosesSkipped: 0,
firstDoseAt: "2026-01-01T08:00:00.000Z",
lastDoseAt: "2026-01-02T08:00:00.000Z",
refills: [],
},
}),
});
for (const format of ["txt", "md"] as const) {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({
1: {
dosesTaken: 2,
automaticDosesTaken: 0,
dosesSkipped: 0,
firstDoseAt: "2026-01-01T08:00:00.000Z",
lastDoseAt: "2026-01-02T08:00:00.000Z",
refills: [],
},
}),
});
render(<ReportModal isOpen={true} onClose={onClose} medications={[createMedication()]} />);
const view = render(<ReportModal isOpen={true} onClose={onClose} medications={[createMedication()]} />);
fireEvent.click(screen.getByRole("radio", { name: /report\.formatTxt/i }));
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/medications/report-data",
expect.objectContaining({ method: "POST" })
fireEvent.click(
screen.getByRole("radio", { name: new RegExp(`report\\.format${format === "txt" ? "Txt" : "Md"}`, "i") })
);
});
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
expect(onClose).toHaveBeenCalledTimes(1);
expect(URL.createObjectURL).toHaveBeenCalled();
await waitFor(() => {
expectPreviewToBeVisible();
});
expect(screen.getByRole("button", { name: /report\.download/i })).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
expect(URL.createObjectURL).not.toHaveBeenCalled();
expect(getPreviewContent()).toContain("report.docTitle");
view.unmount();
}
});
it("renders shared formatter output in exported text reports", async () => {
@@ -99,18 +128,15 @@ describe("ReportModal", () => {
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(URL.createObjectURL).toHaveBeenCalled();
expectPreviewToBeVisible();
});
const [blob] = (URL.createObjectURL as ReturnType<typeof vi.fn>).mock.calls.at(-1) ?? [];
expect(blob).toBeInstanceOf(Blob);
const content = await (blob as Blob).text();
const content = getPreviewContent();
expect(content).toContain(formatDate("2026-02-01"));
expect(content).toContain(formatDateTime("2026-02-02T08:30:00.000Z"));
expect(content).toContain(formatDate("2026-02-03T12:00:00.000Z"));
expect(onClose).toHaveBeenCalledTimes(1);
expect(onClose).not.toHaveBeenCalled();
});
it("exports bottle current stock separately from configured capacity", async () => {
@@ -151,16 +177,15 @@ describe("ReportModal", () => {
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(URL.createObjectURL).toHaveBeenCalled();
expectPreviewToBeVisible();
});
const [blob] = (URL.createObjectURL as ReturnType<typeof vi.fn>).mock.calls.at(-1) ?? [];
const content = await (blob as Blob).text();
const content = getPreviewContent();
expect(content).toContain("report.docTotalCapacity: 100");
expect(content).toContain("report.docCurrentStock: 70 common.pills");
expect(content).not.toContain("report.docCurrentStock: 100 common.pills");
expect(onClose).toHaveBeenCalledTimes(1);
expect(onClose).not.toHaveBeenCalled();
});
it("exports injection refill history with injection unit wording", async () => {
@@ -205,15 +230,14 @@ describe("ReportModal", () => {
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(URL.createObjectURL).toHaveBeenCalled();
expectPreviewToBeVisible();
});
const [blob] = (URL.createObjectURL as ReturnType<typeof vi.fn>).mock.calls.at(-1) ?? [];
const content = await (blob as Blob).text();
const content = getPreviewContent();
expect(content).toContain("report.docCurrentStock: 6 common.injections");
expect(content).toContain("+3 common.injections");
expect(onClose).toHaveBeenCalledTimes(1);
expect(onClose).not.toHaveBeenCalled();
});
it("generates printable report when PDF format is selected", async () => {
@@ -288,14 +312,17 @@ describe("ReportModal", () => {
onClose={onClose}
medications={[
createMedication({ id: 1, name: "Alice Med", takenBy: ["Alice"] }),
createMedication({ id: 2, name: "Bob Med", takenBy: ["Bob"] }),
createMedication({ id: 2, name: "Alice Lower", takenBy: ["alice"] }),
createMedication({ id: 3, name: "Bob Med", takenBy: ["Bob"] }),
]}
/>
);
expect(screen.getByText(/report\.filterByPerson/i)).toBeInTheDocument();
expect(screen.getAllByRole("checkbox", { name: "Alice" })).toHaveLength(1);
fireEvent.click(screen.getByRole("checkbox", { name: "Alice" }));
expect(screen.getByText("Alice Med")).toBeInTheDocument();
expect(screen.getByText("Alice Lower")).toBeInTheDocument();
expect(screen.queryByText("Bob Med")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /report\.deselectAll/i }));
@@ -335,7 +362,8 @@ describe("ReportModal", () => {
onClose={onClose}
medications={[
createMedication({ id: 1, name: "Alice Med", takenBy: ["Alice"] }),
createMedication({ id: 2, name: "Bob Med", takenBy: ["Bob"] }),
createMedication({ id: 2, name: "Alice Lower", takenBy: ["alice"] }),
createMedication({ id: 3, name: "Bob Med", takenBy: ["Bob"] }),
]}
/>
);
@@ -345,15 +373,14 @@ describe("ReportModal", () => {
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/medications/report-data",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ medicationIds: [1], takenByFilter: ["Alice"] }),
})
);
const [, requestInit] = authFetchMock.mock.calls[0] ?? [];
const body = JSON.parse((requestInit?.body as string) ?? "{}");
expect(body).toMatchObject({ medicationIds: [1, 2], takenByFilter: ["Alice"] });
expect(typeof body.startDate).toBe("string");
expect(typeof body.endDate).toBe("string");
});
authFetchMock.mockClear();
(global.fetch as ReturnType<typeof vi.fn>).mockClear();
firstRender.unmount();
render(
@@ -362,7 +389,8 @@ describe("ReportModal", () => {
onClose={onClose}
medications={[
createMedication({ id: 1, name: "Alice Med", takenBy: ["Alice"] }),
createMedication({ id: 2, name: "Bob Med", takenBy: ["Bob"] }),
createMedication({ id: 2, name: "Alice Lower", takenBy: ["alice"] }),
createMedication({ id: 3, name: "Bob Med", takenBy: ["Bob"] }),
]}
/>
);
@@ -370,17 +398,16 @@ describe("ReportModal", () => {
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/medications/report-data",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ medicationIds: [1, 2], takenByFilter: undefined }),
})
);
const [, requestInit] = authFetchMock.mock.calls[0] ?? [];
const body = JSON.parse((requestInit?.body as string) ?? "{}");
expect(body).toMatchObject({ medicationIds: [1, 2, 3] });
expect(body).not.toHaveProperty("takenByFilter");
expect(typeof body.startDate).toBe("string");
expect(typeof body.endDate).toBe("string");
});
});
it("generates markdown report and keeps modal open on fetch error", async () => {
it("shows a localized fetch error and keeps the modal open when preview generation fails", async () => {
const onClose = vi.fn();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: false });
@@ -390,9 +417,35 @@ describe("ReportModal", () => {
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(global.fetch).toHaveBeenCalled();
expect(authFetchMock).toHaveBeenCalledWith(
"/api/medications/report-data",
expect.objectContaining({ method: "POST" })
);
});
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByText(/report\.error/i)).toBeInTheDocument();
expect(screen.queryByText(/report\.preview/i)).not.toBeInTheDocument();
});
it("shows a localized error and skips the request when the date range is invalid", async () => {
const onClose = vi.fn();
render(<ReportModal isOpen={true} onClose={onClose} medications={[createMedication()]} />);
const inputs = screen.getAllByDisplayValue(/\d{2}\.\d{2}\.\d{4}|\d{1,2}\/\d{1,2}\/\d{4}|\d{4}-\d{2}-\d{2}/i);
const startInput = inputs[0] as HTMLInputElement;
const endInput = inputs[1] as HTMLInputElement;
fireEvent.change(startInput.parentElement?.querySelector("input") ?? startInput, {
target: { value: "2026-02-10T10:00" },
});
fireEvent.change(endInput.parentElement?.querySelector("input") ?? endInput, {
target: { value: "2026-02-10T09:00" },
});
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
expect(authFetchMock).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByText(/report\.invalidDateRange/i)).toBeInTheDocument();
});
});
@@ -10,13 +10,21 @@ describe("ShareDialog", () => {
onShareSelectedPersonChange: vi.fn(),
shareSelectedDays: 30,
onShareSelectedDaysChange: vi.fn(),
shareSelectedExpiryDays: null,
onShareSelectedExpiryDaysChange: vi.fn(),
shareAllowJournalNotes: false,
onShareAllowJournalNotesChange: vi.fn(),
shareGenerating: false,
shareLink: null,
onShareLinkChange: vi.fn(),
shareCopied: false,
onShareCopiedChange: vi.fn(),
activeShareLinks: [],
activeSharesLoading: false,
revokingShareToken: null,
onClose: vi.fn(),
onGenerateShareLink: vi.fn(),
onRevokeShareLink: vi.fn().mockResolvedValue(true),
onCopyShareLink: vi.fn(),
};
@@ -105,9 +113,13 @@ describe("ShareDialog", () => {
const selects = screen.getAllByRole("combobox");
fireEvent.change(selects[0], { target: { value: "Bob" } });
fireEvent.change(selects[1], { target: { value: "90" } });
fireEvent.change(selects[2], { target: { value: "30" } });
fireEvent.click(screen.getByLabelText(/share\.allowJournalNotes/i));
expect(defaultProps.onShareSelectedPersonChange).toHaveBeenCalledWith("Bob");
expect(defaultProps.onShareSelectedDaysChange).toHaveBeenCalledWith(90);
expect(defaultProps.onShareSelectedExpiryDaysChange).toHaveBeenCalledWith(30);
expect(defaultProps.onShareAllowJournalNotesChange).toHaveBeenCalledWith(true);
});
it("disables generate button when no person is selected", () => {
@@ -116,4 +128,58 @@ describe("ShareDialog", () => {
const generateButton = screen.getByRole("button", { name: /share\.generateLink/i });
expect(generateButton).toBeDisabled();
});
it("keeps active share management collapsed until opened", () => {
render(
<ShareDialog
{...defaultProps}
activeShareLinks={[
{
token: "abcdef0123456789",
takenBy: "Alice",
scheduleDays: 30,
createdAt: "2026-05-17T12:00:00.000Z",
expiresAt: null,
allowJournalNotes: true,
shareUrl: "/share/abcdef0123456789",
},
]}
/>
);
expect(screen.getByText(/share\.manageLinksSummary/i)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /share\.revoke/i })).not.toBeInTheDocument();
fireEvent.click(screen.getByText(/share\.manageLinksSummary/i));
expect(screen.getByRole("button", { name: /share\.revoke/i })).toBeInTheDocument();
});
it("uses an in-app confirm modal before revoking an active share link", async () => {
render(
<ShareDialog
{...defaultProps}
activeShareLinks={[
{
token: "abcdef0123456789",
takenBy: "Alice",
scheduleDays: 30,
createdAt: "2026-05-17T12:00:00.000Z",
expiresAt: null,
allowJournalNotes: true,
shareUrl: "/share/abcdef0123456789",
},
]}
/>
);
fireEvent.click(screen.getByText(/share\.manageLinksSummary/i));
fireEvent.click(screen.getByRole("button", { name: /share\.revoke/i }));
expect(screen.getByText(/share\.revokeConfirm/i)).toBeInTheDocument();
fireEvent.click(screen.getAllByRole("button", { name: /share\.revoke/i })[1]);
expect(defaultProps.onRevokeShareLink).toHaveBeenCalledWith("abcdef0123456789");
});
});
@@ -141,6 +141,7 @@ function createSharedDataWithTodayDose(referenceNow: Date) {
sharedBy: "Owner",
takenBy: "Max",
scheduleDays: 30,
allowJournalNotes: false,
automaticDoseId: `1-0-${dateOnlyMs}`,
medications: [
{
@@ -171,17 +172,24 @@ function createSharedDataWithTodayDose(referenceNow: Date) {
function createSharedDoseFetchMock(options: {
token?: string;
sharedData: ReturnType<typeof createSharedDataWithTodayDose>;
initialDoses?: Array<{ doseId: string; skipped?: boolean; dismissed?: boolean; takenSource?: string }>;
initialDoses?: Array<{
doseId: string;
skipped?: boolean;
dismissed?: boolean;
takenSource?: string;
hasJournalNote?: boolean;
}>;
}) {
const token = options.token ?? "token-123";
const doseState = new Map((options.initialDoses ?? []).map((dose) => [dose.doseId, { ...dose }]));
const journalState = new Map<string, { note: string | null; createdAt: string | null; updatedAt: string | null }>();
const requests: Array<{ url: string; method: string; body?: unknown }> = [];
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
const method = init?.method ?? "GET";
const body =
typeof init?.body === "string" && init.body.length > 0
? (JSON.parse(init.body) as { doseId: string })
? (JSON.parse(init.body) as { doseId?: string; note?: string | null })
: undefined;
requests.push({ url, method, body });
@@ -190,7 +198,11 @@ function createSharedDoseFetchMock(options: {
}
if (url === `/api/share/${token}/doses` && method === "GET") {
return { ok: true, json: async () => ({ doses: Array.from(doseState.values()) }) };
const doses = Array.from(doseState.values()).map((dose) => ({
...dose,
hasJournalNote: dose.hasJournalNote === true || Boolean(journalState.get(dose.doseId)?.note?.trim()),
}));
return { ok: true, json: async () => ({ doses }) };
}
if (url === `/api/share/${token}/doses/skip` && method === "POST" && body?.doseId) {
@@ -203,6 +215,61 @@ function createSharedDoseFetchMock(options: {
return { ok: true, json: async () => ({}) };
}
if (url.startsWith(`/api/share/${token}/journal/event/`) && method === "GET") {
const doseId = decodeURIComponent(url.split("/").at(-1) ?? "");
const journal = journalState.get(doseId) ?? { note: null, createdAt: null, updatedAt: null };
return {
ok: true,
json: async () => ({
entry: {
doseTrackingId: 1,
doseId,
medicationId: 1,
medicationName: "Ibuprofen",
scheduledFor: new Date().toISOString(),
takenAt: new Date().toISOString(),
dismissed: false,
takenSource: "manual",
markedBy: "Max",
note: journal.note,
createdAt: journal.createdAt,
updatedAt: journal.updatedAt,
},
}),
};
}
if (url.startsWith(`/api/share/${token}/journal/event/`) && method === "PUT") {
const doseId = decodeURIComponent(url.split("/").at(-1) ?? "");
const timestamp = new Date().toISOString();
journalState.set(doseId, { note: body?.note ?? null, createdAt: timestamp, updatedAt: timestamp });
return {
ok: true,
json: async () => ({
entry: {
doseTrackingId: 1,
doseId,
medicationId: 1,
medicationName: "Ibuprofen",
scheduledFor: new Date().toISOString(),
takenAt: new Date().toISOString(),
dismissed: false,
takenSource: "manual",
markedBy: "Max",
note: body?.note ?? null,
createdAt: timestamp,
updatedAt: timestamp,
},
}),
};
}
if (url.startsWith(`/api/share/${token}/journal/event/`) && method === "DELETE") {
const doseId = decodeURIComponent(url.split("/").at(-1) ?? "");
journalState.delete(doseId);
return { ok: true, json: async () => ({ success: true }) };
}
if (url.startsWith(`/api/share/${token}/doses/skip/`) && method === "DELETE") {
const doseId = decodeURIComponent(url.split("/").at(-1) ?? "");
doseState.delete(doseId);
@@ -244,10 +311,109 @@ describe("SharedSchedule", () => {
await waitFor(() => {
expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument();
expect(screen.getByText("share.publicAccessHelp")).toBeInTheDocument();
expect(screen.getByText("share.noSchedule")).toBeInTheDocument();
});
});
it("opens and saves a shared journal note when the share link allows notes", async () => {
const referenceNow = new Date();
referenceNow.setHours(12, 0, 0, 0);
vi.spyOn(Date, "now").mockReturnValue(referenceNow.getTime());
const sharedData = {
...createSharedDataWithTodayDose(referenceNow),
allowJournalNotes: true,
};
const { fetchMock, requests } = createSharedDoseFetchMock({
sharedData,
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
renderSharedSchedule("/share/token-123");
await waitFor(() => {
expect(document.querySelector(".dose-btn.take")).toBeInTheDocument();
});
const unavailableJournalButton = document.querySelector(".dose-btn.journal") as HTMLButtonElement;
expect(unavailableJournalButton).toBeDisabled();
expect(unavailableJournalButton).not.toHaveClass("has-note");
expect(unavailableJournalButton.closest("span")).toHaveAttribute("data-tooltip", "journal.actions.noteTakenOnly");
fireEvent.click(screen.getByText("dose.take"));
await waitFor(() => {
expect(requests).toContainEqual({
url: "/api/share/token-123/doses",
method: "POST",
body: { doseId: sharedData.automaticDoseId },
});
expect(document.querySelector(".day-block.today")).not.toHaveClass("collapsed");
});
await waitFor(() => {
const availableJournalButton = document.querySelector(".dose-btn.journal") as HTMLButtonElement;
expect(availableJournalButton).not.toBeDisabled();
expect(availableJournalButton).not.toHaveClass("has-note");
expect(availableJournalButton.closest("span")).not.toHaveAttribute("data-tooltip");
});
fireEvent.click(document.querySelector(".dose-btn.journal") as Element);
await waitFor(() => {
expect(requests).toContainEqual({
url: `/api/share/token-123/journal/event/${sharedData.automaticDoseId}`,
method: "GET",
body: undefined,
});
});
await waitFor(() => {
expect(screen.getByLabelText("journal.editor.noteLabel")).toHaveValue("");
});
expect(screen.queryByRole("button", { name: "common.delete" })).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText("journal.editor.noteLabel"), { target: { value: "Shared note" } });
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
await waitFor(() => {
expect(requests).toContainEqual({
url: `/api/share/token-123/journal/event/${sharedData.automaticDoseId}`,
method: "PUT",
body: { note: "Shared note" },
});
});
await waitFor(() => {
expect(screen.queryByLabelText("journal.editor.noteLabel")).not.toBeInTheDocument();
const savedJournalButton = document.querySelector(".dose-btn.journal") as HTMLButtonElement;
expect(savedJournalButton).toHaveClass("has-note");
});
});
it("marks shared journal notes from the shared dose read state", async () => {
const referenceNow = new Date();
referenceNow.setHours(12, 0, 0, 0);
vi.spyOn(Date, "now").mockReturnValue(referenceNow.getTime());
const sharedData = {
...createSharedDataWithTodayDose(referenceNow),
allowJournalNotes: true,
};
const { fetchMock } = createSharedDoseFetchMock({
sharedData,
initialDoses: [{ doseId: sharedData.automaticDoseId, takenSource: "manual", hasJournalNote: true }],
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
renderSharedSchedule("/share/token-123");
await waitFor(() => {
const journalButton = document.querySelector(".dose-btn.journal") as HTMLButtonElement;
expect(journalButton).not.toBeDisabled();
expect(journalButton).toHaveClass("has-note");
});
});
it("renders not found state for missing share link", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
if (url === "/api/share/token-123/doses") {
@@ -275,7 +275,7 @@ describe("UserFilterModal", () => {
const meds: Medication[] = [
{ ...mockMedication, id: 1, name: "Med1", takenBy: ["John"] },
{ ...mockMedication, id: 2, name: "Med2", takenBy: ["Jane"] },
{ ...mockMedication, id: 3, name: "Med3", takenBy: ["John", "Jane"] },
{ ...mockMedication, id: 3, name: "Med3", takenBy: ["john", "Jane"] },
];
render(
+111 -22
View File
@@ -4,10 +4,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { AppProvider, useAppContext } from "../../context/AppContext";
import type { Medication } from "../../types";
const feedbackMock = vi.hoisted(() => ({ showFeedback: vi.fn() }));
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
const mockUseAuth = vi.fn();
const mockUseMedications = vi.fn();
const mockUseSettings = vi.fn();
const mockUseDoses = vi.fn();
const mockUseIntakeJournal = vi.fn();
const mockUseCollapsedDays = vi.fn();
const mockUseShare = vi.fn();
const mockUseRefill = vi.fn();
@@ -26,10 +29,19 @@ vi.mock("../../components/Auth", () => ({
useAuth: () => mockUseAuth(),
}));
vi.mock("../../context/FeedbackContext", () => ({
useFeedback: () => ({
showFeedback: feedbackMock.showFeedback,
dismissFeedback: vi.fn(),
clearFeedback: vi.fn(),
}),
}));
vi.mock("../../hooks", () => ({
useMedications: () => mockUseMedications(),
useSettings: () => mockUseSettings(),
useDoses: () => mockUseDoses(),
useIntakeJournal: () => mockUseIntakeJournal(),
useCollapsedDays: () => mockUseCollapsedDays(),
useShare: () => mockUseShare(),
useRefill: () => mockUseRefill(),
@@ -55,7 +67,7 @@ const meds: Medication[] = [
{
id: 11,
name: "Aspirin",
takenBy: ["Max", "Anna"],
takenBy: ["Max", "Anna", "max"],
packageType: "blister",
packCount: 1,
blistersPerPack: 1,
@@ -80,7 +92,8 @@ describe("useAppContext", () => {
const loadSettings = vi.fn();
const loadTakenDoses = vi.fn();
mockUseAuth.mockReturnValue({ user: { id: 7, username: "owner" } });
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
mockUseAuth.mockReturnValue({ user: { id: 7, username: "owner" }, authFetch: authFetchMock });
mockUseMedications.mockReturnValue({
meds,
@@ -206,6 +219,35 @@ describe("useAppContext", () => {
loadTakenDoses,
});
mockUseIntakeJournal.mockReturnValue({
journalEditorOpen: false,
journalHistoryOpen: false,
journalTargetDoseId: null,
journalEvent: null,
journalEventLoading: false,
journalEventSaving: false,
journalEventDeleting: false,
journalEventError: null,
journalHistoryEntries: [],
journalHistoryFilters: {
medicationId: null,
from: "",
until: "",
},
journalHistoryLoading: false,
journalHistoryError: null,
openJournalEditor: vi.fn(),
closeJournalEditor: vi.fn(),
saveJournalNote: vi.fn(async () => true),
deleteJournalNote: vi.fn(async () => true),
openJournalHistory: vi.fn(),
closeJournalHistory: vi.fn(),
setJournalHistoryFilters: vi.fn(),
reloadJournalHistory: vi.fn(async () => {}),
reopenJournalHistoryEntry: vi.fn(async () => {}),
resetJournalState: vi.fn(),
});
mockUseCollapsedDays.mockReturnValue({
manuallyCollapsedDays: new Set<string>(),
manuallyExpandedDays: new Set<string>(),
@@ -219,11 +261,19 @@ describe("useAppContext", () => {
setShareSelectedPerson: vi.fn(),
shareSelectedDays: 30,
setShareSelectedDays: vi.fn(),
shareSelectedExpiryDays: null,
setShareSelectedExpiryDays: vi.fn(),
shareAllowJournalNotes: false,
setShareAllowJournalNotes: vi.fn(),
shareGenerating: false,
shareLink: null,
setShareLink: vi.fn(),
shareCopied: false,
setShareCopied: vi.fn(),
activeShareLinks: [],
activeSharesLoading: false,
revokingShareToken: null,
revokeShareLink: vi.fn(),
openShareDialog: vi.fn(),
generateShareLink: vi.fn(),
copyShareLink: vi.fn(),
@@ -345,7 +395,7 @@ describe("useAppContext", () => {
const clearRefillStateBefore = mockUseRefill().clearRefillState.mock.calls.length;
const resetShareDialogStateBefore = mockUseShare().resetShareDialogState.mock.calls.length;
mockUseAuth.mockReturnValue({ user: { id: 8, username: "other-user" } });
mockUseAuth.mockReturnValue({ user: { id: 8, username: "other-user" }, authFetch: authFetchMock });
rerender();
await waitFor(() => {
@@ -407,11 +457,10 @@ describe("useAppContext", () => {
await result.current.handleImportConfirm();
});
expect(fetch).toHaveBeenCalledWith(
expect(authFetchMock).toHaveBeenCalledWith(
"/api/import",
expect.objectContaining({
method: "POST",
credentials: "include",
})
);
expect(mockUseMedications().loadMeds).toHaveBeenCalled();
@@ -447,9 +496,7 @@ describe("useAppContext", () => {
await result.current.handleExport(true);
});
expect(fetch).toHaveBeenCalledWith("/api/export?includeSensitive=true&includeImages=true", {
credentials: "include",
});
expect(authFetchMock).toHaveBeenCalledWith("/api/export?includeSensitive=true&includeImages=true");
expect(createObjectURL).toHaveBeenCalled();
expect(click).toHaveBeenCalled();
expect(appendChild).toHaveBeenCalled();
@@ -458,9 +505,6 @@ describe("useAppContext", () => {
});
it("handles invalid import JSON file", () => {
const mockAlert = vi.fn();
global.alert = mockAlert;
class MockFileReader {
onload: ((event: ProgressEvent<FileReader>) => void) | null = null;
readAsText = vi.fn(() => {
@@ -478,10 +522,46 @@ describe("useAppContext", () => {
} as unknown as React.ChangeEvent<HTMLInputElement>);
});
expect(mockAlert).toHaveBeenCalledWith("exportImport.invalidFile");
expect(feedbackMock.showFeedback).toHaveBeenCalledWith({ message: "exportImport.invalidFile", tone: "error" });
});
it("parses valid import file and opens confirm modal", () => {
it("parses valid import file and opens confirm modal", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
preview: {
version: "1",
exportedAt: "2026-01-01T00:00:00.000Z",
includeSensitiveData: true,
incoming: {
medications: 0,
doseHistory: 0,
refillHistory: 0,
shareLinks: 0,
journalEntries: 0,
imageCount: 0,
hasSettings: false,
},
current: {
medications: 1,
doseHistory: 0,
refillHistory: 0,
shareLinks: 0,
hasSettings: true,
},
warnings: {
replacesExistingData: true,
regeneratesShareLinks: false,
containsImages: false,
containsSensitiveData: true,
},
},
})
),
});
class MockFileReader {
onload: ((event: ProgressEvent<FileReader>) => void) | null = null;
readAsText = vi.fn(() => {
@@ -503,11 +583,20 @@ describe("useAppContext", () => {
} as unknown as React.ChangeEvent<HTMLInputElement>);
});
expect(result.current.showImportConfirm).toBe(true);
expect(result.current.pendingImportData).toEqual({
version: "1",
exportedAt: "2026-01-01T00:00:00.000Z",
medications: [],
await waitFor(() => {
expect(authFetchMock).toHaveBeenCalledWith(
"/api/import/preview",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ version: "1", exportedAt: "2026-01-01T00:00:00.000Z", medications: [] }),
})
);
expect(result.current.showImportConfirm).toBe(true);
expect(result.current.pendingImportData).toEqual({
version: "1",
exportedAt: "2026-01-01T00:00:00.000Z",
medications: [],
});
});
});
@@ -550,9 +639,6 @@ describe("useAppContext", () => {
});
it("shows import error alert when import API returns non-ok response", async () => {
const mockAlert = vi.fn();
global.alert = mockAlert;
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 500,
@@ -569,6 +655,9 @@ describe("useAppContext", () => {
await result.current.handleImportConfirm();
});
expect(mockAlert).toHaveBeenCalledWith("exportImport.importError: Import failed");
expect(feedbackMock.showFeedback).toHaveBeenCalledWith({
message: "exportImport.importError: Import failed",
tone: "error",
});
});
});
+35 -4
View File
@@ -2,9 +2,27 @@ import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useDoses } from "../../hooks/useDoses";
const feedbackMock = vi.hoisted(() => ({ showFeedback: vi.fn() }));
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
vi.mock("../../components/Auth", () => ({
useAuth: () => ({
authFetch: authFetchMock,
}),
}));
vi.mock("../../context/FeedbackContext", () => ({
useFeedback: () => ({
showFeedback: feedbackMock.showFeedback,
dismissFeedback: vi.fn(),
clearFeedback: vi.fn(),
}),
}));
describe("useDoses", () => {
beforeEach(() => {
vi.clearAllMocks();
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ doses: [] }),
@@ -15,6 +33,19 @@ describe("useDoses", () => {
vi.clearAllMocks();
});
it("loads taken doses through authFetch", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ doses: [] }),
});
renderHook(() => useDoses());
await waitFor(() => {
expect(authFetchMock).toHaveBeenCalledWith("/api/doses/taken");
});
});
it("initializes with empty state", () => {
const { result } = renderHook(() => useDoses());
@@ -273,9 +304,6 @@ describe("useDoses", () => {
});
it("shows an out-of-stock alert and reverts the optimistic mark", async () => {
const alertMock = vi.fn();
global.alert = alertMock;
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) })
.mockResolvedValueOnce({
@@ -297,7 +325,10 @@ describe("useDoses", () => {
await waitFor(() => {
expect(result.current.takenDoses.has("blocked-dose")).toBe(false);
});
expect(alertMock).toHaveBeenCalledWith("common.outOfStockTakeBlocked");
expect(feedbackMock.showFeedback).toHaveBeenCalledWith({
message: "common.outOfStockTakeBlocked",
tone: "error",
});
});
it("undoDoseTaken encodes special characters in dose ID", async () => {
@@ -0,0 +1,183 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { type IntakeJournalEntry, useIntakeJournal } from "../../hooks/useIntakeJournal";
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
vi.mock("../../components/Auth", () => ({
useAuth: () => ({
authFetch: authFetchMock,
}),
}));
function buildEntry(overrides: Partial<IntakeJournalEntry> = {}): IntakeJournalEntry {
return {
doseTrackingId: 1,
doseId: "11-0-1760000000000-Daniel",
medicationId: 11,
medicationName: "Journal Med",
scheduledFor: "2026-02-10T08:00:00.000Z",
takenAt: "2026-02-10T08:05:00.000Z",
dismissed: false,
takenSource: "manual",
markedBy: "Daniel",
note: null,
updatedAt: null,
createdAt: null,
...overrides,
};
}
describe("useIntakeJournal", () => {
beforeEach(() => {
vi.clearAllMocks();
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
});
afterEach(() => {
vi.restoreAllMocks();
});
it("loads an event and updates local note state on save and delete", async () => {
const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
const initialEntry = buildEntry();
const savedEntry = buildEntry({
note: "Took after breakfast",
createdAt: "2026-02-10T08:06:00.000Z",
updatedAt: "2026-02-10T08:07:00.000Z",
});
fetchMock
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ entry: initialEntry }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ entry: savedEntry }),
})
.mockResolvedValueOnce({ ok: true });
const { result } = renderHook(() => useIntakeJournal());
await act(async () => {
await result.current.openJournalEditor(initialEntry.doseId);
});
expect(authFetchMock).toHaveBeenNthCalledWith(
1,
`/api/intake-journal/event/${encodeURIComponent(initialEntry.doseId)}`
);
expect(result.current.journalEditorOpen).toBe(true);
expect(result.current.journalTargetDoseId).toBe(initialEntry.doseId);
expect(result.current.journalEvent).toEqual(initialEntry);
let saveResult = false;
await act(async () => {
saveResult = await result.current.saveJournalNote("Took after breakfast");
});
expect(saveResult).toBe(true);
expect(authFetchMock).toHaveBeenNthCalledWith(
2,
`/api/intake-journal/event/${encodeURIComponent(initialEntry.doseId)}`,
expect.objectContaining({
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note: "Took after breakfast" }),
})
);
expect(result.current.journalEvent?.note).toBe("Took after breakfast");
let deleteResult = false;
await act(async () => {
deleteResult = await result.current.deleteJournalNote();
});
expect(deleteResult).toBe(true);
expect(authFetchMock).toHaveBeenNthCalledWith(
3,
`/api/intake-journal/event/${encodeURIComponent(initialEntry.doseId)}`,
expect.objectContaining({ method: "DELETE" })
);
expect(result.current.journalEvent).toEqual(
expect.objectContaining({
doseId: initialEntry.doseId,
note: null,
createdAt: null,
updatedAt: null,
})
);
});
it("loads filtered history and reopens an entry in the editor", async () => {
const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
const historyEntry = buildEntry({
doseId: "11-0-1760086400000-Daniel",
note: "Evening note",
updatedAt: "2026-02-11T18:30:00.000Z",
createdAt: "2026-02-11T18:20:00.000Z",
});
fetchMock
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ entries: [historyEntry] }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ entry: historyEntry }),
});
const { result } = renderHook(() => useIntakeJournal());
act(() => {
result.current.setJournalHistoryFilters({
medicationId: 11,
from: "2026-02-11T00:00:00.000Z",
to: "2026-02-11T23:59:59.000Z",
limit: 25,
});
result.current.openJournalHistory();
});
await waitFor(() => {
expect(result.current.journalHistoryEntries).toEqual([historyEntry]);
});
expect(authFetchMock).toHaveBeenNthCalledWith(
1,
"/api/intake-journal?medicationId=11&from=2026-02-11T00%3A00%3A00.000Z&to=2026-02-11T23%3A59%3A59.000Z&limit=25"
);
await act(async () => {
await result.current.reopenJournalHistoryEntry(historyEntry.doseId);
});
expect(authFetchMock).toHaveBeenNthCalledWith(
2,
`/api/intake-journal/event/${encodeURIComponent(historyEntry.doseId)}`
);
expect(result.current.journalHistoryOpen).toBe(false);
expect(result.current.journalEditorOpen).toBe(true);
expect(result.current.journalTargetDoseId).toBe(historyEntry.doseId);
expect(result.current.journalEvent).toEqual(historyEntry);
});
it("surfaces owner access errors instead of swallowing them", async () => {
const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
fetchMock.mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ error: "Tracked dose event not found for the current owner" }),
});
const { result } = renderHook(() => useIntakeJournal());
await act(async () => {
await result.current.openJournalEditor("99-0-1760000000000-Daniel");
});
expect(result.current.journalEvent).toBeNull();
expect(result.current.journalEventError).toBe("Tracked dose event not found for the current owner");
});
});
@@ -332,6 +332,7 @@ describe("useMedicationForm", () => {
act(() => {
result.current.addTakenByPerson("Alice");
result.current.addTakenByPerson("alice");
result.current.addTakenByPerson("");
});
+31 -5
View File
@@ -3,9 +3,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useMedications } from "../../hooks/useMedications";
import type { Medication } from "../../types";
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
vi.mock("../../components/Auth", () => ({
useAuth: () => ({
authFetch: authFetchMock,
}),
}));
describe("useMedications", () => {
beforeEach(() => {
vi.clearAllMocks();
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
@@ -16,6 +25,23 @@ describe("useMedications", () => {
vi.clearAllMocks();
});
it("loads medications through authFetch", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([]),
});
const { result } = renderHook(() => useMedications());
act(() => {
result.current.loadMeds();
});
await waitFor(() => {
expect(authFetchMock).toHaveBeenCalledWith("/api/medications?includeObsolete=true");
});
});
it("initializes with empty state", () => {
const { result } = renderHook(() => useMedications());
@@ -45,7 +71,7 @@ describe("useMedications", () => {
expect(result.current.meds).toEqual(mockMeds);
});
expect(fetch).toHaveBeenCalledWith("/api/medications?includeObsolete=true", { credentials: "include" });
expect(authFetchMock).toHaveBeenCalledWith("/api/medications?includeObsolete=true");
});
it("handles API error gracefully", async () => {
@@ -107,7 +133,7 @@ describe("useMedications", () => {
await result.current.deleteMed(1, 1, mockResetForm);
});
expect(fetch).toHaveBeenCalledWith("/api/medications/1", { method: "DELETE", credentials: "include" });
expect(authFetchMock).toHaveBeenCalledWith("/api/medications/1", { method: "DELETE" });
expect(mockResetForm).toHaveBeenCalled();
});
@@ -123,8 +149,8 @@ describe("useMedications", () => {
await result.current.deleteMed(5, 5, mockResetForm);
});
expect(fetch).toHaveBeenCalledWith("/api/medications/5", { method: "DELETE", credentials: "include" });
expect(fetch).toHaveBeenCalledWith("/api/medications?includeObsolete=true", { credentials: "include" });
expect(authFetchMock).toHaveBeenCalledWith("/api/medications/5", { method: "DELETE" });
expect(authFetchMock).toHaveBeenCalledWith("/api/medications?includeObsolete=true");
expect(mockResetForm).toHaveBeenCalled();
});
@@ -190,7 +216,7 @@ describe("useMedications", () => {
await result.current.deleteMedImage(1);
});
expect(fetch).toHaveBeenCalledWith("/api/medications/1/image", { method: "DELETE", credentials: "include" });
expect(authFetchMock).toHaveBeenCalledWith("/api/medications/1/image", { method: "DELETE" });
});
it("allows setting meds directly", () => {
@@ -0,0 +1,47 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useModalHistory } from "../../hooks/useModalHistory";
describe("useModalHistory", () => {
beforeEach(() => {
vi.spyOn(window.history, "pushState").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("pushes a modal history entry and closes the modal on browser back", () => {
const onClose = vi.fn();
const { rerender } = renderHook(({ isOpen }) => useModalHistory(isOpen, "journal-editor", onClose), {
initialProps: { isOpen: false },
});
rerender({ isOpen: true });
expect(window.history.pushState).toHaveBeenCalledWith({ modal: "journal-editor" }, "");
act(() => {
window.dispatchEvent(new PopStateEvent("popstate"));
});
expect(onClose).toHaveBeenCalledTimes(1);
});
it("stops parent popstate handlers when a nested modal consumes browser back", () => {
const onClose = vi.fn();
const parentClose = vi.fn();
window.addEventListener("popstate", parentClose);
renderHook(() => useModalHistory(true, "nested-confirm", onClose));
act(() => {
window.dispatchEvent(new PopStateEvent("popstate"));
});
expect(onClose).toHaveBeenCalledTimes(1);
expect(parentClose).not.toHaveBeenCalled();
window.removeEventListener("popstate", parentClose);
});
});
+51 -33
View File
@@ -3,9 +3,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useRefill } from "../../hooks/useRefill";
import type { Coverage, Medication } from "../../types";
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
vi.mock("../../components/Auth", () => ({
useAuth: () => ({
authFetch: authFetchMock,
}),
}));
function parseRequestBody(requestInit: RequestInit | undefined) {
expect(requestInit).toBeDefined();
expect(typeof requestInit?.body).toBe("string");
return JSON.parse(requestInit?.body as string) as Record<string, unknown>;
}
describe("useRefill", () => {
beforeEach(() => {
vi.clearAllMocks();
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve({}),
@@ -18,6 +33,21 @@ describe("useRefill", () => {
vi.restoreAllMocks();
});
it("loads refill history through authFetch", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([]),
});
const { result } = renderHook(() => useRefill());
await act(async () => {
await result.current.loadRefillHistory(1);
});
expect(authFetchMock).toHaveBeenCalledWith("/api/medications/1/refills");
});
it("initializes with default state", () => {
const { result } = renderHook(() => useRefill());
@@ -159,7 +189,7 @@ describe("useRefill", () => {
await result.current.submitRefill(1, 1, mockSetForm, mockLoadMeds);
});
expect(fetch).toHaveBeenNthCalledWith(
expect(authFetchMock).toHaveBeenNthCalledWith(
1,
"/api/medications/1/refill",
expect.objectContaining({
@@ -167,11 +197,7 @@ describe("useRefill", () => {
body: JSON.stringify({ packsAdded: 1, loosePillsAdded: 0, quantityAdded: 0, usePrescription: false }),
})
);
expect(fetch).toHaveBeenNthCalledWith(
2,
"/api/medications/1/refills",
expect.objectContaining({ credentials: "include" })
);
expect(authFetchMock).toHaveBeenNthCalledWith(2, "/api/medications/1/refills");
expect(mockSetForm).toHaveBeenCalled();
expect(mockLoadMeds).toHaveBeenCalled();
});
@@ -191,7 +217,7 @@ describe("useRefill", () => {
await result.current.submitRefill(1, 1, mockSetForm, mockLoadMeds);
});
expect(fetch).not.toHaveBeenCalled();
expect(authFetchMock).not.toHaveBeenCalled();
});
it("opens edit stock modal", () => {
@@ -306,7 +332,7 @@ describe("useRefill", () => {
await result.current.submitStockCorrection(1, mockMed, mockLoadMeds);
});
expect(fetch).toHaveBeenCalledWith(
expect(authFetchMock).toHaveBeenCalledWith(
"/api/medications/1/stock-adjustment",
expect.objectContaining({ method: "PATCH" })
);
@@ -342,7 +368,7 @@ describe("useRefill", () => {
await result.current.submitStockCorrection(1, mockMed, mockLoadMeds);
});
expect(fetch).toHaveBeenCalled();
expect(authFetchMock).toHaveBeenCalled();
expect(mockLoadMeds).toHaveBeenCalled();
});
@@ -379,8 +405,8 @@ describe("useRefill", () => {
await result.current.submitStockCorrection(8, blisterMed, mockLoadMeds);
});
const [, requestInit] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(requestInit.body as string);
const [, requestInit] = authFetchMock.mock.calls[0] ?? [];
const body = parseRequestBody(requestInit);
expect(body).toEqual({
stockAdjustment: 0,
packCount: 0,
@@ -431,8 +457,8 @@ describe("useRefill", () => {
await result.current.submitStockCorrection(id, med, mockLoadMeds);
});
const [, requestInit] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(requestInit.body as string);
const [, requestInit] = authFetchMock.mock.calls[0] ?? [];
const body = parseRequestBody(requestInit);
expect(body).toEqual({
stockAdjustment: 0,
packCount: 0,
@@ -506,8 +532,8 @@ describe("useRefill", () => {
await result.current.submitStockCorrection(id, med, mockLoadMeds);
});
const [, requestInit] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(requestInit.body as string);
const [, requestInit] = authFetchMock.mock.calls[0] ?? [];
const body = parseRequestBody(requestInit);
expect(body).toEqual({
stockAdjustment: 0,
packCount: 0,
@@ -554,8 +580,8 @@ describe("useRefill", () => {
await result.current.submitStockCorrection(12, liquidMed, mockLoadMeds);
});
const [, requestInit] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(requestInit.body as string);
const [, requestInit] = authFetchMock.mock.calls[0] ?? [];
const body = parseRequestBody(requestInit);
expect(body).toEqual({
stockAdjustment: -60,
packCount: 2,
@@ -604,11 +630,9 @@ describe("useRefill", () => {
// baseTotal (fixed) = getPackageSize(bottle) = looseTablets = 150
// newStockAdjustment = 149 - 150 = -1
// → getMedTotal = 150 + (-1) = 149 ✓
const fetchCall = (global.fetch as ReturnType<typeof vi.fn>).mock.calls.find(
(call) => call[0] === "/api/medications/4/stock-adjustment"
);
const fetchCall = authFetchMock.mock.calls.find((call) => call[0] === "/api/medications/4/stock-adjustment");
expect(fetchCall).toBeDefined();
const body = JSON.parse(fetchCall![1].body as string);
const body = parseRequestBody(fetchCall?.[1]);
expect(body.stockAdjustment).toBe(50);
expect(body.looseTablets).toBeUndefined();
});
@@ -657,11 +681,9 @@ describe("useRefill", () => {
// desiredTotal is capped to package max (25)
// baseTotal = getPackageSize(blister) = 25
// newStockAdjustment = 25 - 25 = 0
const fetchCall = (global.fetch as ReturnType<typeof vi.fn>).mock.calls.find(
(call) => call[0] === "/api/medications/2/stock-adjustment"
);
const fetchCall = authFetchMock.mock.calls.find((call) => call[0] === "/api/medications/2/stock-adjustment");
expect(fetchCall).toBeDefined();
const body = JSON.parse(fetchCall![1].body as string);
const body = parseRequestBody(fetchCall?.[1]);
expect(body.stockAdjustment).toBe(0);
});
@@ -699,11 +721,9 @@ describe("useRefill", () => {
await result.current.submitStockCorrection(5, blisterMed, mockLoadMeds);
});
const fetchCall = (global.fetch as ReturnType<typeof vi.fn>).mock.calls.find(
(call) => call[0] === "/api/medications/5/stock-adjustment"
);
const fetchCall = authFetchMock.mock.calls.find((call) => call[0] === "/api/medications/5/stock-adjustment");
expect(fetchCall).toBeDefined();
const body = JSON.parse(fetchCall![1].body as string);
const body = parseRequestBody(fetchCall?.[1]);
// NEW: baseTotal = structuralMax + finalLoosePills = 20 + 7 = 27; desiredTotal = 27 => stockAdjustment=0
// looseTablets is sent separately so DB reflects the actual loose count after correction
expect(body.stockAdjustment).toBe(0);
@@ -744,11 +764,9 @@ describe("useRefill", () => {
await result.current.submitStockCorrection(6, blisterMed, mockLoadMeds);
});
const fetchCall = (global.fetch as ReturnType<typeof vi.fn>).mock.calls.find(
(call) => call[0] === "/api/medications/6/stock-adjustment"
);
const fetchCall = authFetchMock.mock.calls.find((call) => call[0] === "/api/medications/6/stock-adjustment");
expect(fetchCall).toBeDefined();
const body = JSON.parse(fetchCall![1].body as string);
const body = parseRequestBody(fetchCall?.[1]);
// baseTotal = structuralMax + finalLoosePills = 275 + 2 = 277; desiredTotal = 57 => stockAdjustment = -220
expect(body.stockAdjustment).toBe(-220);
expect(body.looseTablets).toBe(2);
+224 -16
View File
@@ -3,16 +3,30 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useShare } from "../../hooks/useShare";
import type { Medication } from "../../types";
const feedbackMock = vi.hoisted(() => ({ showFeedback: vi.fn() }));
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
vi.mock("../../components/Auth", () => ({
useAuth: () => ({
authFetch: authFetchMock,
}),
}));
vi.mock("../../context/FeedbackContext", () => ({
useFeedback: () => ({
showFeedback: feedbackMock.showFeedback,
dismissFeedback: vi.fn(),
clearFeedback: vi.fn(),
}),
}));
describe("useShare", () => {
let mockAlert: ReturnType<typeof vi.fn>;
let mockClipboard: { writeText: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
mockAlert = vi.fn();
global.alert = mockAlert as unknown as typeof global.alert;
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
mockClipboard = { writeText: vi.fn().mockResolvedValue(undefined) };
Object.defineProperty(navigator, "clipboard", {
@@ -48,10 +62,13 @@ describe("useShare", () => {
expect(result.current.sharePeople).toEqual([]);
expect(result.current.shareSelectedPerson).toBe("");
expect(result.current.shareSelectedDays).toBe(30);
expect(result.current.shareSelectedExpiryDays).toBeNull();
expect(result.current.shareAllowJournalNotes).toBe(false);
expect(result.current.shareLink).toBeNull();
expect(result.current.activeShareLinks).toEqual([]);
});
it("opens share dialog with people from medications", () => {
it("opens share dialog with people from medications", async () => {
const { result } = renderHook(() => useShare());
const meds: Medication[] = [
@@ -85,6 +102,10 @@ describe("useShare", () => {
result.current.openShareDialog(meds);
});
await act(async () => {
await Promise.resolve();
});
expect(result.current.showShareDialog).toBe(true);
expect(result.current.sharePeople).toEqual(["all", "Alice", "Bob", "Charlie"]);
expect(result.current.shareSelectedPerson).toBe("Alice");
@@ -146,24 +167,26 @@ describe("useShare", () => {
});
await act(async () => {
await Promise.resolve();
await result.current.generateShareLink();
});
expect(fetch).toHaveBeenCalledWith(
expect(authFetchMock).toHaveBeenNthCalledWith(
2,
"/api/share",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ takenBy: "Alice", scheduleDays: 30 }),
body: JSON.stringify({ takenBy: "Alice", scheduleDays: 30, expiryDays: null, allowJournalNotes: false }),
})
);
expect(authFetchMock).toHaveBeenNthCalledWith(1, "/api/share");
expect(result.current.shareLink).toBe("http://localhost:5173/share/test-token");
});
it("handles share link generation error", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ error: "Failed to generate" }),
});
authFetchMock
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ shareLinks: [] }) } as Response)
.mockResolvedValueOnce({ ok: false, json: () => Promise.resolve({ error: "Failed to generate" }) } as Response);
const { result } = renderHook(() => useShare());
@@ -187,15 +210,18 @@ describe("useShare", () => {
});
await act(async () => {
await Promise.resolve();
await result.current.generateShareLink();
});
expect(mockAlert).toHaveBeenCalled();
expect(feedbackMock.showFeedback).toHaveBeenCalledWith({ message: "Failed to generate", tone: "error" });
expect(result.current.shareLink).toBeNull();
});
it("handles network error on share link generation", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Network error"));
authFetchMock
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ shareLinks: [] }) } as Response)
.mockRejectedValueOnce(new Error("Network error"));
const { result } = renderHook(() => useShare());
@@ -219,10 +245,11 @@ describe("useShare", () => {
});
await act(async () => {
await Promise.resolve();
await result.current.generateShareLink();
});
expect(mockAlert).toHaveBeenCalled();
expect(feedbackMock.showFeedback).toHaveBeenCalledWith({ message: "share.generateFailed", tone: "error" });
});
it("does nothing when generateShareLink called without selected person", async () => {
@@ -233,7 +260,7 @@ describe("useShare", () => {
await result.current.generateShareLink();
});
expect(fetch).not.toHaveBeenCalled();
expect(authFetchMock).not.toHaveBeenCalled();
});
it("copies share link to clipboard", async () => {
@@ -338,17 +365,198 @@ describe("useShare", () => {
expect(result.current.showShareDialog).toBe(false);
expect(result.current.shareLink).toBeNull();
expect(result.current.shareCopied).toBe(false);
expect(result.current.shareSelectedExpiryDays).toBeNull();
expect(result.current.shareAllowJournalNotes).toBe(false);
expect(result.current.activeShareLinks).toEqual([]);
});
it("allows changing selected person and days", () => {
it("includes selected expiry when generating a share link", async () => {
const { result } = renderHook(() => useShare());
const meds: Medication[] = [
{
id: 1,
name: "Med1",
takenBy: ["Alice"],
packageType: "blister",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
blisters: [],
updatedAt: null,
},
];
act(() => {
result.current.openShareDialog(meds);
result.current.setShareSelectedExpiryDays(7);
});
await act(async () => {
await Promise.resolve();
await result.current.generateShareLink();
});
expect(authFetchMock).toHaveBeenCalledWith(
"/api/share",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ takenBy: "Alice", scheduleDays: 30, expiryDays: 7, allowJournalNotes: false }),
})
);
});
it("includes the shared journal-note permission when generating a share link", async () => {
const { result } = renderHook(() => useShare());
const meds: Medication[] = [
{
id: 1,
name: "Med1",
takenBy: ["Alice"],
packageType: "blister",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
blisters: [],
updatedAt: null,
},
];
act(() => {
result.current.openShareDialog(meds);
result.current.setShareAllowJournalNotes(true);
});
await act(async () => {
await Promise.resolve();
await result.current.generateShareLink();
});
expect(authFetchMock).toHaveBeenCalledWith(
"/api/share",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ takenBy: "Alice", scheduleDays: 30, expiryDays: null, allowJournalNotes: true }),
})
);
});
it("loads active share links when opening the dialog", async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
shareLinks: [
{
token: "abcdef0123456789",
takenBy: "Alice",
scheduleDays: 30,
createdAt: "2026-05-17T12:00:00.000Z",
expiresAt: null,
allowJournalNotes: true,
shareUrl: "/share/abcdef0123456789",
},
],
}),
})
.mockResolvedValue({ ok: true, json: () => Promise.resolve({ token: "test-token" }) });
const { result } = renderHook(() => useShare());
const meds: Medication[] = [
{
id: 1,
name: "Med1",
takenBy: ["Alice"],
packageType: "blister",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
blisters: [],
updatedAt: null,
},
];
act(() => {
result.current.openShareDialog(meds);
});
await act(async () => {
await Promise.resolve();
});
expect(result.current.activeShareLinks).toHaveLength(1);
expect(result.current.activeShareLinks[0].token).toBe("abcdef0123456789");
});
it("revokes an active share link", async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
shareLinks: [
{
token: "abcdef0123456789",
takenBy: "Alice",
scheduleDays: 30,
createdAt: "2026-05-17T12:00:00.000Z",
expiresAt: null,
allowJournalNotes: false,
shareUrl: "/share/abcdef0123456789",
},
],
}),
})
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) });
const { result } = renderHook(() => useShare());
const meds: Medication[] = [
{
id: 1,
name: "Med1",
takenBy: ["Alice"],
packageType: "blister",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
blisters: [],
updatedAt: null,
},
];
act(() => {
result.current.openShareDialog(meds);
});
await act(async () => {
await Promise.resolve();
});
await act(async () => {
await result.current.revokeShareLink("abcdef0123456789");
});
expect(authFetchMock).toHaveBeenCalledWith("/api/share/abcdef0123456789", { method: "DELETE" });
expect(result.current.activeShareLinks).toEqual([]);
});
it("allows changing selected person, days, and expiry", () => {
const { result } = renderHook(() => useShare());
act(() => {
result.current.setShareSelectedPerson("Bob");
result.current.setShareSelectedDays(90);
result.current.setShareSelectedExpiryDays(30);
result.current.setShareAllowJournalNotes(true);
});
expect(result.current.shareSelectedPerson).toBe("Bob");
expect(result.current.shareSelectedDays).toBe(90);
expect(result.current.shareSelectedExpiryDays).toBe(30);
expect(result.current.shareAllowJournalNotes).toBe(true);
});
});
+150 -8
View File
@@ -11,6 +11,9 @@ import {
userStorageKey,
} from "../../pages/dashboard-helpers";
const feedbackMock = vi.hoisted(() => ({ showFeedback: vi.fn() }));
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
// Mock data for tests with medications
const mockMeds = [
{
@@ -130,6 +133,21 @@ const mockTodayDay = {
],
};
const mockJournalEntry = {
doseTrackingId: 1,
doseId: "1-0-1760000000000",
medicationId: 1,
medicationName: "Aspirin",
scheduledFor: "2026-05-21T09:00:00.000Z",
takenAt: "2026-05-21T09:05:00.000Z",
dismissed: false,
takenSource: "manual" as const,
markedBy: null,
note: "",
updatedAt: null,
createdAt: null,
};
function getRouteDateKey(value: Date): string {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
@@ -321,12 +339,22 @@ vi.mock("../../context", () => ({
vi.mock("../../components/Auth", () => ({
useAuth: () => ({
user: { id: 1, username: "testuser" },
authFetch: authFetchMock,
}),
}));
vi.mock("../../context/FeedbackContext", () => ({
useFeedback: () => ({
showFeedback: feedbackMock.showFeedback,
dismissFeedback: vi.fn(),
clearFeedback: vi.fn(),
}),
}));
describe("DashboardPage", () => {
beforeEach(() => {
vi.clearAllMocks();
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
localStorage.clear();
mockContextValue = createMockAppContext();
HTMLElement.prototype.scrollIntoView = vi.fn();
@@ -421,6 +449,121 @@ describe("DashboardPage", () => {
expect(screen.getByText("09:00")).toBeInTheDocument();
});
it("disables the journal note action for untaken doses", () => {
const openJournalEditor = vi.fn();
mockContextValue = createMockAppContext({
openJournalEditor,
todayDay: {
dateStr: "Today",
date: new Date(),
isPast: false,
meds: [
{
medName: "Aspirin",
total: 1,
doses: [
{
id: "untaken-dose",
timeStr: "09:00",
when: Date.now() + 60_000,
usage: 1,
takenBy: ["John"],
},
],
lastWhen: Date.now() + 60_000,
},
],
},
});
render(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>
);
const noteButton = screen.getByRole("button", { name: "journal.actions.note" });
expect(noteButton).toBeDisabled();
expect(noteButton.closest("span")).toHaveAttribute("data-tooltip", "journal.actions.noteTakenOnly");
fireEvent.click(noteButton);
expect(openJournalEditor).not.toHaveBeenCalled();
});
it("enables the journal note action for skipped doses", () => {
const openJournalEditor = vi.fn();
const skippedDoseId = "skipped-dose-John";
mockContextValue = createMockAppContext({
openJournalEditor,
skippedDoses: new Set([skippedDoseId]),
todayDay: {
dateStr: "Today",
date: new Date(),
isPast: false,
meds: [
{
medName: "Aspirin",
total: 1,
doses: [
{
id: "skipped-dose",
timeStr: "09:00",
when: Date.now() - 60_000,
usage: 1,
takenBy: ["John"],
},
],
lastWhen: Date.now() - 60_000,
},
],
},
});
render(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>
);
const noteButton = screen.getByRole("button", { name: "journal.actions.note" });
expect(noteButton).not.toBeDisabled();
fireEvent.click(noteButton);
expect(openJournalEditor).toHaveBeenCalledWith(skippedDoseId);
});
it("closes the journal editor after saving a main app note", async () => {
const saveJournalNote = vi.fn(async () => true);
const closeJournalEditor = vi.fn();
mockContextValue = createMockAppContext({
journalEditorOpen: true,
journalEvent: mockJournalEntry,
journalEventLoading: false,
journalEventSaving: false,
journalEventDeleting: false,
journalEventError: null,
saveJournalNote,
closeJournalEditor,
deleteJournalNote: vi.fn(),
});
render(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>
);
fireEvent.change(screen.getByLabelText("journal.editor.noteLabel"), {
target: { value: "Main app note" },
});
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
await waitFor(() => {
expect(saveJournalNote).toHaveBeenCalledWith("Main app note");
expect(closeJournalEditor).toHaveBeenCalled();
});
});
it("renders schedule days selector", () => {
render(
<MemoryRouter>
@@ -1033,12 +1176,11 @@ describe("DashboardPage with shoutrrr notifications", () => {
fireEvent.click(sendButton);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
expect(authFetchMock).toHaveBeenCalledWith(
"/api/reminder/send-email",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
})
);
});
@@ -1153,8 +1295,6 @@ describe("DashboardPage with past days", () => {
it("posts the computed dismiss-until payload when clearing missed doses", async () => {
const loadMeds = vi.fn();
const alertMock = vi.fn();
global.alert = alertMock;
global.fetch = vi.fn().mockResolvedValue({ ok: true });
mockContextValue = createMockAppContext({
@@ -1175,22 +1315,24 @@ describe("DashboardPage with past days", () => {
fireEvent.click(screen.getByRole("button", { name: /dashboard\.schedules\.clearMissedConfirm/i }));
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
expect(authFetchMock).toHaveBeenCalledWith(
"/api/medications/dismiss-until",
expect.objectContaining({
method: "POST",
credentials: "include",
})
);
});
const body = JSON.parse(((global.fetch as ReturnType<typeof vi.fn>).mock.calls[0]?.[1]?.body as string) ?? "{}");
const body = JSON.parse(((authFetchMock as ReturnType<typeof vi.fn>).mock.calls[0]?.[1]?.body as string) ?? "{}");
expect(body).toEqual({
medicationIds: [1],
until: mockPastDays[0].date.toISOString().slice(0, 10),
});
expect(loadMeds).toHaveBeenCalled();
expect(alertMock).toHaveBeenCalledWith(expect.stringContaining("dashboard.schedules.clearMissedSuccess"));
expect(feedbackMock.showFeedback).toHaveBeenCalledWith({
message: expect.stringContaining("dashboard.schedules.clearMissedSuccess"),
tone: "success",
});
});
});
@@ -3,6 +3,8 @@ import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MedicationsPage } from "../../pages/MedicationsPage";
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
const mockMeds = [
{
id: 1,
@@ -140,7 +142,7 @@ vi.mock("../../context", () => ({
}));
vi.mock("../../components/Auth", () => ({
useAuth: () => ({ user: { id: 1, username: "testuser" }, isAuthenticated: true }),
useAuth: () => ({ user: { id: 1, username: "testuser" }, isAuthenticated: true, authFetch: authFetchMock }),
}));
vi.mock("../../components", async () => {
@@ -286,10 +288,22 @@ function createGroupedOpenFdaMedicationEnrichmentResults(count: number, name: st
describe("MedicationsPage", () => {
beforeEach(() => {
vi.clearAllMocks();
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
mockContextValue = createMockContext();
mockFormHookValue = createMockFormHook();
Object.defineProperty(window, "innerWidth", { value: 1200, writable: true });
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
Object.defineProperty(Element.prototype, "scrollIntoView", {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(window, "requestAnimationFrame", {
configurable: true,
value: (callback: FrameRequestCallback) => {
callback(0);
return 1;
},
});
Object.defineProperty(window, "cancelAnimationFrame", {
configurable: true,
value: vi.fn(),
});
@@ -538,9 +552,8 @@ describe("MedicationsPage with items", () => {
fireEvent.click(confirmButtons[confirmButtons.length - 1]);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medications/1/obsolete", {
expect(authFetchMock).toHaveBeenCalledWith("/api/medications/1/obsolete", {
method: "POST",
credentials: "include",
});
});
});
@@ -562,9 +575,8 @@ describe("MedicationsPage with items", () => {
fireEvent.click(screen.getByText("medications.list.reactivate"));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medications/2/reactivate", {
expect(authFetchMock).toHaveBeenCalledWith("/api/medications/2/reactivate", {
method: "POST",
credentials: "include",
});
});
});
@@ -750,18 +762,14 @@ describe("MedicationsPage form interactions", () => {
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.searchAction" }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Aspirin&limit=6", {
credentials: "include",
});
expect(authFetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Aspirin&limit=6");
});
await screen.findByText("Aspirin 500 mg tablets");
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.showMoreAction" }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Aspirin&limit=12", {
credentials: "include",
});
expect(authFetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Aspirin&limit=12");
});
await screen.findByText("Bayer Aspirin");
@@ -769,7 +777,7 @@ describe("MedicationsPage form interactions", () => {
fireEvent.click(screen.getAllByRole("button", { name: "form.enrichment.applyAction" })[0]);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/enrich", {
expect(authFetchMock).toHaveBeenCalledWith("/api/medication-enrichment/enrich", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -779,7 +787,6 @@ describe("MedicationsPage form interactions", () => {
code: "RX-ASPIRIN",
source: "rxnorm",
}),
credentials: "include",
});
expect(setForm).toHaveBeenCalledWith(
expect.objectContaining({
@@ -825,9 +832,7 @@ describe("MedicationsPage form interactions", () => {
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.searchAction" }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Aspirin&limit=6", {
credentials: "include",
});
expect(authFetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Aspirin&limit=6");
});
expect(await screen.findByText("form.enrichment.authRequired")).toBeInTheDocument();
@@ -867,9 +872,7 @@ describe("MedicationsPage form interactions", () => {
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.showMoreAction" }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Aspirin&limit=12", {
credentials: "include",
});
expect(authFetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Aspirin&limit=12");
});
await waitFor(() => {
@@ -912,9 +915,9 @@ describe("MedicationsPage form interactions", () => {
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.showMoreAction" }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(`/api/medication-enrichment/search?q=Aspirin&limit=${expectedLimit}`, {
credentials: "include",
});
expect(authFetchMock).toHaveBeenCalledWith(
`/api/medication-enrichment/search?q=Aspirin&limit=${expectedLimit}`
);
});
}
@@ -976,9 +979,7 @@ describe("MedicationsPage form interactions", () => {
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.showMoreAction" }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Aspirin&limit=12", {
credentials: "include",
});
expect(authFetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Aspirin&limit=12");
});
expect(screen.getByRole("button", { name: "form.enrichment.loadingMoreResults" })).toBeDisabled();
@@ -1057,12 +1058,8 @@ describe("MedicationsPage form interactions", () => {
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.showMoreAction" }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Tecfidera&limit=12", {
credentials: "include",
});
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Tecfidera&limit=18", {
credentials: "include",
});
expect(authFetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Tecfidera&limit=12");
expect(authFetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Tecfidera&limit=18");
});
await screen.findByText("Result 1");
@@ -1448,7 +1445,7 @@ describe("MedicationsPage form interactions", () => {
fireEvent.click(getEnrichmentPackageButtons()[1]);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/enrich", {
expect(authFetchMock).toHaveBeenCalledWith("/api/medication-enrichment/enrich", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -1458,7 +1455,6 @@ describe("MedicationsPage form interactions", () => {
code: "NDC-IBU",
source: "openfda",
}),
credentials: "include",
});
expect(setForm).toHaveBeenCalledWith(
expect.objectContaining({
@@ -1610,7 +1606,7 @@ describe("MedicationsPage form interactions", () => {
fireEvent.click(initialPackageButtons[0]);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/enrich", {
expect(authFetchMock).toHaveBeenCalledWith("/api/medication-enrichment/enrich", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -1620,7 +1616,6 @@ describe("MedicationsPage form interactions", () => {
code: "NDC-IBU-STRENGTH",
source: "openfda",
}),
credentials: "include",
});
});
@@ -1719,7 +1714,7 @@ describe("MedicationsPage form interactions", () => {
fireEvent.click(packageButtons[0]);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/enrich", {
expect(authFetchMock).toHaveBeenCalledWith("/api/medication-enrichment/enrich", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -1729,7 +1724,6 @@ describe("MedicationsPage form interactions", () => {
code: "NDC-PENDING-PACKAGE",
source: "openfda",
}),
credentials: "include",
});
});
+7 -5
View File
@@ -3,6 +3,8 @@ import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PlannerPage } from "../../pages/PlannerPage";
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
// Mock data
const mockMeds = [
{
@@ -48,12 +50,14 @@ vi.mock("../../context", () => ({
vi.mock("../../components/Auth", () => ({
useAuth: () => ({
user: { id: 1, username: "testuser" },
authFetch: authFetchMock,
}),
}));
describe("PlannerPage", () => {
beforeEach(() => {
vi.clearAllMocks();
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
localStorage.clear();
mockContextValue = createMockContext();
});
@@ -440,12 +444,11 @@ describe("PlannerPage with email enabled", () => {
fireEvent.click(notifyBtn);
});
expect(global.fetch).toHaveBeenCalledWith(
expect(authFetchMock).toHaveBeenCalledWith(
"/api/planner/send-email",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
})
);
@@ -580,16 +583,15 @@ describe("PlannerPage form interactions", () => {
fireEvent.submit(form);
});
expect(global.fetch).toHaveBeenCalledWith(
expect(authFetchMock).toHaveBeenCalledWith(
"/api/medications/usage",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
})
);
const fetchCall = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const fetchCall = (authFetchMock as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(fetchCall[1].body);
expect(body.includeUntilStart).toBe(true);
expect(typeof body.startDate).toBe("string");
+134 -1
View File
@@ -1,8 +1,11 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SchedulePage } from "../../pages/SchedulePage";
const feedbackMock = vi.hoisted(() => ({ showFeedback: vi.fn() }));
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
// Mock data
const mockMeds = [
{
@@ -85,6 +88,21 @@ const mockPastDays = [
},
];
const mockJournalEntry = {
doseTrackingId: 1,
doseId: `1-0-${FIXED_TIMESTAMP}-John`,
medicationId: 1,
medicationName: "Aspirin",
scheduledFor: "2026-05-21T09:00:00.000Z",
takenAt: "2026-05-21T09:05:00.000Z",
dismissed: false,
takenSource: "manual" as const,
markedBy: "John",
note: "",
updatedAt: null,
createdAt: null,
};
// Factory function for mock context
const createMockContext = (overrides = {}) => ({
meds: [],
@@ -116,6 +134,7 @@ const createMockContext = (overrides = {}) => ({
openUserFilter: vi.fn(),
isDoseTakenAutomatically: vi.fn(() => false),
missedPastDoseIds: [],
loadMeds: vi.fn(),
...overrides,
});
@@ -129,12 +148,22 @@ vi.mock("../../context", () => ({
vi.mock("../../components/Auth", () => ({
useAuth: () => ({
user: { id: 1, username: "testuser" },
authFetch: authFetchMock,
}),
}));
vi.mock("../../context/FeedbackContext", () => ({
useFeedback: () => ({
showFeedback: feedbackMock.showFeedback,
dismissFeedback: vi.fn(),
clearFeedback: vi.fn(),
}),
}));
describe("SchedulePage", () => {
beforeEach(() => {
vi.clearAllMocks();
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
localStorage.clear();
mockContextValue = createMockContext();
});
@@ -185,6 +214,29 @@ describe("SchedulePage", () => {
expect(timeline).toBeInTheDocument();
});
it("disables the journal note action for untaken doses", () => {
const openJournalEditor = vi.fn();
mockContextValue = createMockContext({
meds: mockMeds,
coverageByMed: mockCoverageByMed,
futureDays: mockFutureDays,
openJournalEditor,
});
render(
<MemoryRouter>
<SchedulePage />
</MemoryRouter>
);
const noteButton = screen.getByRole("button", { name: "journal.actions.note" });
expect(noteButton).toBeDisabled();
expect(noteButton.closest("span")).toHaveAttribute("data-tooltip", "journal.actions.noteTakenOnly");
fireEvent.click(noteButton);
expect(openJournalEditor).not.toHaveBeenCalled();
});
it("shows empty state when no medications", () => {
render(
<MemoryRouter>
@@ -248,6 +300,48 @@ describe("SchedulePage", () => {
fireEvent.change(select, { target: { value: "90" } });
expect(setScheduleDays).toHaveBeenCalledWith(90);
});
it("posts the computed dismiss-until payload when clearing missed doses", async () => {
const loadMeds = vi.fn();
global.fetch = vi.fn().mockResolvedValue({ ok: true });
mockContextValue = createMockContext({
meds: mockMeds,
coverageByMed: mockCoverageByMed,
pastDays: mockPastDays,
missedPastDoseIds: [`${mockPastDays[0].meds[0].doses[0].id}-John`],
loadMeds,
});
render(
<MemoryRouter>
<SchedulePage />
</MemoryRouter>
);
fireEvent.click(screen.getByRole("button", { name: /dashboard\.schedules\.clearMissed/i }));
fireEvent.click(screen.getByRole("button", { name: /dashboard\.schedules\.clearMissedConfirm/i }));
await waitFor(() => {
expect(authFetchMock).toHaveBeenCalledWith(
"/api/medications/dismiss-until",
expect.objectContaining({
method: "POST",
})
);
});
const body = JSON.parse(((authFetchMock as ReturnType<typeof vi.fn>).mock.calls[0]?.[1]?.body as string) ?? "{}");
expect(body).toEqual({
medicationIds: [1],
until: mockPastDays[0].date.toISOString().slice(0, 10),
});
expect(loadMeds).toHaveBeenCalled();
expect(feedbackMock.showFeedback).toHaveBeenCalledWith({
message: expect.stringContaining("dashboard.schedules.clearMissedSuccess"),
tone: "success",
});
});
});
describe("SchedulePage structure", () => {
@@ -726,11 +820,13 @@ describe("SchedulePage skip behavior", () => {
it("renders undo skip state for skipped doses", () => {
const skippedDoseId = `1-0-${FIXED_TIMESTAMP}-John`;
const openJournalEditor = vi.fn();
mockContextValue = createMockContext({
meds: mockMeds,
futureDays: mockFutureDays,
coverageByMed: mockCoverageByMed,
skippedDoses: new Set([skippedDoseId]),
openJournalEditor,
});
render(
@@ -741,6 +837,43 @@ describe("SchedulePage skip behavior", () => {
expect(document.querySelector(".dose-btn.undo.skip")).toBeInTheDocument();
expect(screen.getByText("John").closest(".dose-person")).toHaveClass("skipped");
const noteButton = screen.getByRole("button", { name: "journal.actions.note" });
expect(noteButton).not.toBeDisabled();
fireEvent.click(noteButton);
expect(openJournalEditor).toHaveBeenCalledWith(skippedDoseId);
});
it("closes the journal editor after saving a main app note", async () => {
const saveJournalNote = vi.fn(async () => true);
const closeJournalEditor = vi.fn();
mockContextValue = createMockContext({
journalEditorOpen: true,
journalEvent: mockJournalEntry,
journalEventLoading: false,
journalEventSaving: false,
journalEventDeleting: false,
journalEventError: null,
saveJournalNote,
closeJournalEditor,
deleteJournalNote: vi.fn(),
});
render(
<MemoryRouter>
<SchedulePage />
</MemoryRouter>
);
fireEvent.change(screen.getByLabelText("journal.editor.noteLabel"), {
target: { value: "Main app note" },
});
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
await waitFor(() => {
expect(saveJournalNote).toHaveBeenCalledWith("Main app note");
expect(closeJournalEditor).toHaveBeenCalled();
});
});
it("calls undoDoseSkipped when clicking undo skip", () => {
+115 -35
View File
@@ -1,9 +1,10 @@
import { fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SettingsPage } from "../../pages/SettingsPage";
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
const changeLanguageMock = vi.fn();
vi.mock("react-i18next", async () => {
@@ -42,6 +43,9 @@ const createMockContext = (overrides = {}) => ({
reminderRepeatIntervalMinutes: 30,
maxNaggingReminders: 5,
language: "en",
timezone: "Europe/Berlin",
serverTimezone: "Europe/Berlin",
availableTimezones: ["Europe/Berlin", "UTC"],
stockCalculationMode: "automatic",
smtpHost: "",
smtpPort: 587,
@@ -84,6 +88,8 @@ const createMockContext = (overrides = {}) => ({
setShowImportConfirm: vi.fn(),
pendingImportData: null,
setPendingImportData: vi.fn(),
importPreview: null,
setImportPreview: vi.fn(),
handleImportConfirm: vi.fn(),
importResult: null,
setImportResult: vi.fn(),
@@ -98,14 +104,9 @@ vi.mock("../../context", () => ({
useAppContext: () => mockContextValue,
}));
interface MockConfirmModalProps {
title: string;
message: ReactNode;
confirmLabel: string;
cancelLabel: string;
onConfirm: () => void;
onCancel: () => void;
}
vi.mock("../../components/Auth", () => ({
useAuth: () => ({ authFetch: authFetchMock }),
}));
interface MockExportModalProps {
isOpen: boolean;
@@ -113,31 +114,53 @@ interface MockExportModalProps {
onExport: () => void;
}
vi.mock("../../components", () => ({
ConfirmModal: ({ title, message, confirmLabel, cancelLabel, onConfirm, onCancel }: MockConfirmModalProps) => (
<div>
<div>{title}</div>
<div>{message}</div>
<button type="button" onClick={onConfirm}>
{confirmLabel}
</button>
<button type="button" onClick={onCancel}>
{cancelLabel}
</button>
</div>
),
ExportModal: ({ isOpen, onClose, onExport }: MockExportModalProps) =>
isOpen ? (
<div>
<button type="button" onClick={onExport}>
export-modal-export
</button>
<button type="button" onClick={onClose}>
export-modal-close
</button>
</div>
) : null,
}));
const createImportPreview = (overrides = {}) => ({
version: "1.6",
exportedAt: "2026-05-17T10:00:00.000Z",
includeSensitiveData: false,
incoming: {
medications: 1,
doseHistory: 2,
refillHistory: 3,
shareLinks: 4,
journalEntries: 1,
imageCount: 0,
hasSettings: true,
},
current: {
medications: 1,
doseHistory: 0,
refillHistory: 0,
shareLinks: 0,
hasSettings: false,
},
warnings: {
replacesExistingData: true,
regeneratesShareLinks: true,
containsImages: false,
containsSensitiveData: false,
},
...overrides,
});
vi.mock("../../components", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../components")>();
return {
...actual,
ExportModal: ({ isOpen, onClose, onExport }: MockExportModalProps) =>
isOpen ? (
<div>
<button type="button" onClick={onExport}>
export-modal-export
</button>
<button type="button" onClick={onClose}>
export-modal-close
</button>
</div>
) : null,
};
});
function renderPage() {
render(
@@ -151,6 +174,7 @@ describe("SettingsPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockContextValue = createMockContext();
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
fetchMock.mockResolvedValue({ ok: true, json: async () => ({}) });
vi.stubGlobal("fetch", fetchMock);
});
@@ -200,7 +224,24 @@ describe("SettingsPage", () => {
expect(select).toBeInTheDocument();
fireEvent.change(select as HTMLSelectElement, { target: { value: "de" } });
expect(changeLanguageMock).toHaveBeenCalledWith("de");
expect(fetchMock).toHaveBeenCalledWith("/api/settings/language", expect.objectContaining({ method: "PUT" }));
expect(authFetchMock).toHaveBeenCalledWith("/api/settings/language", expect.objectContaining({ method: "PUT" }));
});
it("generates an API key through authFetch and shows the returned token", async () => {
fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({ token: "new-token-123" }) });
renderPage();
fireEvent.click(screen.getByText("settings.apiKey.generateButton"));
expect(authFetchMock).toHaveBeenCalledWith(
"/api/auth/api-keys",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ name: "Default API Key", scope: "write" }),
})
);
expect(await screen.findByDisplayValue("new-token-123")).toBeInTheDocument();
});
it("updates timeline toggles through setSettings", () => {
@@ -379,10 +420,13 @@ describe("SettingsPage", () => {
it("cancels import confirm and clears pending import", () => {
const setShowImportConfirm = vi.fn();
const setPendingImportData = vi.fn();
const setImportPreview = vi.fn();
mockContextValue = createMockContext({
setShowImportConfirm,
setPendingImportData,
setImportPreview,
showImportConfirm: true,
importPreview: createImportPreview(),
meds: [{ id: 1 }],
});
@@ -390,6 +434,7 @@ describe("SettingsPage", () => {
fireEvent.click(screen.getByText("exportImport.cancelButton"));
expect(setShowImportConfirm).toHaveBeenCalledWith(false);
expect(setPendingImportData).toHaveBeenCalledWith(null);
expect(setImportPreview).toHaveBeenCalledWith(null);
});
it("renders notification matrix with toggle switches", () => {
@@ -452,11 +497,13 @@ describe("SettingsPage", () => {
mockContextValue = createMockContext({
handleImportConfirm,
showImportConfirm: true,
importPreview: createImportPreview(),
meds: [{ id: 1 }],
});
renderPage();
expect(screen.getByText("exportImport.confirmImport")).toBeInTheDocument();
expect(screen.getByText("exportImport.reviewDescription")).toBeInTheDocument();
expect(screen.getByText(/exportImport\.confirmImportWarning/i)).toBeInTheDocument();
fireEvent.click(screen.getByText("exportImport.confirmButton"));
@@ -466,19 +513,52 @@ describe("SettingsPage", () => {
it("renders import confirm for empty state and handles cancel", () => {
const setShowImportConfirm = vi.fn();
const setPendingImportData = vi.fn();
const setImportPreview = vi.fn();
mockContextValue = createMockContext({
setShowImportConfirm,
setPendingImportData,
setImportPreview,
showImportConfirm: true,
importPreview: createImportPreview({
current: {
medications: 0,
doseHistory: 0,
refillHistory: 0,
shareLinks: 0,
hasSettings: false,
},
warnings: {
replacesExistingData: false,
regeneratesShareLinks: false,
containsImages: false,
containsSensitiveData: false,
},
}),
meds: [],
});
renderPage();
expect(screen.getByText("exportImport.confirmImportEmpty")).toBeInTheDocument();
expect(screen.getByText("exportImport.reviewDescriptionEmpty")).toBeInTheDocument();
expect(screen.getByText("exportImport.confirmImportEmptyMessage")).toBeInTheDocument();
fireEvent.click(screen.getByText("exportImport.cancelButton"));
expect(setShowImportConfirm).toHaveBeenCalledWith(false);
expect(setPendingImportData).toHaveBeenCalledWith(null);
expect(setImportPreview).toHaveBeenCalledWith(null);
});
it("offers backup-first from the import review modal", () => {
const handleExport = vi.fn();
mockContextValue = createMockContext({
handleExport,
showImportConfirm: true,
importPreview: createImportPreview(),
meds: [{ id: 1 }],
});
renderPage();
fireEvent.click(screen.getByText("exportImport.backupFirst"));
expect(handleExport).toHaveBeenCalledWith(true);
});
});
+1
View File
@@ -328,6 +328,7 @@ export type SharedScheduleData = {
takenBy: string;
sharedBy: string | null;
scheduleDays: number;
allowJournalNotes?: boolean;
medications: SharedMedication[];
stockThresholds?: {
lowStockDays: number;
+35
View File
@@ -0,0 +1,35 @@
export function getPersonTagKey(value: string): string {
return value.trim().toLocaleLowerCase();
}
export function personTagsMatch(left: string | null | undefined, right: string | null | undefined): boolean {
if (typeof left !== "string" || typeof right !== "string") {
return false;
}
return getPersonTagKey(left) === getPersonTagKey(right);
}
export function mergePersonTags(values: Array<string | null | undefined>): string[] {
const merged = new Map<string, string>();
for (const value of values) {
if (typeof value !== "string") {
continue;
}
const trimmed = value.trim();
if (!trimmed) {
continue;
}
const key = getPersonTagKey(trimmed);
if (!merged.has(key)) {
merged.set(key, trimmed);
}
}
return Array.from(merged.values()).sort((left, right) =>
left.localeCompare(right, undefined, { sensitivity: "accent" })
);
}