diff --git a/frontend/e2e/fixtures/index.ts b/frontend/e2e/fixtures/index.ts index c7d0926..3cf709e 100644 --- a/frontend/e2e/fixtures/index.ts +++ b/frontend/e2e/fixtures/index.ts @@ -289,6 +289,7 @@ export interface TestShareToken { token: string; takenBy: string; scheduleDays: number; + allowJournalNotes?: boolean; expiresAt: string; } @@ -460,7 +461,11 @@ export async function deleteAllMedicationsViaAPI(): Promise { * Create a share token via the backend API. * Requires a medication with takenBy to exist first. */ -export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30): Promise { +export async function createShareTokenViaAPI( + takenBy: string, + scheduleDays = 30, + options: { allowJournalNotes?: boolean; expiryDays?: number | null } = {} +): Promise { let token = await ensureAuthCookie(); const apiBase = await getRuntimeApiBase(); for (let attempt = 0; attempt < 5; attempt++) { @@ -470,7 +475,12 @@ export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30) "Content-Type": "application/json", ...(token ? { Cookie: `access_token=${token}` } : {}), }, - body: JSON.stringify({ takenBy, scheduleDays }), + body: JSON.stringify({ + takenBy, + scheduleDays, + expiryDays: options.expiryDays ?? null, + allowJournalNotes: options.allowJournalNotes ?? false, + }), }); if (res.status === 401) { token = await refreshAuthCookieViaLogin(); diff --git a/frontend/e2e/mobile-modal-history.spec.ts b/frontend/e2e/mobile-modal-history.spec.ts new file mode 100644 index 0000000..4f7db8a --- /dev/null +++ b/frontend/e2e/mobile-modal-history.spec.ts @@ -0,0 +1,94 @@ +import { + authFile, + createMedicationViaAPI, + createShareTokenViaAPI, + deleteAllMedicationsViaAPI, + expect, + navigateTo, + test, +} from "./fixtures"; + +test.describe("Mobile modal browser back", () => { + test.use({ + storageState: authFile, + viewport: { width: 412, height: 915 }, + isMobile: true, + hasTouch: true, + }); + + test("closes owner-side modals with browser back on a Pixel-width viewport", async ({ page }) => { + await navigateTo(page, "/dashboard"); + + const journalHistoryButton = page.locator(".journal-history-button").first(); + await expect(journalHistoryButton).toBeVisible({ timeout: 10000 }); + await journalHistoryButton.click(); + + const journalHistoryModal = page.locator(".journal-history-modal"); + await expect(journalHistoryModal).toBeVisible({ timeout: 10000 }); + await page.goBack(); + await expect(journalHistoryModal).toBeHidden({ timeout: 10000 }); + + await navigateTo(page, "/settings"); + + const exportButton = page + .locator("button.secondary") + .filter({ hasText: /Export|Exportieren/i }) + .first(); + await expect(exportButton).toBeVisible({ timeout: 10000 }); + await exportButton.click(); + + const exportModal = page.locator(".modal-content").filter({ hasText: /Export Options|Export-Optionen/i }); + await expect(exportModal).toBeVisible({ timeout: 10000 }); + await page.goBack(); + await expect(exportModal).toBeHidden({ timeout: 10000 }); + }); + + test("closes the shared intake journal modal with browser back on mobile", async ({ page }) => { + const uniqueSuffix = Date.now().toString(36); + const person = `Mobile Journal ${uniqueSuffix}`; + const medicationName = `Mobile Shared Journal ${uniqueSuffix}`; + const start = new Date(); + start.setHours(8, 0, 0, 0); + const pad = (value: number) => value.toString().padStart(2, "0"); + const startTime = `${start.getFullYear()}-${pad(start.getMonth() + 1)}-${pad(start.getDate())}T${pad(start.getHours())}:${pad(start.getMinutes())}`; + + await deleteAllMedicationsViaAPI(); + await createMedicationViaAPI({ + name: medicationName, + takenBy: [person], + packageType: "blister", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 10, + intakes: [{ usage: 1, every: 1, start: startTime, intakeRemindersEnabled: false, takenBy: person }], + }); + + const shareToken = await createShareTokenViaAPI(person, 30, { allowJournalNotes: true }); + + await page.goto(`/share/${shareToken.token}`); + await page.waitForLoadState("networkidle"); + await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 }); + await expect(page.locator(".med-name-text").filter({ hasText: medicationName }).first()).toBeVisible({ + timeout: 15000, + }); + + const doseItem = page.locator(".dose-item").first(); + await expect(doseItem).toBeVisible({ timeout: 15000 }); + await doseItem.locator(".dose-btn.take").click(); + + const collapsedTodayDivider = page.locator(".day-block.today.collapsed .day-divider.clickable").first(); + if (await collapsedTodayDivider.isVisible().catch(() => false)) { + await collapsedTodayDivider.click(); + } + + const noteButton = page.locator(".dose-item").first().locator(".dose-btn.journal"); + await expect(noteButton).toBeEnabled({ timeout: 10000 }); + await noteButton.click(); + + const journalModal = page.locator(".journal-modal"); + await expect(journalModal).toBeVisible({ timeout: 10000 }); + await page.goBack(); + await expect(journalModal).toBeHidden({ timeout: 10000 }); + await expect(page.locator(".shared-schedule-container")).toBeVisible(); + }); +}); diff --git a/frontend/e2e/share-schedule.spec.ts b/frontend/e2e/share-schedule.spec.ts index 8a73d77..3b71674 100644 --- a/frontend/e2e/share-schedule.spec.ts +++ b/frontend/e2e/share-schedule.spec.ts @@ -18,7 +18,7 @@ import { */ test.describe("Share Schedule", () => { test.use({ storageState: authFile }); - test.describe.configure({ timeout: 90000 }); + test.describe.configure({ mode: "serial", timeout: 90000 }); const MED_ALICE = "ShareTest AliceMed"; const MED_BOB = "ShareTest BobMed"; @@ -300,4 +300,59 @@ test.describe("Share Schedule", () => { await page.locator("button.modal-close").click(); }); + + test("should let a shared recipient add and reopen a journal note", async ({ page }) => { + const uniqueSuffix = Date.now().toString(36); + const person = `Journal E2E ${uniqueSuffix}`; + const medicationName = `Share Journal E2E ${uniqueSuffix}`; + const journalNote = `Shared E2E note ${uniqueSuffix}`; + + await createMedicationViaAPI({ + name: medicationName, + takenBy: [person], + packageType: "blister", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 10, + intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false, takenBy: person }], + }); + + const shareToken = await createShareTokenViaAPI(person, 30, { allowJournalNotes: true }); + + await page.goto(`/share/${shareToken.token}`); + await page.waitForLoadState("networkidle"); + await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 }); + + await expect(page.locator(".med-name-text").filter({ hasText: medicationName }).first()).toBeVisible({ + timeout: 15000, + }); + + const doseItem = page.locator(".dose-item").first(); + await expect(doseItem).toBeVisible({ timeout: 15000 }); + await expect(doseItem.locator(".dose-btn.journal")).toBeDisabled(); + + await doseItem.locator(".dose-btn.take").click(); + + const collapsedTodayDivider = page.locator(".day-block.today.collapsed .day-divider.clickable").first(); + if (await collapsedTodayDivider.isVisible().catch(() => false)) { + await collapsedTodayDivider.click(); + } + + const updatedDoseItem = page.locator(".dose-item").first(); + const noteButton = updatedDoseItem.locator(".dose-btn.journal"); + await expect(noteButton).toBeEnabled({ timeout: 10000 }); + await noteButton.click(); + + const noteInput = page.locator("#journal-note-input"); + await expect(noteInput).toBeVisible({ timeout: 10000 }); + await expect(noteInput).toHaveValue(""); + + await noteInput.fill(journalNote); + await page.locator(".journal-modal-footer button.primary").click(); + await expect(page.locator(".journal-modal")).toBeHidden({ timeout: 10000 }); + + await noteButton.click(); + await expect(noteInput).toBeVisible({ timeout: 10000 }); + await expect(noteInput).toHaveValue(journalNote, { timeout: 10000 }); + }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c261708..49ac02c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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
{t("common.loading")}
; } +function AuthStatusCard({ theme, children }: { theme: "light" | "dark"; children: React.ReactNode }) { + return ( +
+
+

๐Ÿ’Š MedAssist-ng

+ {children} +
+
+ ); +} + // ============================================================================= // 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 ( - }> - - {/* Public share route - accessible without auth */} - } /> - } /> - {/* All other routes go through AppRouter */} - } /> - - + + }> + + {/* Public share route - accessible without auth */} + } /> + } /> + {/* All other routes go through AppRouter */} + } /> + + + ); } @@ -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 ( -
-
-

