diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
index c2f60a5..c7dbed4 100644
--- a/.github/workflows/dotnet.yml
+++ b/.github/workflows/dotnet.yml
@@ -38,7 +38,7 @@ jobs:
run: dotnet test --no-build --verbosity normal
frontend:
- name: Frontend (React lint and build)
+ name: Frontend (React lint, test and build)
runs-on: ubuntu-latest
defaults:
@@ -62,5 +62,8 @@ jobs:
- name: Lint
run: npm run lint
+ - name: Test
+ run: npm test
+
- name: Build
run: npm run build
diff --git a/README.md b/README.md
index dee7337..b55043a 100644
--- a/README.md
+++ b/README.md
@@ -1191,6 +1191,16 @@ The frontend is available at:
http://localhost:5173
```
+The API base URL defaults to `http://localhost:5026`. To point the frontend at another API, copy `frontend/.env.example` to `frontend/.env.local` and set `VITE_API_BASE_URL`.
+
+Run the frontend checks:
+
+```bash
+npm run lint
+npm test
+npm run build
+```
+
---
# Seed Demo Data
@@ -1268,6 +1278,7 @@ Frontend job:
* installs dependencies with `npm ci`
* runs ESLint (`npm run lint`)
+* runs the Vitest + React Testing Library tests (`npm test`)
* builds the production bundle (`npm run build`, including TypeScript type checking)
---
@@ -1305,6 +1316,19 @@ The solution currently includes 59 unit tests covering:
* Simulation candidate scoring logic
* Demo data seeding and reset support
+## Frontend tests
+
+The React dashboard has component tests with Vitest, React Testing Library and jsdom. `fetch` is stubbed with fixtures captured from the real API after `POST /api/demo/reset`, so no backend is needed.
+
+They cover:
+
+* Schedule overview rendering and Danish translation of risk indicators (including culture-independent coverage rates)
+* Error page and retry when the API is unavailable
+* Keeping the dashboard visible while refreshing after schedule generation
+* Reloading employee preference profiles after a demo reset
+* Weekly schedule placement per weekday, assigned/unassigned counts and filters
+* Keeping the weekly schedule visible during refresh, plus error and retry
+
---
# Example Frontend Demo Flow
diff --git a/frontend/.env.example b/frontend/.env.example
new file mode 100644
index 0000000..033e0c5
--- /dev/null
+++ b/frontend/.env.example
@@ -0,0 +1,2 @@
+# Base URL of the ShiftPlanner API (defaults to http://localhost:5026)
+VITE_API_BASE_URL=http://localhost:5026
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index c936420..606304f 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -13,6 +13,9 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+ "@testing-library/jest-dom": "^7.0.1",
+ "@testing-library/react": "^16.3.3",
+ "@testing-library/user-event": "^14.6.8",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -21,9 +24,71 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
+ "jsdom": "^30.1.3",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
- "vite": "^8.0.12"
+ "vite": "^8.0.12",
+ "vitest": "^5.0.5"
+ }
+ },
+ "node_modules/@adobe/css-tools": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
+ "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "7.1.5",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.5.tgz",
+ "integrity": "sha512-jCpAvj8apa7SKHZYogk0rbXB3Kb0hLFrpkWF+Ap+vBA5rirItnpv9NKyAyEuDuFizvl0qwnj6JWR70RGJ0rOlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^3.4.3",
+ "@csstools/css-color-parser": "^4.2.6",
+ "@csstools/css-parser-algorithms": "^4.0.2",
+ "@csstools/css-tokenizer": "^4.0.2",
+ "lru-cache": "^11.5.3"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+ "version": "11.5.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
+ "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "10.0.3",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-10.0.3.tgz",
+ "integrity": "sha512-3+777rEPsOOjoy5/sRmIzHnoRDaLpm3XWY2dmWn/N7f/XwDG0bcpnFaKL3X9FYJUbNwLmpcJC1bUYexJRxsFlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bidi-js": "^1.1.0",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.5.3"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
+ "version": "11.5.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
+ "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
}
},
"node_modules/@babel/code-frame": {
@@ -218,6 +283,16 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.29.10",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
+ "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -266,6 +341,159 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.3",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz",
+ "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz",
+ "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz",
+ "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.2",
+ "@csstools/css-tokenizer": "^4.0.2"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.2.6",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz",
+ "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.1.2",
+ "@csstools/css-calc": "^3.4.3"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.2",
+ "@csstools/css-tokenizer": "^4.0.2"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz",
+ "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.2"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz",
+ "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz",
+ "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -428,6 +656,24 @@
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
+ "node_modules/@exodus/bytes": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
+ "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -527,9 +773,9 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
- "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"dev": true,
"license": "MIT"
},
@@ -855,6 +1101,105 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz",
+ "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz",
+ "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=22",
+ "npm": ">=6",
+ "yarn": ">=1"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=10 <11",
+ "vitest": ">= 0.32"
+ },
+ "peerDependenciesMeta": {
+ "vitest": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.3",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
+ "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.8",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.8.tgz",
+ "integrity": "sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -866,6 +1211,32 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@@ -1186,6 +1557,44 @@
}
}
},
+ "node_modules/@vitest/mocker": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz",
+ "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.31",
+ "@vitest/spy": "5.0.5",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^1.4.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9 || ^3.0.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz",
+ "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -1226,6 +1635,51 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -1249,6 +1703,16 @@
"node": ">=6.0.0"
}
},
+ "node_modules/bidi-js": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
+ "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
"node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
@@ -1317,6 +1781,16 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/chai": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
+ "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1339,6 +1813,27 @@
"node": ">= 8"
}
},
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1346,6 +1841,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/data-urls": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz",
+ "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^17.1.2"
+ },
+ "engines": {
+ "node": "^22.14.0 || >=24.0.0"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1364,6 +1873,13 @@
}
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -1371,6 +1887,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1381,12 +1907,40 @@
"node": ">=8"
}
},
- "node_modules/electron-to-chromium": {
- "version": "1.5.360",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.360.tgz",
- "integrity": "sha512-GkcBt6YYAw9SxFWn+xVar4cLVGlXVuswwtRLBozi2zp0GjXs4ZnOrqV4zbXzg35n7w81hCkyJNYicgXlVHAmBA==",
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.360",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.360.tgz",
+ "integrity": "sha512-GkcBt6YYAw9SxFWn+xVar4cLVGlXVuswwtRLBozi2zp0GjXs4ZnOrqV4zbXzg35n7w81hCkyJNYicgXlVHAmBA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/entities": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
+ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
"dev": true,
- "license": "ISC"
+ "license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
@@ -1583,6 +2137,16 @@
"node": ">=4.0"
}
},
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -1593,6 +2157,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -1751,6 +2325,19 @@
"hermes-estree": "0.25.1"
}
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz",
+ "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.15.1"
+ },
+ "engines": {
+ "node": "^22.13.0 || >=24.0.0"
+ }
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -1771,6 +2358,16 @@
"node": ">=0.8.19"
}
},
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -1794,6 +2391,13 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -1808,6 +2412,56 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/jsdom": {
+ "version": "30.1.3",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.3.tgz",
+ "integrity": "sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^7.1.3",
+ "@asamuzakjp/dom-selector": "^10.0.3",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.15",
+ "@exodus/bytes": "^1.16.0",
+ "css-tree": "^3.2.1",
+ "data-urls": "^8.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^7.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.5.3",
+ "parse5": "^8.0.1",
+ "saxes": "^6.0.0",
+ "tough-cookie": "^6.0.2",
+ "undici": "^8.12.1",
+ "w3c-xmlserializer": "^6.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^17.2.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.2.3"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsdom/node_modules/lru-cache": {
+ "version": "11.5.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
+ "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -2178,6 +2832,44 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz",
+ "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.6.0"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@@ -2234,6 +2926,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/obug": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz",
+ "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -2284,6 +2990,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -2312,9 +3031,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
- "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2363,6 +3082,22 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -2394,6 +3129,38 @@
"react": "^19.2.6"
}
},
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/rolldown": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
@@ -2428,6 +3195,19 @@
"@rolldown/binding-win32-x64-msvc": "1.0.1"
}
},
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -2477,10 +3257,50 @@
"node": ">=0.10.0"
}
},
+ "node_modules/std-env": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
+ "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz",
+ "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
+ "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
- "version": "0.2.16",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
- "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2494,6 +3314,52 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tldts": {
+ "version": "7.4.18",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.18.tgz",
+ "integrity": "sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.18"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.18",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.18.tgz",
+ "integrity": "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
+ "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz",
+ "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": "^22.14.0 || >=24.0.0"
+ }
+ },
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -2566,6 +3432,16 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
+ "node_modules/undici": {
+ "version": "8.12.2",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.2.tgz",
+ "integrity": "sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.19.0"
+ }
+ },
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
@@ -2692,6 +3568,137 @@
}
}
},
+ "node_modules/vitest": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz",
+ "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/mocker": "5.0.5",
+ "chai": "^6.3.0",
+ "es-module-lexer": "^2.3.2",
+ "expect-type": "^1.4.0",
+ "magic-string": "^1.4.3",
+ "obug": "^3.0.0",
+ "picomatch": "^4.0.7",
+ "std-env": "^4.3.0",
+ "tinybench": "^6.2.2",
+ "tinyexec": "^1.3.1",
+ "tinyglobby": "^0.2.17",
+ "why-is-node-running": "3.2.1"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^22.12.0 || ^24.0.0 || >=26.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "5.0.5",
+ "@vitest/browser-preview": "5.0.5",
+ "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
+ "@vitest/coverage-istanbul": "5.0.5",
+ "@vitest/coverage-v8": "5.0.5",
+ "@vitest/ui": "5.0.5",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz",
+ "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "17.3.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.3.0.tgz",
+ "integrity": "sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.16.0",
+ "tr46": "^7.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^22.14.0 || >=24.0.0"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -2708,6 +3715,19 @@
"node": ">= 8"
}
},
+ "node_modules/why-is-node-running": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz",
+ "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=20.11"
+ }
+ },
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -2718,6 +3738,23 @@
"node": ">=0.10.0"
}
},
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 70b0913..5270b31 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -7,7 +7,8 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "test": "vitest run"
},
"dependencies": {
"react": "^19.2.6",
@@ -15,6 +16,9 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+ "@testing-library/jest-dom": "^7.0.1",
+ "@testing-library/react": "^16.3.3",
+ "@testing-library/user-event": "^14.6.8",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -23,8 +27,10 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
+ "jsdom": "^30.1.3",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
- "vite": "^8.0.12"
+ "vite": "^8.0.12",
+ "vitest": "^5.0.5"
}
}
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
new file mode 100644
index 0000000..d86752f
--- /dev/null
+++ b/frontend/src/App.test.tsx
@@ -0,0 +1,126 @@
+import { render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+import App from "./App";
+import { deferred, fixtures, jsonResponse, stubApi } from "./test/fakeApi";
+
+function preferencesSection() {
+ return screen
+ .getByRole("heading", { name: "Medarbejderpræferencer" })
+ .closest("section") as HTMLElement;
+}
+
+describe("App", () => {
+ it("renders the schedule overview with translated risk indicators", async () => {
+ stubApi();
+
+ render();
+
+ expect(screen.getByText("Henter data...")).toBeInTheDocument();
+ expect(await screen.findByText("Kræver opmærksomhed")).toBeInTheDocument();
+ // The API formats the coverage rate with the server culture; a decimal point must survive translation.
+ expect(screen.getByText("Vagtplanens dækningsgrad er 78.57%.")).toBeInTheDocument();
+ expect(screen.getByText("3 vagt(er) er aktuelt ubesatte.")).toBeInTheDocument();
+ expect(
+ screen.getByText("UL kræves af 1 ubesat vagt, men ingen medarbejdere har denne kompetence."),
+ ).toBeInTheDocument();
+ });
+
+ it("shows an error page and recovers when retrying", async () => {
+ let failing = true;
+ stubApi([
+ (_method, path) => {
+ if (failing && path === "/api/schedule/overview") {
+ return new Response("Service unavailable", { status: 503 });
+ }
+ },
+ ]);
+ const user = userEvent.setup();
+
+ render();
+
+ expect(
+ await screen.findByText("Kunne ikke hente vagtplanens overblik fra API'et."),
+ ).toBeInTheDocument();
+
+ failing = false;
+ await user.click(screen.getByRole("button", { name: "Prøv igen" }));
+
+ expect(await screen.findByText("Kræver opmærksomhed")).toBeInTheDocument();
+ });
+
+ it("keeps the dashboard visible while refreshing after generating a schedule", async () => {
+ let generated = false;
+ const refreshedOverview = deferred();
+ const fetchMock = stubApi([
+ (method, path, body) => {
+ if (method === "POST" && path === "/api/schedule/generate") {
+ expect(body).toEqual({ maxAssignmentsPerEmployee: 5 });
+ generated = true;
+ return jsonResponse({
+ message: "Schedule generation completed.",
+ employeeCount: 10,
+ shiftCount: 14,
+ assignments: [
+ { shiftId: "1", employeeId: "e1", employeeName: "Jonas", requiredSkill: "CT", wasAssigned: true, failureReasons: [] },
+ { shiftId: "2", employeeId: null, employeeName: null, requiredSkill: "UL", wasAssigned: false, failureReasons: [] },
+ ],
+ });
+ }
+ if (generated && method === "GET" && path === "/api/schedule/overview") {
+ return refreshedOverview.promise;
+ }
+ },
+ ]);
+ const user = userEvent.setup();
+
+ render();
+ await user.click(await screen.findByRole("button", { name: "Generér vagtplan" }));
+
+ await waitFor(() =>
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringContaining("/api/schedule/generate"),
+ expect.objectContaining({ method: "POST" }),
+ ),
+ );
+ expect(screen.queryByText("Henter data...")).not.toBeInTheDocument();
+ expect(screen.getByRole("navigation", { name: "Dashboard navigation" })).toBeInTheDocument();
+
+ refreshedOverview.resolve(jsonResponse(fixtures.scheduleOverview));
+
+ expect(
+ await screen.findByText(
+ "Vagtplanen blev genereret. 1 vagt(er) blev tildelt, og 1 vagt(er) kunne ikke tildeles.",
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it("reloads employee preference profiles after resetting demo data", async () => {
+ let reset = false;
+ stubApi([
+ (method, path) => {
+ if (method === "POST" && path === "/api/demo/reset") {
+ reset = true;
+ return jsonResponse({ wasSeeded: true, message: "Demo data was reset and seeded.", employeeCount: 10, shiftCount: 14 });
+ }
+ if (method === "GET" && path === "/api/employees/preference-profiles") {
+ // An empty database has no employees and therefore no profiles until the reset.
+ return jsonResponse(reset ? fixtures.preferenceProfiles : []);
+ }
+ },
+ ]);
+ const user = userEvent.setup();
+
+ render();
+ await user.click(await screen.findByRole("button", { name: /Medarbejdere/ }));
+ expect(within(preferencesSection()).queryByText("Henrik")).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "Nulstil demo-data" }));
+
+ expect(
+ await screen.findByText(/Demo-data blev nulstillet\. 10 medarbejdere og 14 vagter blev oprettet\./),
+ ).toBeInTheDocument();
+ expect(await within(preferencesSection()).findByText("Henrik")).toBeInTheDocument();
+ expect(within(preferencesSection()).getByText("Undgår helst weekend")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 9aba4f2..8dac81b 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -59,10 +59,12 @@ function translateRiskType(type: string) {
function translateRiskMessage(message: string) {
if (message.startsWith("Schedule coverage is")) {
+ // The API formats the rate with the server culture ("78,57%" or "78.57%"),
+ // so only the sentence-ending period may be removed.
const percentage = message
.replace("Schedule coverage is", "")
- .replace(".", "")
- .trim();
+ .trim()
+ .replace(/\.$/, "");
return `Vagtplanens dækningsgrad er ${percentage}.`;
}
diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts
new file mode 100644
index 0000000..d92abd4
--- /dev/null
+++ b/frontend/src/api/config.ts
@@ -0,0 +1,3 @@
+// Base URL of the ShiftPlanner API. Override with VITE_API_BASE_URL (see .env.example).
+export const API_BASE_URL: string =
+ import.meta.env.VITE_API_BASE_URL ?? "http://localhost:5026";
diff --git a/frontend/src/api/demoDataApi.ts b/frontend/src/api/demoDataApi.ts
index bf3df4a..2dfded6 100644
--- a/frontend/src/api/demoDataApi.ts
+++ b/frontend/src/api/demoDataApi.ts
@@ -1,3 +1,5 @@
+import { API_BASE_URL } from "./config";
+
export type DemoSeedResult = {
wasSeeded: boolean;
message: string;
@@ -5,8 +7,6 @@ export type DemoSeedResult = {
shiftCount: number;
};
-const API_BASE_URL = "http://localhost:5026";
-
export async function resetDemoData(): Promise {
const response = await fetch(`${API_BASE_URL}/api/demo/reset`, {
method: "POST",
diff --git a/frontend/src/api/employeeLoadDetailsApi.ts b/frontend/src/api/employeeLoadDetailsApi.ts
index 21d7a0e..372f219 100644
--- a/frontend/src/api/employeeLoadDetailsApi.ts
+++ b/frontend/src/api/employeeLoadDetailsApi.ts
@@ -1,3 +1,5 @@
+import { API_BASE_URL } from "./config";
+
export type EmployeeAssignedShiftLoadItem = {
shiftId: string;
date: string;
@@ -16,8 +18,6 @@ export type EmployeeLoadDetailsResponse = {
assignedShifts: EmployeeAssignedShiftLoadItem[];
};
-const API_BASE_URL = "http://localhost:5026";
-
export async function getEmployeeLoadDetails(
employeeId: string
): Promise {
diff --git a/frontend/src/api/employeeLoadOverviewApi.ts b/frontend/src/api/employeeLoadOverviewApi.ts
index 2a5b4f7..1bf9d19 100644
--- a/frontend/src/api/employeeLoadOverviewApi.ts
+++ b/frontend/src/api/employeeLoadOverviewApi.ts
@@ -1,3 +1,5 @@
+import { API_BASE_URL } from "./config";
+
export type EmployeeLoadOverviewItem = {
employeeId: string;
employeeName: string;
@@ -7,8 +9,6 @@ export type EmployeeLoadOverviewItem = {
isHighRisk: boolean;
};
-const API_BASE_URL = "http://localhost:5026";
-
export async function getEmployeeLoadOverview(): Promise {
const response = await fetch(`${API_BASE_URL}/api/employees/load-overview`);
diff --git a/frontend/src/api/employeePreferenceProfilesApi.ts b/frontend/src/api/employeePreferenceProfilesApi.ts
index a371d65..29cab05 100644
--- a/frontend/src/api/employeePreferenceProfilesApi.ts
+++ b/frontend/src/api/employeePreferenceProfilesApi.ts
@@ -1,4 +1,5 @@
import type { ShiftType } from "./scheduleSimulationApi";
+import { API_BASE_URL } from "./config";
export type EmployeePreferenceProfileOverviewItem = {
employeeId: string;
@@ -11,8 +12,6 @@ export type EmployeePreferenceProfileOverviewItem = {
avoidsWeekends: boolean;
};
-const API_BASE_URL = "http://localhost:5026";
-
export async function getEmployeePreferenceProfiles(): Promise<
EmployeePreferenceProfileOverviewItem[]
> {
diff --git a/frontend/src/api/scheduleDataApi.ts b/frontend/src/api/scheduleDataApi.ts
index b477728..84099d9 100644
--- a/frontend/src/api/scheduleDataApi.ts
+++ b/frontend/src/api/scheduleDataApi.ts
@@ -1,3 +1,5 @@
+import { API_BASE_URL } from "./config";
+
export type ShiftType = "Day" | "Evening" | "Night" | "OnCall";
export type Employee = {
@@ -15,8 +17,6 @@ export type Shift = {
requiredStaff: number;
};
-const API_BASE_URL = "http://localhost:5026";
-
export async function getEmployees(): Promise {
const response = await fetch(`${API_BASE_URL}/api/employees`);
diff --git a/frontend/src/api/scheduleGenerationApi.ts b/frontend/src/api/scheduleGenerationApi.ts
index ae40e07..37647c8 100644
--- a/frontend/src/api/scheduleGenerationApi.ts
+++ b/frontend/src/api/scheduleGenerationApi.ts
@@ -1,3 +1,5 @@
+import { API_BASE_URL } from "./config";
+
export type ScheduleAssignmentResult = {
shiftId: string;
employeeId: string | null;
@@ -18,8 +20,6 @@ export type GenerateScheduleResponse = {
assignments: ScheduleAssignmentResult[];
};
-const API_BASE_URL = "http://localhost:5026";
-
export async function generateSchedule(
request: GenerateScheduleRequest,
): Promise {
diff --git a/frontend/src/api/scheduleOverviewApi.ts b/frontend/src/api/scheduleOverviewApi.ts
index 9ee0c5a..30ef1df 100644
--- a/frontend/src/api/scheduleOverviewApi.ts
+++ b/frontend/src/api/scheduleOverviewApi.ts
@@ -1,3 +1,5 @@
+import { API_BASE_URL } from "./config";
+
export type ScheduleRiskLevel = "Low" | "Medium" | "High";
export type ShiftType = "Day" | "Evening" | "Night" | "OnCall";
@@ -47,8 +49,6 @@ export type ScheduleOverviewResponse = {
unassignedShiftDetails: UnassignedShiftDetail[];
};
-const API_BASE_URL = "http://localhost:5026";
-
export async function getScheduleOverview(): Promise {
const response = await fetch(`${API_BASE_URL}/api/schedule/overview`);
diff --git a/frontend/src/api/scheduleSimulationApi.ts b/frontend/src/api/scheduleSimulationApi.ts
index 4d5d314..bd2c788 100644
--- a/frontend/src/api/scheduleSimulationApi.ts
+++ b/frontend/src/api/scheduleSimulationApi.ts
@@ -1,3 +1,5 @@
+import { API_BASE_URL } from "./config";
+
export type ScheduleRiskLevel = "Low" | "Medium" | "High";
export type ShiftType = "Day" | "Evening" | "Night" | "OnCall";
@@ -36,8 +38,6 @@ export type SimulateScheduleResponse = {
candidateResults: SimulationCandidateResult[];
};
-const API_BASE_URL = "http://localhost:5026";
-
export async function simulateSchedule(
request: SimulateScheduleRequest,
): Promise {
diff --git a/frontend/src/api/shiftAssignmentAnalysisApi.ts b/frontend/src/api/shiftAssignmentAnalysisApi.ts
index 09038e4..dac70a0 100644
--- a/frontend/src/api/shiftAssignmentAnalysisApi.ts
+++ b/frontend/src/api/shiftAssignmentAnalysisApi.ts
@@ -1,3 +1,5 @@
+import { API_BASE_URL } from "./config";
+
export type ShiftAssignmentCandidateResult = {
employeeId: string;
employeeName: string;
@@ -16,8 +18,6 @@ export type ShiftAssignmentAnalysisResponse = {
candidateResults: ShiftAssignmentCandidateResult[];
};
-const API_BASE_URL = "http://localhost:5026";
-
export async function getShiftAssignmentAnalysis(
shiftId: string,
maxAssignmentsPerEmployee: number
diff --git a/frontend/src/components/WeeklyScheduleTable.test.tsx b/frontend/src/components/WeeklyScheduleTable.test.tsx
new file mode 100644
index 0000000..aa3b08b
--- /dev/null
+++ b/frontend/src/components/WeeklyScheduleTable.test.tsx
@@ -0,0 +1,105 @@
+import { render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+import { deferred, fixtures, jsonResponse, stubApi } from "../test/fakeApi";
+import { WeeklyScheduleTable } from "./WeeklyScheduleTable";
+
+function summaryValue(label: string) {
+ return screen.getByText(label).nextElementSibling;
+}
+
+function dayCard(dayLabel: string) {
+ return screen.getByText(dayLabel).closest(".schedule-day-card") as HTMLElement;
+}
+
+describe("WeeklyScheduleTable", () => {
+ it("places shifts on the right weekday and shows who is assigned", async () => {
+ stubApi();
+
+ render();
+
+ expect(screen.getByText("Henter ugeplan...")).toBeInTheDocument();
+ expect(await screen.findByText("Vagter i ugeplanen")).toBeInTheDocument();
+ expect(summaryValue("Vagter i ugeplanen")).toHaveTextContent("14");
+ expect(summaryValue("Tildelte vagter")).toHaveTextContent("4");
+ expect(summaryValue("Ubesatte vagter")).toHaveTextContent("10");
+
+ const monday = dayCard("Mandag");
+ expect(within(monday).getByText("Tildelt: Kris")).toBeInTheDocument();
+ expect(within(monday).getByText("Tildelt: Mette")).toBeInTheDocument();
+
+ const friday = dayCard("Fredag");
+ expect(within(friday).getByText("Kræver UL")).toBeInTheDocument();
+ expect(within(friday).getAllByText("Ikke tildelt")).toHaveLength(2);
+
+ expect(within(dayCard("Lørdag")).getAllByText("Weekend")).toHaveLength(1);
+ });
+
+ it("filters the week to only unassigned or only assigned shifts", async () => {
+ stubApi();
+ const user = userEvent.setup();
+
+ render();
+ await screen.findByText("Vagter i ugeplanen");
+
+ await user.click(screen.getByRole("button", { name: "Kun ubesatte" }));
+ expect(screen.queryByText(/^Tildelt: /)).not.toBeInTheDocument();
+ expect(screen.getAllByText("Ikke tildelt")).toHaveLength(10);
+ expect(within(dayCard("Mandag")).getByText("Ingen vagter")).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "Kun tildelte" }));
+ expect(screen.queryByText("Ikke tildelt")).not.toBeInTheDocument();
+ expect(screen.getAllByText(/^Tildelt: /)).toHaveLength(4);
+ });
+
+ it("keeps the current week visible while refreshing after refreshKey changes", async () => {
+ let refreshed = false;
+ const refreshedShifts = deferred();
+ stubApi([
+ (method, path) => {
+ if (refreshed && method === "GET" && path === "/api/shifts") {
+ return refreshedShifts.promise;
+ }
+ },
+ ]);
+
+ const { rerender } = render();
+ await screen.findByText("Tildelt: Kris");
+
+ refreshed = true;
+ rerender();
+
+ expect(screen.queryByText("Henter ugeplan...")).not.toBeInTheDocument();
+ expect(screen.getByText("Tildelt: Kris")).toBeInTheDocument();
+
+ const allAssignedToKris = fixtures.shifts.map((shift) => ({
+ ...shift,
+ employeeId: fixtures.employees[0].id,
+ }));
+ refreshedShifts.resolve(jsonResponse(allAssignedToKris));
+
+ await waitFor(() => expect(summaryValue("Tildelte vagter")).toHaveTextContent("14"));
+ });
+
+ it("shows an error and can retry when the API is unavailable", async () => {
+ let failing = true;
+ stubApi([
+ (_method, path) => {
+ if (failing && path === "/api/shifts") {
+ return new Response("Service unavailable", { status: 503 });
+ }
+ },
+ ]);
+ const user = userEvent.setup();
+
+ render();
+
+ expect(await screen.findByText("Kunne ikke hente vagtplanens skemadata.")).toBeInTheDocument();
+
+ failing = false;
+ await user.click(screen.getByRole("button", { name: "Prøv igen" }));
+
+ expect(await screen.findByText("Tildelt: Kris")).toBeInTheDocument();
+ expect(screen.queryByText("Kunne ikke hente vagtplanens skemadata.")).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/test/fakeApi.ts b/frontend/src/test/fakeApi.ts
new file mode 100644
index 0000000..6abe3fb
--- /dev/null
+++ b/frontend/src/test/fakeApi.ts
@@ -0,0 +1,74 @@
+import { vi } from "vitest";
+import employees from "./fixtures/employees.json";
+import loadOverview from "./fixtures/load-overview.json";
+import preferenceProfiles from "./fixtures/preference-profiles.json";
+import scheduleOverview from "./fixtures/schedule-overview.json";
+import shifts from "./fixtures/shifts.json";
+
+// Fixtures are real responses captured from the API after POST /api/demo/reset.
+export const fixtures = {
+ employees,
+ shifts,
+ scheduleOverview,
+ loadOverview,
+ preferenceProfiles,
+};
+
+export function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+type Route = (
+ method: string,
+ path: string,
+ body: unknown,
+) => Response | Promise | undefined;
+
+const defaultGetResponses: Record = {
+ "/api/employees": employees,
+ "/api/shifts": shifts,
+ "/api/schedule/overview": scheduleOverview,
+ "/api/employees/load-overview": loadOverview,
+ "/api/employees/preference-profiles": preferenceProfiles,
+};
+
+/**
+ * Replaces global fetch with an in-memory version of the API.
+ * Custom routes are tried first; unmatched GET requests fall back to the fixtures.
+ */
+export function stubApi(routes: Route[] = []) {
+ const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = new URL(input.toString());
+ const method = init?.method ?? "GET";
+ const body = init?.body ? JSON.parse(init.body.toString()) : undefined;
+
+ for (const route of routes) {
+ const response = route(method, url.pathname, body);
+ if (response) {
+ return response;
+ }
+ }
+
+ if (method === "GET" && url.pathname in defaultGetResponses) {
+ return jsonResponse(defaultGetResponses[url.pathname]);
+ }
+
+ return jsonResponse({ error: `No fake route for ${method} ${url.pathname}` }, 404);
+ });
+
+ vi.stubGlobal("fetch", fetchMock);
+
+ return fetchMock;
+}
+
+export function deferred() {
+ let resolve: (value: T) => void = () => {};
+ const promise = new Promise((res) => {
+ resolve = res;
+ });
+
+ return { promise, resolve };
+}
diff --git a/frontend/src/test/fixtures/employees.json b/frontend/src/test/fixtures/employees.json
new file mode 100644
index 0000000..693bace
--- /dev/null
+++ b/frontend/src/test/fixtures/employees.json
@@ -0,0 +1,79 @@
+[
+ {
+ "id": "4d929b19-7a96-4fc7-a9dd-75fa989acdc3",
+ "name": "Kris",
+ "skills": [
+ "CT",
+ "XR"
+ ]
+ },
+ {
+ "id": "312f340c-8955-45c5-bb58-a81ea4c16409",
+ "name": "Mette",
+ "skills": [
+ "MRI",
+ "XR"
+ ]
+ },
+ {
+ "id": "a1ebbb78-c7d1-49c9-9d55-0e28b3364f7f",
+ "name": "Henrik",
+ "skills": [
+ "CT",
+ "Night"
+ ]
+ },
+ {
+ "id": "8745e44e-70c6-41e1-9a06-81a0d69a5c6c",
+ "name": "Sofie",
+ "skills": [
+ "XR",
+ "Night"
+ ]
+ },
+ {
+ "id": "1c5cf4bf-7ff8-4374-b2ea-6c4e95d5306b",
+ "name": "Lars",
+ "skills": [
+ "MRI",
+ "CT"
+ ]
+ },
+ {
+ "id": "a0518392-3c90-49ad-894b-366e92d95b0e",
+ "name": "Anna",
+ "skills": [
+ "XR"
+ ]
+ },
+ {
+ "id": "add63646-029b-4e20-8b54-56fcf901eb65",
+ "name": "Jonas",
+ "skills": [
+ "CT"
+ ]
+ },
+ {
+ "id": "6a224f4d-aa65-460f-b921-d2ce3ac57e22",
+ "name": "Maria",
+ "skills": [
+ "MRI",
+ "Night"
+ ]
+ },
+ {
+ "id": "fa030bc3-f6dd-4d0b-a354-3d85e0d1b761",
+ "name": "Peter",
+ "skills": [
+ "XR",
+ "CT"
+ ]
+ },
+ {
+ "id": "89613300-1b09-4a82-a38a-3099125ef018",
+ "name": "Line",
+ "skills": [
+ "MRI"
+ ]
+ }
+]
diff --git a/frontend/src/test/fixtures/load-overview.json b/frontend/src/test/fixtures/load-overview.json
new file mode 100644
index 0000000..14d5690
--- /dev/null
+++ b/frontend/src/test/fixtures/load-overview.json
@@ -0,0 +1,109 @@
+[
+ {
+ "employeeId": "a1ebbb78-c7d1-49c9-9d55-0e28b3364f7f",
+ "employeeName": "Henrik",
+ "skills": [
+ "CT",
+ "Night"
+ ],
+ "totalLoad": 4,
+ "loadStatus": "Medium",
+ "isHighRisk": false
+ },
+ {
+ "employeeId": "312f340c-8955-45c5-bb58-a81ea4c16409",
+ "employeeName": "Mette",
+ "skills": [
+ "MRI",
+ "XR"
+ ],
+ "totalLoad": 2,
+ "loadStatus": "Low",
+ "isHighRisk": false
+ },
+ {
+ "employeeId": "4d929b19-7a96-4fc7-a9dd-75fa989acdc3",
+ "employeeName": "Kris",
+ "skills": [
+ "CT",
+ "XR"
+ ],
+ "totalLoad": 1,
+ "loadStatus": "Low",
+ "isHighRisk": false
+ },
+ {
+ "employeeId": "8745e44e-70c6-41e1-9a06-81a0d69a5c6c",
+ "employeeName": "Sofie",
+ "skills": [
+ "XR",
+ "Night"
+ ],
+ "totalLoad": 1,
+ "loadStatus": "Low",
+ "isHighRisk": false
+ },
+ {
+ "employeeId": "a0518392-3c90-49ad-894b-366e92d95b0e",
+ "employeeName": "Anna",
+ "skills": [
+ "XR"
+ ],
+ "totalLoad": 0,
+ "loadStatus": "Low",
+ "isHighRisk": false
+ },
+ {
+ "employeeId": "add63646-029b-4e20-8b54-56fcf901eb65",
+ "employeeName": "Jonas",
+ "skills": [
+ "CT"
+ ],
+ "totalLoad": 0,
+ "loadStatus": "Low",
+ "isHighRisk": false
+ },
+ {
+ "employeeId": "1c5cf4bf-7ff8-4374-b2ea-6c4e95d5306b",
+ "employeeName": "Lars",
+ "skills": [
+ "MRI",
+ "CT"
+ ],
+ "totalLoad": 0,
+ "loadStatus": "Low",
+ "isHighRisk": false
+ },
+ {
+ "employeeId": "89613300-1b09-4a82-a38a-3099125ef018",
+ "employeeName": "Line",
+ "skills": [
+ "MRI"
+ ],
+ "totalLoad": 0,
+ "loadStatus": "Low",
+ "isHighRisk": false
+ },
+ {
+ "employeeId": "6a224f4d-aa65-460f-b921-d2ce3ac57e22",
+ "employeeName": "Maria",
+ "skills": [
+ "MRI",
+ "Night"
+ ],
+ "totalLoad": 0,
+ "loadStatus": "Low",
+ "isHighRisk": false
+ },
+ {
+ "employeeId": "fa030bc3-f6dd-4d0b-a354-3d85e0d1b761",
+ "employeeName": "Peter",
+ "skills": [
+ "XR",
+ "CT"
+ ],
+ "totalLoad": 0,
+ "loadStatus": "Low",
+ "isHighRisk": false
+ }
+]
diff --git a/frontend/src/test/fixtures/preference-profiles.json b/frontend/src/test/fixtures/preference-profiles.json
new file mode 100644
index 0000000..e4dd46e
--- /dev/null
+++ b/frontend/src/test/fixtures/preference-profiles.json
@@ -0,0 +1,106 @@
+[
+ {
+ "employeeId": "a0518392-3c90-49ad-894b-366e92d95b0e",
+ "employeeName": "Anna",
+ "preferredShiftTypes": [],
+ "dislikedShiftTypes": [],
+ "maxNightShifts": null,
+ "maxEveningShifts": null,
+ "prefersWeekends": false,
+ "avoidsWeekends": false
+ },
+ {
+ "employeeId": "a1ebbb78-c7d1-49c9-9d55-0e28b3364f7f",
+ "employeeName": "Henrik",
+ "preferredShiftTypes": [
+ "Night"
+ ],
+ "dislikedShiftTypes": [],
+ "maxNightShifts": 3,
+ "maxEveningShifts": null,
+ "prefersWeekends": false,
+ "avoidsWeekends": false
+ },
+ {
+ "employeeId": "add63646-029b-4e20-8b54-56fcf901eb65",
+ "employeeName": "Jonas",
+ "preferredShiftTypes": [],
+ "dislikedShiftTypes": [],
+ "maxNightShifts": null,
+ "maxEveningShifts": null,
+ "prefersWeekends": false,
+ "avoidsWeekends": false
+ },
+ {
+ "employeeId": "4d929b19-7a96-4fc7-a9dd-75fa989acdc3",
+ "employeeName": "Kris",
+ "preferredShiftTypes": [],
+ "dislikedShiftTypes": [],
+ "maxNightShifts": null,
+ "maxEveningShifts": null,
+ "prefersWeekends": false,
+ "avoidsWeekends": false
+ },
+ {
+ "employeeId": "1c5cf4bf-7ff8-4374-b2ea-6c4e95d5306b",
+ "employeeName": "Lars",
+ "preferredShiftTypes": [],
+ "dislikedShiftTypes": [],
+ "maxNightShifts": null,
+ "maxEveningShifts": null,
+ "prefersWeekends": false,
+ "avoidsWeekends": false
+ },
+ {
+ "employeeId": "89613300-1b09-4a82-a38a-3099125ef018",
+ "employeeName": "Line",
+ "preferredShiftTypes": [],
+ "dislikedShiftTypes": [],
+ "maxNightShifts": null,
+ "maxEveningShifts": null,
+ "prefersWeekends": false,
+ "avoidsWeekends": false
+ },
+ {
+ "employeeId": "6a224f4d-aa65-460f-b921-d2ce3ac57e22",
+ "employeeName": "Maria",
+ "preferredShiftTypes": [],
+ "dislikedShiftTypes": [],
+ "maxNightShifts": null,
+ "maxEveningShifts": null,
+ "prefersWeekends": false,
+ "avoidsWeekends": false
+ },
+ {
+ "employeeId": "312f340c-8955-45c5-bb58-a81ea4c16409",
+ "employeeName": "Mette",
+ "preferredShiftTypes": [],
+ "dislikedShiftTypes": [
+ "Night"
+ ],
+ "maxNightShifts": 1,
+ "maxEveningShifts": null,
+ "prefersWeekends": false,
+ "avoidsWeekends": false
+ },
+ {
+ "employeeId": "fa030bc3-f6dd-4d0b-a354-3d85e0d1b761",
+ "employeeName": "Peter",
+ "preferredShiftTypes": [],
+ "dislikedShiftTypes": [],
+ "maxNightShifts": null,
+ "maxEveningShifts": null,
+ "prefersWeekends": false,
+ "avoidsWeekends": true
+ },
+ {
+ "employeeId": "8745e44e-70c6-41e1-9a06-81a0d69a5c6c",
+ "employeeName": "Sofie",
+ "preferredShiftTypes": [],
+ "dislikedShiftTypes": [],
+ "maxNightShifts": null,
+ "maxEveningShifts": null,
+ "prefersWeekends": false,
+ "avoidsWeekends": false
+ }
+]
diff --git a/frontend/src/test/fixtures/schedule-overview.json b/frontend/src/test/fixtures/schedule-overview.json
new file mode 100644
index 0000000..cd72c5b
--- /dev/null
+++ b/frontend/src/test/fixtures/schedule-overview.json
@@ -0,0 +1,156 @@
+{
+ "totalShifts": 14,
+ "assignedShifts": 11,
+ "unassignedShifts": 3,
+ "coverageRate": 78.57,
+ "employeeCount": 10,
+ "highRiskEmployeeCount": 0,
+ "unassignedShiftDetails": [
+ {
+ "shiftId": "ce5649f6-da84-4588-a033-e7123cbd1153",
+ "date": "2026-05-15",
+ "shiftType": "Day",
+ "requiredSkill": "UL",
+ "failureReasons": [
+ "Kris: Missing required skill 'UL'.",
+ "Mette: Missing required skill 'UL'.",
+ "Henrik: Missing required skill 'UL'.",
+ "Sofie: Missing required skill 'UL'.",
+ "Lars: Missing required skill 'UL'.",
+ "Anna: Missing required skill 'UL'.",
+ "Jonas: Missing required skill 'UL'.",
+ "Maria: Missing required skill 'UL'.",
+ "Peter: Missing required skill 'UL'.",
+ "Line: Missing required skill 'UL'."
+ ]
+ },
+ {
+ "shiftId": "57a0f822-1470-46c0-9725-e9c426ca2457",
+ "date": "2026-05-15",
+ "shiftType": "Evening",
+ "requiredSkill": "Intervention",
+ "failureReasons": [
+ "Kris: Missing required skill 'Intervention'.",
+ "Mette: Missing required skill 'Intervention'.",
+ "Henrik: Missing required skill 'Intervention'.",
+ "Sofie: Missing required skill 'Intervention'.",
+ "Lars: Missing required skill 'Intervention'.",
+ "Anna: Missing required skill 'Intervention'.",
+ "Jonas: Missing required skill 'Intervention'.",
+ "Maria: Missing required skill 'Intervention'.",
+ "Peter: Missing required skill 'Intervention'.",
+ "Line: Missing required skill 'Intervention'."
+ ]
+ },
+ {
+ "shiftId": "48a89f5e-3dd5-4968-b8ef-ad3b1f7ab191",
+ "date": "2026-05-17",
+ "shiftType": "Night",
+ "requiredSkill": "Night",
+ "failureReasons": [
+ "Kris: Missing required skill 'Night'.",
+ "Mette: Missing required skill 'Night'.",
+ "Henrik: Employee projected workload would be too high.",
+ "Sofie: Employee projected workload would be too high.",
+ "Lars: Missing required skill 'Night'.",
+ "Anna: Missing required skill 'Night'.",
+ "Jonas: Missing required skill 'Night'.",
+ "Maria: Employee projected workload would be too high.",
+ "Peter: Missing required skill 'Night'.",
+ "Line: Missing required skill 'Night'."
+ ]
+ }
+ ],
+ "skillGaps": [
+ {
+ "requiredSkill": "Intervention",
+ "unassignedShiftCount": 1
+ },
+ {
+ "requiredSkill": "Night",
+ "unassignedShiftCount": 1
+ },
+ {
+ "requiredSkill": "UL",
+ "unassignedShiftCount": 1
+ }
+ ],
+ "riskSummary": {
+ "coverageRisk": "Medium",
+ "unassignedShiftCount": 3,
+ "skillGapCount": 3,
+ "highRiskEmployeeCount": 0
+ },
+ "riskIndicators": [
+ {
+ "type": "Coverage",
+ "severity": "Medium",
+ "message": "Schedule coverage is 78.57%."
+ },
+ {
+ "type": "UnassignedShifts",
+ "severity": "Medium",
+ "message": "3 shift(s) are currently unassigned."
+ },
+ {
+ "type": "SkillGap",
+ "severity": "Medium",
+ "message": "1 unassigned shift(s) require Intervention."
+ },
+ {
+ "type": "SkillGap",
+ "severity": "Medium",
+ "message": "1 unassigned shift(s) require Night."
+ },
+ {
+ "type": "SkillGap",
+ "severity": "Medium",
+ "message": "1 unassigned shift(s) require UL."
+ },
+ {
+ "type": "Capacity",
+ "severity": "High",
+ "message": "Intervention is required by 1 unassigned shift(s), but no employees have this skill."
+ },
+ {
+ "type": "Capacity",
+ "severity": "High",
+ "message": "UL is required by 1 unassigned shift(s), but no employees have this skill."
+ }
+ ],
+ "skillCapacity": [
+ {
+ "skill": "CT",
+ "employeeCount": 5
+ },
+ {
+ "skill": "MRI",
+ "employeeCount": 4
+ },
+ {
+ "skill": "Night",
+ "employeeCount": 3
+ },
+ {
+ "skill": "XR",
+ "employeeCount": 5
+ }
+ ],
+ "uncoveredRequiredSkills": [
+ {
+ "skill": "Intervention",
+ "requiredByUnassignedShifts": 1,
+ "availableEmployees": 0
+ },
+ {
+ "skill": "UL",
+ "requiredByUnassignedShifts": 1,
+ "availableEmployees": 0
+ }
+ ],
+ "capacitySummary": {
+ "totalSkills": 4,
+ "missingRequiredSkills": 2,
+ "criticalSkillGaps": 2
+ }
+}
diff --git a/frontend/src/test/fixtures/shifts.json b/frontend/src/test/fixtures/shifts.json
new file mode 100644
index 0000000..9dff1ee
--- /dev/null
+++ b/frontend/src/test/fixtures/shifts.json
@@ -0,0 +1,114 @@
+[
+ {
+ "id": "3105c80e-4f29-4b1b-b472-a51569292643",
+ "employeeId": "4d929b19-7a96-4fc7-a9dd-75fa989acdc3",
+ "date": "2026-05-11",
+ "shiftType": "Day",
+ "requiredSkill": "CT",
+ "requiredStaff": 1
+ },
+ {
+ "id": "0a5664ed-d58f-4a51-baf8-a506f9c0e490",
+ "employeeId": "312f340c-8955-45c5-bb58-a81ea4c16409",
+ "date": "2026-05-11",
+ "shiftType": "Evening",
+ "requiredSkill": "MRI",
+ "requiredStaff": 1
+ },
+ {
+ "id": "9e1a4f4a-0204-471f-9e4b-2c1aaeea7e27",
+ "employeeId": "a1ebbb78-c7d1-49c9-9d55-0e28b3364f7f",
+ "date": "2026-05-12",
+ "shiftType": "Night",
+ "requiredSkill": "Night",
+ "requiredStaff": 1
+ },
+ {
+ "id": "f02474ea-9dba-40f0-af72-fc8dbaa12c0b",
+ "employeeId": "8745e44e-70c6-41e1-9a06-81a0d69a5c6c",
+ "date": "2026-05-12",
+ "shiftType": "Day",
+ "requiredSkill": "XR",
+ "requiredStaff": 1
+ },
+ {
+ "id": "7b7cfa03-deee-4c7a-acd6-716ba059dc7f",
+ "employeeId": null,
+ "date": "2026-05-13",
+ "shiftType": "Day",
+ "requiredSkill": "CT",
+ "requiredStaff": 1
+ },
+ {
+ "id": "733c4a0a-db62-4960-b66a-f238c1266575",
+ "employeeId": null,
+ "date": "2026-05-13",
+ "shiftType": "Evening",
+ "requiredSkill": "MRI",
+ "requiredStaff": 1
+ },
+ {
+ "id": "8cecf40b-cbc8-48dc-9448-281f85a4a39b",
+ "employeeId": null,
+ "date": "2026-05-14",
+ "shiftType": "Night",
+ "requiredSkill": "Night",
+ "requiredStaff": 1
+ },
+ {
+ "id": "2efad4f3-f629-4c96-a4a9-77a946979c6b",
+ "employeeId": null,
+ "date": "2026-05-14",
+ "shiftType": "Day",
+ "requiredSkill": "XR",
+ "requiredStaff": 1
+ },
+ {
+ "id": "ce5649f6-da84-4588-a033-e7123cbd1153",
+ "employeeId": null,
+ "date": "2026-05-15",
+ "shiftType": "Day",
+ "requiredSkill": "UL",
+ "requiredStaff": 1
+ },
+ {
+ "id": "57a0f822-1470-46c0-9725-e9c426ca2457",
+ "employeeId": null,
+ "date": "2026-05-15",
+ "shiftType": "Evening",
+ "requiredSkill": "Intervention",
+ "requiredStaff": 1
+ },
+ {
+ "id": "d2714adf-3fcd-4fc5-b861-a067ada96281",
+ "employeeId": null,
+ "date": "2026-05-16",
+ "shiftType": "Day",
+ "requiredSkill": "CT",
+ "requiredStaff": 1
+ },
+ {
+ "id": "7a88dafa-8b10-4a02-96cb-53c5aef2a755",
+ "employeeId": null,
+ "date": "2026-05-16",
+ "shiftType": "Night",
+ "requiredSkill": "Night",
+ "requiredStaff": 1
+ },
+ {
+ "id": "8995a793-ad88-4396-8414-ae233f32334a",
+ "employeeId": null,
+ "date": "2026-05-17",
+ "shiftType": "Day",
+ "requiredSkill": "XR",
+ "requiredStaff": 1
+ },
+ {
+ "id": "48a89f5e-3dd5-4968-b8ef-ad3b1f7ab191",
+ "employeeId": null,
+ "date": "2026-05-17",
+ "shiftType": "Night",
+ "requiredSkill": "Night",
+ "requiredStaff": 1
+ }
+]
diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts
new file mode 100644
index 0000000..b2225d7
--- /dev/null
+++ b/frontend/src/test/setup.ts
@@ -0,0 +1,9 @@
+import "@testing-library/jest-dom/vitest";
+import { cleanup } from "@testing-library/react";
+import { afterEach, vi } from "vitest";
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json
index 7f42e5f..0d272e5 100644
--- a/frontend/tsconfig.app.json
+++ b/frontend/tsconfig.app.json
@@ -9,6 +9,7 @@
/* Bundler mode */
"moduleResolution": "bundler",
+ "resolveJsonModule": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 8b0f57b..6e6a47f 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,7 +1,12 @@
+///
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
+ test: {
+ environment: 'jsdom',
+ setupFiles: ['./src/test/setup.ts'],
+ },
})