(null);
const hasDesktopFormHistoryState = useRef(false);
+ const getMedicationLinkState = useCallback((params: URLSearchParams) => {
+ const viewMedId = params.get("viewMedId");
+ if (viewMedId) {
+ return { mode: "view" as const, linkedMedId: viewMedId };
+ }
+
+ const editMedId = params.get("editMedId");
+ if (editMedId) {
+ return { mode: "edit" as const, linkedMedId: editMedId };
+ }
+
+ return { mode: null, linkedMedId: null };
+ }, []);
+
// Sync formChanged state to the global context for navigation blocking
const { setHasUnsavedChanges } = useUnsavedChanges();
useEffect(() => {
@@ -819,12 +835,13 @@ export function MedicationsPage() {
[t]
);
- const clearEditMedIdParam = useCallback(() => {
+ const clearMedicationLinkParams = useCallback(() => {
setSearchParams(
(prevParams) => {
- if (!prevParams.has("editMedId")) return prevParams;
+ if (!prevParams.has("editMedId") && !prevParams.has("viewMedId")) return prevParams;
const nextParams = new URLSearchParams(prevParams);
nextParams.delete("editMedId");
+ nextParams.delete("viewMedId");
return nextParams;
},
{ replace: true }
@@ -848,7 +865,7 @@ export function MedicationsPage() {
setShowUnsavedConfirm(true);
return;
}
- clearEditMedIdParam();
+ clearMedicationLinkParams();
// Mark as confirmed to avoid double confirmation in popstate handler
closeConfirmedRef.current = true;
window.history.back();
@@ -1159,7 +1176,7 @@ export function MedicationsPage() {
if (shouldCloseMobileModal) {
// Treat post-save close as confirmed so popstate does not trigger unsaved guards.
closeConfirmedRef.current = true;
- clearEditMedIdParam();
+ clearMedicationLinkParams();
setShowEditModal(false);
setReadOnlyView(false);
setActiveTab("general");
@@ -1188,7 +1205,8 @@ export function MedicationsPage() {
// Handle browser back button for modals and unsaved changes
useEffect(() => {
const handlePopState = () => {
- const currentEditMedId = new URLSearchParams(window.location.search).get("editMedId");
+ const currentParams = new URLSearchParams(window.location.search);
+ const { mode: currentLinkMode, linkedMedId: currentMedicationLinkId } = getMedicationLinkState(currentParams);
// Obsolete confirmation is open — dismiss it and stay where we are
if (showObsoleteConfirm) {
@@ -1207,10 +1225,10 @@ export function MedicationsPage() {
// If close was already confirmed programmatically, allow navigation
if (closeConfirmedRef.current) {
closeConfirmedRef.current = false;
- if (currentEditMedId) {
+ if (currentMedicationLinkId && currentLinkMode) {
// Prevent URL popstate from immediately reopening mobile edit for the same id.
- processedEditMedIdRef.current = currentEditMedId;
- clearEditMedIdParam();
+ processedMedicationLinkRef.current = `${currentLinkMode}:${currentMedicationLinkId}`;
+ clearMedicationLinkParams();
}
if (showEditModal) {
setShowEditModal(false);
@@ -1231,11 +1249,11 @@ export function MedicationsPage() {
setShowUnsavedConfirm(true);
return;
}
- if (currentEditMedId) {
+ if (currentMedicationLinkId && currentLinkMode) {
// Mark as handled before URL cleanup to avoid same-tick re-open races.
- processedEditMedIdRef.current = currentEditMedId;
+ processedMedicationLinkRef.current = `${currentLinkMode}:${currentMedicationLinkId}`;
}
- clearEditMedIdParam();
+ clearMedicationLinkParams();
setShowEditModal(false);
resetForm();
resetMedicationEnrichment();
@@ -1271,7 +1289,16 @@ export function MedicationsPage() {
};
window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState);
- }, [showObsoleteConfirm, showDeleteConfirm, showEditModal, viewMode, formChanged, resetForm, clearEditMedIdParam]);
+ }, [
+ showObsoleteConfirm,
+ showDeleteConfirm,
+ showEditModal,
+ viewMode,
+ formChanged,
+ resetForm,
+ clearMedicationLinkParams,
+ getMedicationLinkState,
+ ]);
// Close modal on Escape key
useEffect(() => {
@@ -1389,22 +1416,23 @@ export function MedicationsPage() {
}, [activeMeds, editingId]);
useEffect(() => {
- const editMedId = searchParams.get("editMedId");
- if (!editMedId) {
- processedEditMedIdRef.current = null;
+ const { mode: linkMode, linkedMedId } = getMedicationLinkState(searchParams);
+ if (!linkedMedId || !linkMode) {
+ processedMedicationLinkRef.current = null;
return;
}
- if (processedEditMedIdRef.current === editMedId) return;
- const parsedMedId = Number.parseInt(editMedId, 10);
+ const linkKey = `${linkMode}:${linkedMedId}`;
+ if (processedMedicationLinkRef.current === linkKey) return;
+ const parsedMedId = Number.parseInt(linkedMedId, 10);
if (Number.isNaN(parsedMedId)) return;
const medicationToEdit =
meds.find((med) => med.id === parsedMedId) ?? allMeds.find((med) => med.id === parsedMedId);
if (!medicationToEdit) return;
- processedEditMedIdRef.current = editMedId;
+ processedMedicationLinkRef.current = linkKey;
setShowNameValidation(false);
- setReadOnlyView(false);
+ setReadOnlyView(linkMode === "view");
setActiveTab("general");
resetMedicationEnrichment(medicationToEdit.name || medicationToEdit.genericName || "");
startEdit(medicationToEdit, openEditModal);
@@ -1415,8 +1443,9 @@ export function MedicationsPage() {
const nextParams = new URLSearchParams(searchParams);
nextParams.delete("editMedId");
+ nextParams.delete("viewMedId");
setSearchParams(nextParams, { replace: true });
- }, [allMeds, meds, openEditModal, searchParams, setSearchParams, startEdit]);
+ }, [allMeds, getMedicationLinkState, meds, openEditModal, searchParams, setSearchParams, startEdit]);
const selectedMedication = useMemo(() => {
if (!editingId) return null;
diff --git a/frontend/src/test/App.test.tsx b/frontend/src/test/App.test.tsx
index 5f0c693..0302f6e 100644
--- a/frontend/src/test/App.test.tsx
+++ b/frontend/src/test/App.test.tsx
@@ -1,5 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/react";
-import { MemoryRouter } from "react-router-dom";
+import { MemoryRouter, useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import App from "../App";
@@ -59,7 +59,15 @@ vi.mock("../context", async () => {
});
vi.mock("../pages", () => ({
- DashboardPage: () => dashboard-page
,
+ DashboardPage: () => {
+ const location = useLocation();
+ return (
+
+ dashboard-page
+ {location.search}
+
+ );
+ },
MedicationsPage: () => medications-page
,
PlannerPage: () => planner-page
,
SchedulePage: () => schedule-page
,
@@ -265,6 +273,19 @@ describe("App", () => {
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
});
+ it("preserves notification query params when redirecting root to dashboard", () => {
+ const search = "?date=2026-05-06&medId=4332&doseId=4332-0-1778104500000";
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("dashboard-page")).toBeInTheDocument();
+ expect(screen.getByTestId("dashboard-location-search")).toHaveTextContent(search);
+ });
+
it("renders initializing state when auth state is missing", () => {
authMock = {
user: null,
diff --git a/frontend/src/test/components/Auth.test.tsx b/frontend/src/test/components/Auth.test.tsx
index 45c3a3b..6682a81 100644
--- a/frontend/src/test/components/Auth.test.tsx
+++ b/frontend/src/test/components/Auth.test.tsx
@@ -175,6 +175,10 @@ describe("LoginForm", () => {
oidcProviderName: "",
};
+ afterEach(() => {
+ window.history.replaceState({}, "", "/");
+ });
+
beforeEach(() => {
vi.clearAllMocks();
(global.fetch as ReturnType)
diff --git a/frontend/src/test/pages/MedicationsPage.test.tsx b/frontend/src/test/pages/MedicationsPage.test.tsx
index d322d9d..c67583b 100644
--- a/frontend/src/test/pages/MedicationsPage.test.tsx
+++ b/frontend/src/test/pages/MedicationsPage.test.tsx
@@ -475,6 +475,21 @@ describe("MedicationsPage with items", () => {
});
});
+ it("opens read-only view from viewMedId query parameter", async () => {
+ const startEdit = vi.fn();
+ mockFormHookValue = createMockFormHook({ startEdit });
+ fetchMock.mockResolvedValue({ ok: true, json: async () => mockMeds });
+
+ renderPage("/medications?viewMedId=1");
+
+ await waitFor(() => {
+ expect(startEdit).toHaveBeenCalledTimes(1);
+ });
+
+ expect(screen.getByText("common.close")).toBeInTheDocument();
+ expect(screen.queryByText("common.save")).not.toBeInTheDocument();
+ });
+
it("opens unsaved confirm and continues edit after confirmation", async () => {
const startEdit = vi.fn();
const resetForm = vi.fn();
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 78752a8..c51644e 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -2,6 +2,24 @@ import { existsSync, readFileSync } from "fs";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
+function parseCsvEnv(value: string | undefined, fallback: string[]) {
+ const entries = value
+ ?.split(",")
+ .map((entry) => entry.trim())
+ .filter((entry) => entry.length > 0);
+
+ return entries && entries.length > 0 ? entries : fallback;
+}
+
+function parseOptionalPort(value: string | undefined) {
+ if (!value) {
+ return undefined;
+ }
+
+ const parsed = Number.parseInt(value, 10);
+ return Number.isFinite(parsed) ? parsed : undefined;
+}
+
// Read version from package.json at build time
const packageJson = JSON.parse(readFileSync("./package.json", "utf-8"));
@@ -9,6 +27,19 @@ const packageJson = JSON.parse(readFileSync("./package.json", "utf-8"));
// In Docker, prefer backend-dev to avoid localhost proxy failures.
const defaultBackendTarget = existsSync("/.dockerenv") ? "http://backend-dev:3000" : "http://localhost:3000";
const backendTarget = process.env.BACKEND_URL || defaultBackendTarget;
+const allowedHosts = parseCsvEnv(process.env.VITE_ALLOWED_HOSTS, ["localhost", "127.0.0.1"]);
+const hmrHost = process.env.VITE_HMR_HOST?.trim();
+const hmrProtocol = process.env.VITE_HMR_PROTOCOL === "ws" ? "ws" : process.env.VITE_HMR_PROTOCOL === "wss" ? "wss" : undefined;
+const hmrClientPort = parseOptionalPort(process.env.VITE_HMR_CLIENT_PORT);
+const hmrPort = parseOptionalPort(process.env.VITE_HMR_PORT);
+const hmr = hmrHost
+ ? {
+ host: hmrHost,
+ protocol: hmrProtocol ?? "wss",
+ clientPort: hmrClientPort ?? (hmrProtocol === "ws" ? 80 : 443),
+ port: hmrPort ?? 5173,
+ }
+ : undefined;
export default defineConfig({
plugins: [react()],
@@ -19,6 +50,8 @@ export default defineConfig({
server: {
port: 5173,
strictPort: true,
+ allowedHosts,
+ hmr,
proxy: {
"/api": {
target: backendTarget,