๐Ÿ’Š MedAssist-ng

-

Loading...

-
-
+ +

{t("common.loading")}

+
); } // Show error if we couldn't connect to the server if (authError) { return ( -
-
-

๐Ÿ’Š MedAssist-ng

-
- Connection Error -
- {authError} -
-

- Please check if the server is running and try again. -

- + +
+ {t("auth.connectionErrorTitle")} +
+ {authError}
-
+

{t("auth.connectionErrorHelp")}

+ + ); } // If auth state is null (shouldn't happen after loading, but be safe) if (!authState) { return ( -
-
-

๐Ÿ’Š MedAssist-ng

-

Initializing...

-
-
+ +

{t("common.initializing")}

+
); } @@ -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} /> diff --git a/frontend/src/components/Auth.tsx b/frontend/src/components/Auth.tsx index 7573de6..bbdb429 100644 --- a/frontend/src/components/Auth.tsx +++ b/frontend/src/components/Auth.tsx @@ -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; register: (username: string, password: string) => Promise; logout: () => Promise; @@ -64,6 +66,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [authState, setAuthState] = useState(null); const [loading, setLoading] = useState(true); const [authError, setAuthError] = useState(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 && (
+ {sessionExpired && ( +
+ {t("auth.sessionExpiredTitle")} +
+ {t("auth.sessionExpiredHelp")} +
+ )} {error &&
{error}
}
@@ -633,7 +656,14 @@ export function UserProfile({ onClose }: { onClose?: () => void }) { const [deleteLoading, setDeleteLoading] = useState(false); const fileInputRef = useRef(null); + const closeDeleteConfirm = useCallback(() => { + if (!deleteLoading) { + setShowDeleteConfirm(false); + } + }, [deleteLoading]); + useEscapeKey(!!onClose, onClose ?? (() => {})); + useModalHistory(showDeleteConfirm, "profile-delete-account", closeDeleteConfirm); async function handleAvatarUpload(e: React.ChangeEvent) { 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" /> diff --git a/frontend/src/components/ImportReviewModal.tsx b/frontend/src/components/ImportReviewModal.tsx new file mode 100644 index 0000000..92b806a --- /dev/null +++ b/frontend/src/components/ImportReviewModal.tsx @@ -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 ( +
{ + if (event.key !== "Escape") { + event.stopPropagation(); + } + }} + > +
event.stopPropagation()} + onKeyDown={(event) => { + if (event.key !== "Escape") { + event.stopPropagation(); + } + }} + > + +

{t(hasExistingData ? "exportImport.confirmImport" : "exportImport.confirmImportEmpty")}

+
+

{t(hasExistingData ? "exportImport.reviewDescription" : "exportImport.reviewDescriptionEmpty")}

+
+
+
+ {t("exportImport.incomingData")} + + {t("exportImport.summaryCounts", { + medications: importPreview.incoming.medications, + doses: importPreview.incoming.doseHistory, + refills: importPreview.incoming.refillHistory, + shares: importPreview.incoming.shareLinks, + })} + +
+
+ {t("exportImport.formatVersion", { version: importPreview.version })} + {t("exportImport.exportedAt", { date: formattedExportedAt })} + {importPreview.incoming.hasSettings && {t("exportImport.settingsIncluded")}} + {importPreview.incoming.journalEntries > 0 && ( + {t("exportImport.journalEntries", { count: importPreview.incoming.journalEntries })} + )} + {importPreview.incoming.imageCount > 0 && ( + {t("exportImport.imageCount", { count: importPreview.incoming.imageCount })} + )} +
+
+
+
+ {t("exportImport.currentData")} + + {t("exportImport.summaryCounts", { + medications: importPreview.current.medications, + doses: importPreview.current.doseHistory, + refills: importPreview.current.refillHistory, + shares: importPreview.current.shareLinks, + })} + +
+ {importPreview.current.hasSettings && ( + {t("exportImport.settingsConfigured")} + )} +
+
+ + {hasWarnings && ( +
+ {t("exportImport.warningListTitle")} +
    + {importPreview.warnings.replacesExistingData &&
  • {t("exportImport.warningReplaceData")}
  • } + {importPreview.warnings.regeneratesShareLinks &&
  • {t("exportImport.warningShareLinks")}
  • } + {importPreview.warnings.containsImages &&
  • {t("exportImport.warningImages")}
  • } + {importPreview.warnings.containsSensitiveData &&
  • {t("exportImport.warningSensitive")}
  • } +
+
+ )} + + {hasExistingData ? ( +

{t("exportImport.confirmImportWarning")}

+ ) : ( +

{t("exportImport.confirmImportEmptyMessage")}

+ )} + +

{t("exportImport.backupHint")}

+
+
+ +
+ {hasExistingData && ( + + )} + +
+
+
+
+ ); +} diff --git a/frontend/src/components/MedicationEnrichmentSection.tsx b/frontend/src/components/MedicationEnrichmentSection.tsx index 65bebf3..849a543 100644 --- a/frontend/src/components/MedicationEnrichmentSection.tsx +++ b/frontend/src/components/MedicationEnrichmentSection.tsx @@ -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", diff --git a/frontend/src/components/ReportModal.tsx b/frontend/src/components/ReportModal.tsx index b1489d5..fa029a4 100644 --- a/frontend/src/components/ReportModal.tsx +++ b/frontend/src/components/ReportModal.tsx @@ -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>(new Set()); const [format, setFormat] = useState("pdf"); const [generating, setGenerating] = useState(false); const [takenByFilter, setTakenByFilter] = useState>(new Set()); + const [dateRange, setDateRange] = useState(() => getDefaultDateRange()); + const [preview, setPreview] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); useScrollLock(isOpen); useEscapeKey(isOpen, onClose); // Collect all unique "taken by" people across all medications const allPeople = useMemo(() => { - const people = new Set(); - 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)

