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:
+94
-78
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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("");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+1
-1
@@ -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;
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -328,6 +328,7 @@ export type SharedScheduleData = {
|
||||
takenBy: string;
|
||||
sharedBy: string | null;
|
||||
scheduleDays: number;
|
||||
allowJournalNotes?: boolean;
|
||||
medications: SharedMedication[];
|
||||
stockThresholds?: {
|
||||
lowStockDays: number;
|
||||
|
||||
@@ -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" })
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user