Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf18318410 | |||
| 72ba4d1272 | |||
| eba77c9520 | |||
| d4b8ddc590 | |||
| 4d6c568668 | |||
| 12dc77455c |
@@ -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
|
||||
|
||||
+15
-1
@@ -107,4 +107,18 @@ docs/SPEC_KIT.md
|
||||
.github/skills/nodejs-backend-patterns/
|
||||
.github/skills/nodejs-best-practices/
|
||||
.github/skills/seo/
|
||||
.playwright-mcp
|
||||
.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/
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -506,7 +506,7 @@ function AppContent() {
|
||||
<AboutModal isOpen={showAbout} onClose={closeAbout} />
|
||||
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/" element={<Navigate to={{ pathname: "/dashboard", search: location.search }} replace />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
|
||||
<Route path="/medications" element={<MedicationsPage />} />
|
||||
|
||||
@@ -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() {
|
||||
<button
|
||||
className={`dose-btn take${options.isEmpty ? " out-of-stock" : ""}`}
|
||||
onClick={() => markDoseTaken(options.doseId)}
|
||||
disabled={options.isEmpty || options.isSkipped}
|
||||
disabled={options.isEmpty}
|
||||
title={options.isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
@@ -476,7 +472,7 @@ export function SharedSchedule() {
|
||||
|
||||
const skipButton = options.isSkipped ? (
|
||||
<button className="dose-btn undo skip" onClick={() => undoDoseSkipped(options.doseId)} title={t("common.undo")}>
|
||||
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||
<span className="dose-btn-label">{t("dose.undoSkip")}</span>
|
||||
<span aria-hidden="true">↩</span>
|
||||
</button>
|
||||
) : (
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const processedMedicationLinkRef = useRef<string | null>(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;
|
||||
|
||||
@@ -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: () => <div>dashboard-page</div>,
|
||||
DashboardPage: () => {
|
||||
const location = useLocation();
|
||||
return (
|
||||
<div>
|
||||
<span>dashboard-page</span>
|
||||
<span data-testid="dashboard-location-search">{location.search}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
MedicationsPage: () => <div>medications-page</div>,
|
||||
PlannerPage: () => <div>planner-page</div>,
|
||||
SchedulePage: () => <div>schedule-page</div>,
|
||||
@@ -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(
|
||||
<MemoryRouter initialEntries={[`/${search}`]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
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,
|
||||
|
||||
@@ -175,6 +175,10 @@ describe("LoginForm", () => {
|
||||
oidcProviderName: "",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
window.history.replaceState({}, "", "/");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user