{t("report.title")}

{t("report.description")}

+
+

{t("report.dateRange")}

+
+
+ {t("report.from")} + setDateRange((prev) => ({ ...prev, startDate: e.target.value }))} + /> +
+
+ {t("report.until")} + setDateRange((prev) => ({ ...prev, endDate: e.target.value }))} + /> +
+
+
+ {/* Person filter */} {allPeople.length > 1 && (
@@ -279,6 +361,25 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
+ {errorMessage &&

{errorMessage}

} + + {preview && ( +
+
+

{t("report.preview")}

+ +
+

{t("report.previewDescription")}

+
{preview.content}
+
+ )} + {/* Actions */}
@@ -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; -async function fetchMedImages(meds: Medication[]): Promise { +async function fetchMedImages(meds: Medication[], authFetch: typeof fetch): Promise { 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((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(

${escHtml(t("report.docTitle"))}

${escHtml(t("report.docGenerated"))}: ${formatDate(new Date().toISOString())}

+

${escHtml(t("report.docRange"))}: ${formatDateTime(dateRange.startDate)} - ${formatDateTime(dateRange.endDate)}

${sections.join("\n")} `; diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx index 83f6966..a8bd3ed 100644 --- a/frontend/src/components/ShareDialog.tsx +++ b/frontend/src/components/ShareDialog.tsx @@ -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; + onRevokeShareLink: (token: string) => Promise; 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(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

{t("share.loadingActiveLinks")}

; + } + + if (activeShareLinks.length === 0) { + return

{t("share.noActiveLinks")}

; + } + + return ( +
    + {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 ( +
  • +
    + + {personLabel} + + + {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")}` : ""} + +
    + +
  • + ); + })} +
+ ); + }; + + const renderManageLinks = () => ( +
+ + {manageLinksOpen ?
{renderActiveShares()}
: null} +
+ ); + return (

{t("share.noPeople")}

+
{renderManageLinks()}
); } @@ -124,6 +229,7 @@ export function ShareDialog({ +
{renderManageLinks()}
); } @@ -159,6 +265,33 @@ export function ShareDialog({ +
+ + +
+ + +
+
{renderManageLinks()}
); })()} + {shareToRevoke && ( + { + const revoked = await onRevokeShareLink(shareToRevoke.token); + if (revoked) { + setShareToRevoke(null); + } + }} + onCancel={closeRevokeConfirm} + isLoading={revokingShareToken === shareToRevoke.token} + confirmVariant="danger" + overlayClassName="nested-confirm" + /> + )} ); diff --git a/frontend/src/components/SharedSchedule.tsx b/frontend/src/components/SharedSchedule.tsx index 97f8aed..4471a5d 100644 --- a/frontend/src/components/SharedSchedule.tsx +++ b/frontend/src/components/SharedSchedule.tsx @@ -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 { + try { + const data = (await response.json()) as { error?: string; code?: string }; + if (typeof data.error === "string" && data.error.trim().length > 0) { + return data.error; + } + if (typeof data.code === "string" && data.code.trim().length > 0) { + return data.code; + } + } catch { + // Fall back to the supplied message when the response body is not JSON. + } + + return fallbackMessage; +} + export function SharedSchedule() { const { token } = useParams<{ token: string }>(); const { t, i18n } = useTranslation(); + const { showFeedback } = useFeedback(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -39,8 +60,15 @@ export function SharedSchedule() { const [takenDoses, setTakenDoses] = useState>(new Set()); const [automaticTakenDoses, setAutomaticTakenDoses] = useState>(new Set()); const [dismissedDoses, setDismissedDoses] = useState>(new Set()); + const [sharedJournalDoseIdsWithNotes, setSharedJournalDoseIdsWithNotes] = useState>(new Set()); const mutationInFlightRef = useRef(0); const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null); + const [sharedJournalOpen, setSharedJournalOpen] = useState(false); + const [sharedJournalDoseId, setSharedJournalDoseId] = useState(null); + const [sharedJournalEntry, setSharedJournalEntry] = useState(null); + const [sharedJournalLoading, setSharedJournalLoading] = useState(false); + const [sharedJournalSaving, setSharedJournalSaving] = useState(false); + const [sharedJournalError, setSharedJournalError] = useState(null); const [showPastDays, setShowPastDays] = useState(false); const [showFutureDays, setShowFutureDays] = useState(false); @@ -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(); const automatic = new Set(); const dismissed = new Set(); + const journalDoseIds = new Set(); for (const d of data.doses as Array<{ doseId: string; dismissed?: boolean; skipped?: boolean; takenSource?: string; + hasJournalNote?: boolean; }>) { if (d.skipped === true || d.dismissed === true) { dismissed.add(d.doseId); @@ -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 ? ( ); + const journalButton = showSharedJournalAction ? ( + + + + ) : 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() {

{pageTitle}

+

{t("share.publicAccessHelp")}

)} + + undefined} + allowDelete={false} + />
); } diff --git a/frontend/src/components/UserFilterModal.tsx b/frontend/src/components/UserFilterModal.tsx index a837701..cac4eda 100644 --- a/frontend/src/components/UserFilterModal.tsx +++ b/frontend/src/components/UserFilterModal.tsx @@ -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 (
intake.takenBy === null || intake.takenBy === selectedUser + (intake) => intake.takenBy === null || personTagsMatch(intake.takenBy, selectedUser) ); return ( diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 6460c0b..70c157c 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -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"; diff --git a/frontend/src/components/intake-journal/IntakeJournalHistoryModal.tsx b/frontend/src/components/intake-journal/IntakeJournalHistoryModal.tsx new file mode 100644 index 0000000..87e9191 --- /dev/null +++ b/frontend/src/components/intake-journal/IntakeJournalHistoryModal.tsx @@ -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) => void; + onReload: () => Promise | void; + onResetFilters: () => void; + onReopen: (doseId: string) => Promise | void; +} + +function formatDisplayDateTime(value: string | null): string | null { + if (!value) { + return null; + } + + return formatDateTime(value, getNumericLocale()); +} + +function getJournalSourceLabel(entry: IntakeJournalEntry, t: ReturnType["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 =
{t("journal.history.loading")}
; + } else if (entries.length === 0) { + listContent =
{t("journal.history.empty")}
; + } else { + listContent = entries.map((entry) => ( +
+
+
+ +
+ {entry.medicationName} +

{formatDisplayDateTime(entry.scheduledFor) ?? t("common.notAvailable")}

+
+
+

{entry.note ?? t("journal.history.noNote")}

+
+ {t(entry.dismissed ? "journal.context.statusSkipped" : "journal.context.statusTaken")} + {getJournalSourceLabel(entry, t)} + {entry.updatedAt && ( + + {t("journal.history.updatedAt", { + date: formatDisplayDateTime(entry.updatedAt) ?? entry.updatedAt, + })} + + )} +
+
+ +
+ )); + } + + return ( +
{ + if (event.key !== "Escape") { + event.stopPropagation(); + } + }} + > +
event.stopPropagation()} + onKeyDown={(event) => { + if (event.key !== "Escape") { + event.stopPropagation(); + } + }} + > + +
+

