diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0824a9d..d656b32 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,11 +1,17 @@ # MedAssist-ng - Copilot Entry Point -## VERY IMPORTANT +## VERY IMPORTANT - Prioritized Constraints +**First: Update Memory and Reports** - Always keep agent work memory updated in `doku/memory_notes.md` so progress and decisions remain recoverable across context loss. + - If `doku/memory_notes.md` is missing, create it immediately. - Always keep a user-facing work report updated in `doku/report.md` so completed work is easy to review. + - If `doku/report.md` is missing, create it immediately. - This memory/report rule replaces the previous `doku/APP_BEHAVIOR.md` persistence requirement. +**Second: Follow Governance Rules** +- Consult `AGENTS.md` for all governance, workflow, and skill rules. + Use `AGENTS.md` as the single source of truth for all governance, workflow, and skill rules. ## Required Startup Steps diff --git a/.gitignore b/.gitignore index 42b31b4..55456fb 100644 --- a/.gitignore +++ b/.gitignore @@ -107,4 +107,18 @@ docs/SPEC_KIT.md .github/skills/nodejs-backend-patterns/ .github/skills/nodejs-best-practices/ .github/skills/seo/ -.playwright-mcp \ No newline at end of file +.playwright-mcp + +# Local GSD/copilot generated workspace artifacts (not for upstream) +.github/agents/copilot-instructions.md +.github/agents/gsd-*.agent.md +.github/agents/medassist-feature-orchestrator.agent.md +.github/agents/speckit.*.agent.md +.github/get-shit-done/ +.github/gsd-file-manifest.json +.github/prompts/speckit.*.prompt.md +.github/skills/gsd-*/ +.planning/ +doku/memory_notes.md +doku/report.md +ops/medtest/ \ No newline at end of file diff --git a/README.md b/README.md index fa620db..fd6b774 100644 --- a/README.md +++ b/README.md @@ -378,6 +378,14 @@ docker compose -p medassist-dev -f docker-compose.dev.yml up - API docs UI: `http://localhost:3000/docs` (when docs are enabled) - OpenAPI JSON: `http://localhost:3000/docs/json` (when docs are enabled) +If you run the frontend dev server behind a reverse proxy or on a remote host, you can optionally set these frontend-only environment variables before starting Vite: + +- `VITE_ALLOWED_HOSTS`: comma-separated hostnames allowed to connect to the dev server; defaults to `localhost,127.0.0.1` +- `VITE_HMR_HOST`: public hostname used for HMR websocket connections +- `VITE_HMR_PROTOCOL`: optional websocket protocol override (`ws` or `wss`) +- `VITE_HMR_CLIENT_PORT`: optional public websocket port exposed to the browser +- `VITE_HMR_PORT`: optional server-side websocket port for the Vite process + Useful local commands: ```bash diff --git a/docs/DEFAULT_USER_SETTINGS.md b/docs/DEFAULT_USER_SETTINGS.md index b9c619e..1fe47db 100644 --- a/docs/DEFAULT_USER_SETTINGS.md +++ b/docs/DEFAULT_USER_SETTINGS.md @@ -6,7 +6,6 @@ Scope and behavior: - These values are applied only when a user's settings are created for the first time. - After that, values stored in the database are used and take precedence. -- Source of truth in code: [backend/src/routes/settings.ts](backend/src/routes/settings.ts). ## Email Defaults @@ -47,6 +46,6 @@ Scope and behavior: |----------|---------|-------------| | `DEFAULT_LANGUAGE` | `en` | Default language (`en` or `de`). | | `DEFAULT_STOCK_CALCULATION_MODE` | `automatic` | Default stock mode (`automatic` or `manual`). | -| `DEFAULT_SHARE_STOCK_STATUS` | `true` | Show stock status on shared schedule links. | +| `DEFAULT_SHARE_MEDICATION_OVERVIEW` | `false` | Show medication overview section on shared schedule links. | | `DEFAULT_UPCOMING_TODAY_ONLY` | `false` | Show only today's upcoming doses by default. | | `DEFAULT_SHARE_SCHEDULE_TODAY_ONLY` | `false` | Show only today's schedule in shared view by default. | diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1dc775f..b50c10c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -506,7 +506,7 @@ function AppContent() { - } /> + } /> } /> } /> diff --git a/frontend/src/components/SharedSchedule.tsx b/frontend/src/components/SharedSchedule.tsx index 190f5ad..97f8aed 100644 --- a/frontend/src/components/SharedSchedule.tsx +++ b/frontend/src/components/SharedSchedule.tsx @@ -235,10 +235,6 @@ export function SharedSchedule() { } async function markDoseTaken(doseId: string) { - if (dismissedDoses.has(doseId)) { - return; - } - const wasTaken = takenDoses.has(doseId); const wasSkipped = dismissedDoses.has(doseId); const wasAutomatic = automaticTakenDoses.has(doseId); @@ -466,7 +462,7 @@ export function SharedSchedule() { ) : ( diff --git a/frontend/src/pages/MedicationsPage.tsx b/frontend/src/pages/MedicationsPage.tsx index 14a206e..584faa0 100644 --- a/frontend/src/pages/MedicationsPage.tsx +++ b/frontend/src/pages/MedicationsPage.tsx @@ -257,8 +257,10 @@ export function MedicationsPage() { useUnsavedChangesWarning(formChanged); // View mode: grid (default) or form (edit/new) - // If navigating in with editMedId, suppress rendering until the edit form is ready - const [pendingEditTransition, setPendingEditTransition] = useState(() => searchParams.has("editMedId")); + // If navigating in with a medication deep-link, suppress rendering until the target form is ready + const [pendingEditTransition, setPendingEditTransition] = useState( + () => searchParams.has("editMedId") || searchParams.has("viewMedId") + ); const [viewMode, setViewMode] = useState<"grid" | "form">(pendingEditTransition ? "form" : "grid"); const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null); const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general"); @@ -269,9 +271,23 @@ export function MedicationsPage() { useEffect(() => { showEditModalRef.current = showEditModal; }, [showEditModal]); - const processedEditMedIdRef = useRef(null); + const processedMedicationLinkRef = useRef(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,