{t("journal.history.title")}

+

{t("journal.history.description")}

+
+ +
+ +
+ {t("journal.history.filters.from")} + onFilterChange({ from: event.target.value })} + step="60" + aria-label={t("journal.history.filters.from")} + placeholder={t("journal.history.filters.fromPlaceholder")} + /> +
+
+ {t("journal.history.filters.to")} + onFilterChange({ to: event.target.value })} + step="60" + aria-label={t("journal.history.filters.to")} + placeholder={t("journal.history.filters.toPlaceholder")} + /> +
+
+ +
+ + +
+ + {error &&
{error}
} + +
{listContent}
+ +
+
+ +
+
+
+
+ ); +} diff --git a/frontend/src/components/intake-journal/IntakeJournalModal.tsx b/frontend/src/components/intake-journal/IntakeJournalModal.tsx new file mode 100644 index 0000000..26702e4 --- /dev/null +++ b/frontend/src/components/intake-journal/IntakeJournalModal.tsx @@ -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; + onDelete: () => Promise | void; + allowDelete?: boolean; +} + +function formatDisplayDateTime(value: string | null): string | null { + if (!value) { + return null; + } + + return formatDateTime(value, getNumericLocale()); +} + +function getJournalSourceLabel(entry: IntakeJournalEntry, t: ReturnType["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(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 =
{t("journal.editor.loading")}
; + } else if (entry) { + bodyContent = ( + <> +
+
+ +
+ {entry.medicationName} +

{entry.dismissed ? t("journal.context.statusSkipped") : t("journal.context.statusTaken")}

+
+
+
+
+ {t("journal.context.scheduledFor")} + {scheduledForLabel ?? t("common.notAvailable")} +
+
+ {t("journal.context.takenAt")} + {takenAtLabel ?? t("journal.context.notRecorded")} +
+
+ {t("journal.context.markedBy")} + {entry.markedBy ?? t("journal.context.self")} +
+
+ {t("journal.context.source")} + {getJournalSourceLabel(entry, t)} +
+
+
+ +