Compare commits
4 Commits
bd5fa730a8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0acece98dc | ||
|
|
6b2d0024ed | ||
|
|
35e632e9e0 | ||
|
|
fe27ec2beb |
@@ -1,7 +1,10 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(supabase --version)"
|
||||
"Bash(supabase --version)",
|
||||
"Bash(grep -n \"Radar-Profil\\\\|Ist/Soll\\\\|Trend \\(Gesamtpunkte\\)\\\\|Für den Trend\\\\|Kein Radarprofil\\\\|Deutsch\\\\|Englisch\\\\|Italienisch\\\\|Spanisch\\\\|Portugiesisch\\\\|Test final abschließen\\\\|Test als Link teilen\" /d/Projekte/PAT-STATS/src/components/**/*.jsx)",
|
||||
"Bash(find /d/Projekte/PAT-STATS/src -type f \\\\\\(-name *route* -o -name *Router* \\\\\\))",
|
||||
"Bash(npm run:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
3
.env
3
.env
@@ -1,2 +1,3 @@
|
||||
VITE_SUPABASE_URL=https://vzudjibddwcvfdnyvhhs.supabase.co
|
||||
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZ6dWRqaWJkZHdjdmZkbnl2aGhzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjUwMjk4MDksImV4cCI6MjA4MDYwNTgwOX0.wE8R1mpd2FY6m6d5-Hf3hlCG8OfeCkra6SjUvbt1mD0
|
||||
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZ6dWRqaWJkZHdjdmZkbnl2aGhzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjUwMjk4MDksImV4cCI6MjA4MDYwNTgwOX0.wE8R1mpd2FY6m6d5-Hf3hlCG8OfeCkra6SjUvbt1mD0
|
||||
SUPABASE_DB_PASSWORD=xDsHrwqC9VpifPTU
|
||||
@@ -1,6 +1,10 @@
|
||||
VITE_SUPABASE_URL=https://your-project.supabase.co
|
||||
VITE_SUPABASE_ANON_KEY=your-anon-key
|
||||
SUPABASE_DB_URL=postgresql://postgres:password@db.your-project.supabase.co:5432/postgres
|
||||
|
||||
# Für Migrations: Datenbankpasswort aus Supabase → Project Settings → Database → Database password
|
||||
SUPABASE_DB_PASSWORD=your-db-password
|
||||
# Alternativ: volle Postgres-URL (überschreibt SUPABASE_DB_PASSWORD)
|
||||
# SUPABASE_DB_URL=postgresql://postgres:password@db.your-project.supabase.co:5432/postgres
|
||||
RESEND_API_KEY=your-resend-api-key
|
||||
REMINDER_FROM_EMAIL=PAT Stats <noreply@example.com>
|
||||
REMINDER_APP_URL=https://your-app.example.com
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -1,7 +1,5 @@
|
||||
Offen
|
||||
Rangliste
|
||||
Übersetzen in gängigge Sprachen Englisch Italienisch Spanisch Portugisisch
|
||||
|
||||
Admin Oberfläche (anzahl user mit Mail Adressen/ anelegten Tests / anbindung von Werbung Links und rechts von den Test Übersichten
|
||||
2fa
|
||||
|
||||
@@ -11,11 +9,12 @@ Erledigt:
|
||||
Final Speichern do
|
||||
Max Punkte bei Bewertung PAT1
|
||||
User Profil mit Land und Sprach Auswahl
|
||||
|
||||
Übersetzen in gängigge Sprachen Englisch Italienisch Spanisch Portugisisch
|
||||
code splitting
|
||||
|
||||
Termninal
|
||||
vercel deploy
|
||||
|
||||
git add .
|
||||
git commit -m "BenutzerProfil "
|
||||
git commit -m "Übersetzung Teil 1"
|
||||
git push
|
||||
1
dist/assets/index-BaBfIFek.css
vendored
1
dist/assets/index-BaBfIFek.css
vendored
File diff suppressed because one or more lines are too long
86
dist/assets/index-Cg2UxsRL.js
vendored
86
dist/assets/index-Cg2UxsRL.js
vendored
File diff suppressed because one or more lines are too long
18
dist/assets/index.es-DQ6YK4So.js
vendored
18
dist/assets/index.es-DQ6YK4So.js
vendored
File diff suppressed because one or more lines are too long
170
dist/assets/jspdf.es.min-CLjTnO_I.js
vendored
170
dist/assets/jspdf.es.min-CLjTnO_I.js
vendored
File diff suppressed because one or more lines are too long
7
dist/index.html
vendored
7
dist/index.html
vendored
@@ -5,8 +5,11 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PAT Test Manager</title>
|
||||
<script type="module" crossorigin src="/assets/index-Cg2UxsRL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BaBfIFek.css">
|
||||
<script type="module" crossorigin src="/assets/index-Bp6S7Gpx.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-react-VZ-Yj73K.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-supabase-PyIEoSKG.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-ui-epoOyxwp.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CRRMtHNa.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
285
package-lock.json
generated
285
package-lock.json
generated
@@ -25,6 +25,7 @@
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"supabase": "^2.83.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"vite": "^5.4.2",
|
||||
"vitest": "^1.6.1"
|
||||
@@ -725,6 +726,19 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"minipass": "^7.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jest/schemas": {
|
||||
"version": "29.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
|
||||
@@ -1482,6 +1496,16 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/all": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/all/-/all-0.0.0.tgz",
|
||||
@@ -1597,6 +1621,23 @@
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/bin-links": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bin-links/-/bin-links-6.0.0.tgz",
|
||||
"integrity": "sha512-X4CiKlcV2GjnCMwnKAfbVWpHa++65th9TuzAEYtZoATiOE2DQKhSp4CJlyLoTqdhBKlXjpXjCTYPNNFS33Fi6w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cmd-shim": "^8.0.0",
|
||||
"npm-normalize-package-bin": "^5.0.0",
|
||||
"proc-log": "^6.0.0",
|
||||
"read-cmd-shim": "^6.0.0",
|
||||
"write-file-atomic": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -1801,6 +1842,26 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cmd-shim": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-8.0.0.tgz",
|
||||
"integrity": "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
@@ -1909,6 +1970,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/data-uri-to-buffer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -2115,6 +2186,30 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-blob": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-domexception": "^1.0.0",
|
||||
"web-streams-polyfill": "^3.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20 || >= 14.13"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
@@ -2134,6 +2229,19 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/formdata-polyfill": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fetch-blob": "^3.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
@@ -2255,6 +2363,20 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/human-signals": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
|
||||
@@ -2562,6 +2684,29 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/minizlib": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
|
||||
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/mlly": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz",
|
||||
@@ -2620,6 +2765,46 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
||||
"deprecated": "Use your platform's native DOMException instead",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"data-uri-to-buffer": "^4.0.0",
|
||||
"fetch-blob": "^3.1.4",
|
||||
"formdata-polyfill": "^4.0.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
@@ -2647,6 +2832,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/npm-normalize-package-bin": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz",
|
||||
"integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/npm-run-path": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",
|
||||
@@ -3140,6 +3335,16 @@
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proc-log": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz",
|
||||
"integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -3223,6 +3428,16 @@
|
||||
"pify": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cmd-shim": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-6.0.0.tgz",
|
||||
"integrity": "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
@@ -3524,6 +3739,26 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/supabase": {
|
||||
"version": "2.83.0",
|
||||
"resolved": "https://registry.npmjs.org/supabase/-/supabase-2.83.0.tgz",
|
||||
"integrity": "sha512-80j5YeYMkJqVc5gZGySMxiUJSUcwES6mNqi1nCa9q40qnPftff+LevcdZGLct4xtpaefkTtgmZArPSdq+H2RZQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bin-links": "^6.0.0",
|
||||
"https-proxy-agent": "^7.0.2",
|
||||
"node-fetch": "^3.3.2",
|
||||
"tar": "7.5.11"
|
||||
},
|
||||
"bin": {
|
||||
"supabase": "bin/supabase"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-preserve-symlinks-flag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
@@ -3585,6 +3820,33 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.11",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz",
|
||||
"integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
"chownr": "^3.0.0",
|
||||
"minipass": "^7.1.2",
|
||||
"minizlib": "^3.1.0",
|
||||
"yallist": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tar/node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/text-segmentation": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
@@ -3939,6 +4201,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -3990,6 +4262,19 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/write-file-atomic": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.1.tgz",
|
||||
"integrity": "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"signal-exit": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"migrate": "node scripts/applyMigrations.js",
|
||||
"migrate": "node --env-file=.env scripts/applyMigrations.js",
|
||||
"migrate:status": "node --env-file=.env scripts/applyMigrations.js --status",
|
||||
"migrate:rollback": "node --env-file=.env scripts/applyMigrations.js --rollback",
|
||||
"send-finalization-reminders": "node scripts/sendFinalizationReminders.js",
|
||||
"test": "vitest run"
|
||||
},
|
||||
@@ -29,6 +31,7 @@
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"supabase": "^2.83.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"vite": "^5.4.2",
|
||||
"vitest": "^1.6.1"
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Lightweight migration runner for Supabase.
|
||||
* Reads SQL files in supabase/migrations (sorted) and applies any that have not been recorded.
|
||||
* Similar to Laravel's `artisan migrate`.
|
||||
*
|
||||
* Env:
|
||||
* - SUPABASE_DB_URL or DATABASE_URL: postgres connection string (service role / connection string)
|
||||
* Usage:
|
||||
* npm run migrate - Apply all pending migrations
|
||||
* npm run migrate:status - Show status of all migrations
|
||||
* npm run migrate:rollback - Rollback the last applied migration
|
||||
*
|
||||
* Env (loaded from .env automatically via --env-file):
|
||||
* SUPABASE_DB_URL or DATABASE_URL postgres connection string
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
@@ -15,15 +20,43 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const migrationsDir = path.resolve(__dirname, '..', 'supabase', 'migrations');
|
||||
const connectionString = process.env.SUPABASE_DB_URL || process.env.DATABASE_URL;
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0]; // --status | --rollback | undefined (= migrate)
|
||||
|
||||
function buildConnectionString() {
|
||||
// Direct override takes precedence
|
||||
if (process.env.SUPABASE_DB_URL) return process.env.SUPABASE_DB_URL;
|
||||
if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
|
||||
|
||||
// Derive from VITE_SUPABASE_URL + SUPABASE_DB_PASSWORD
|
||||
const supabaseUrl = process.env.VITE_SUPABASE_URL;
|
||||
const dbPassword = process.env.SUPABASE_DB_PASSWORD;
|
||||
|
||||
if (supabaseUrl && dbPassword) {
|
||||
// Extract project ref from https://[ref].supabase.co
|
||||
const match = supabaseUrl.match(/https:\/\/([^.]+)\.supabase\.co/);
|
||||
if (!match) {
|
||||
console.error('❌ VITE_SUPABASE_URL hat ein unerwartetes Format.');
|
||||
process.exit(1);
|
||||
}
|
||||
const ref = match[1];
|
||||
return `postgresql://postgres:${encodeURIComponent(dbPassword)}@db.${ref}.supabase.co:5432/postgres`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const connectionString = buildConnectionString();
|
||||
|
||||
if (!connectionString) {
|
||||
console.error('Missing SUPABASE_DB_URL (or DATABASE_URL) environment variable. Aborting migrations.');
|
||||
console.error('❌ Keine Datenbankverbindung konfiguriert.');
|
||||
console.error(' Füge SUPABASE_DB_PASSWORD zu deiner .env hinzu (Passwort aus Supabase → Settings → Database).');
|
||||
console.error(' Alternativ: SUPABASE_DB_URL=postgresql://... direkt setzen.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(migrationsDir)) {
|
||||
console.error(`Migrations directory not found: ${migrationsDir}`);
|
||||
console.error(`❌ Migrations directory not found: ${migrationsDir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -32,38 +65,127 @@ const pool = new Pool({ connectionString });
|
||||
async function ensureMigrationsTable(client) {
|
||||
await client.query(`
|
||||
create table if not exists public.schema_migrations (
|
||||
id serial primary key,
|
||||
filename text not null unique,
|
||||
id serial primary key,
|
||||
filename text not null unique,
|
||||
applied_at timestamptz not null default now()
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
async function getApplied(client) {
|
||||
const { rows } = await client.query('select filename from public.schema_migrations');
|
||||
return new Set(rows.map((r) => r.filename));
|
||||
const { rows } = await client.query(
|
||||
'select filename, applied_at from public.schema_migrations order by filename'
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function listMigrations() {
|
||||
function listMigrationFiles() {
|
||||
return fs
|
||||
.readdirSync(migrationsDir)
|
||||
.filter((file) => file.endsWith('.sql'))
|
||||
.filter((f) => f.endsWith('.sql'))
|
||||
.sort();
|
||||
}
|
||||
|
||||
async function applyMigration(client, filename) {
|
||||
const filePath = path.join(migrationsDir, filename);
|
||||
const sql = fs.readFileSync(filePath, 'utf8');
|
||||
console.log(`Applying migration: ${filename}`);
|
||||
console.log(` → Applying: ${filename}`);
|
||||
await client.query('begin');
|
||||
try {
|
||||
await client.query(sql);
|
||||
await client.query('insert into public.schema_migrations (filename) values ($1)', [filename]);
|
||||
await client.query(
|
||||
'insert into public.schema_migrations (filename) values ($1)',
|
||||
[filename]
|
||||
);
|
||||
await client.query('commit');
|
||||
console.log(`✓ Applied ${filename}`);
|
||||
console.log(` ✓ Done: ${filename}`);
|
||||
} catch (err) {
|
||||
await client.query('rollback');
|
||||
console.error(`✗ Failed ${filename}:`, err.message);
|
||||
console.error(` ✗ Failed: ${filename}\n ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdMigrate(client) {
|
||||
await ensureMigrationsTable(client);
|
||||
const applied = new Set((await getApplied(client)).map((r) => r.filename));
|
||||
const pending = listMigrationFiles().filter((f) => !applied.has(f));
|
||||
|
||||
if (pending.length === 0) {
|
||||
console.log('✔ Nothing to migrate – all migrations are up to date.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\nRunning ${pending.length} migration(s)...\n`);
|
||||
for (const file of pending) {
|
||||
await applyMigration(client, file);
|
||||
}
|
||||
console.log(`\n✔ ${pending.length} migration(s) applied successfully.`);
|
||||
}
|
||||
|
||||
async function cmdStatus(client) {
|
||||
await ensureMigrationsTable(client);
|
||||
const appliedRows = await getApplied(client);
|
||||
const appliedMap = new Map(appliedRows.map((r) => [r.filename, r.applied_at]));
|
||||
const files = listMigrationFiles();
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log('No migration files found.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\n Status Migration');
|
||||
console.log(' ───────── ' + '─'.repeat(50));
|
||||
for (const file of files) {
|
||||
if (appliedMap.has(file)) {
|
||||
const ts = new Date(appliedMap.get(file)).toLocaleString('de-AT');
|
||||
console.log(` ✓ applied ${file} (${ts})`);
|
||||
} else {
|
||||
console.log(` ○ pending ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
const pendingCount = files.filter((f) => !appliedMap.has(f)).length;
|
||||
console.log(`\n ${appliedMap.size} applied, ${pendingCount} pending.\n`);
|
||||
}
|
||||
|
||||
async function cmdRollback(client) {
|
||||
await ensureMigrationsTable(client);
|
||||
const applied = await getApplied(client);
|
||||
|
||||
if (applied.length === 0) {
|
||||
console.log('Nothing to rollback – no migrations have been applied.');
|
||||
return;
|
||||
}
|
||||
|
||||
const last = applied[applied.length - 1];
|
||||
console.log(`\nRolling back: ${last.filename}`);
|
||||
|
||||
// Check for a corresponding .down.sql file
|
||||
const downFile = path.join(
|
||||
migrationsDir,
|
||||
last.filename.replace('.sql', '.down.sql')
|
||||
);
|
||||
|
||||
await client.query('begin');
|
||||
try {
|
||||
if (fs.existsSync(downFile)) {
|
||||
const sql = fs.readFileSync(downFile, 'utf8');
|
||||
await client.query(sql);
|
||||
console.log(` ✓ Ran down-migration: ${path.basename(downFile)}`);
|
||||
} else {
|
||||
console.warn(` ⚠ No down-migration file found (${path.basename(downFile)})`);
|
||||
console.warn(' Only removing the migration record, schema changes are NOT reverted.');
|
||||
}
|
||||
await client.query(
|
||||
'delete from public.schema_migrations where filename = $1',
|
||||
[last.filename]
|
||||
);
|
||||
await client.query('commit');
|
||||
console.log(`✔ Rolled back: ${last.filename}\n`);
|
||||
} catch (err) {
|
||||
await client.query('rollback');
|
||||
console.error(`✗ Rollback failed: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -71,20 +193,13 @@ async function applyMigration(client, filename) {
|
||||
async function run() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await ensureMigrationsTable(client);
|
||||
const applied = await getApplied(client);
|
||||
const migrations = listMigrations();
|
||||
|
||||
const pending = migrations.filter((m) => !applied.has(m));
|
||||
if (pending.length === 0) {
|
||||
console.log('No pending migrations.');
|
||||
return;
|
||||
if (command === '--status') {
|
||||
await cmdStatus(client);
|
||||
} else if (command === '--rollback') {
|
||||
await cmdRollback(client);
|
||||
} else {
|
||||
await cmdMigrate(client);
|
||||
}
|
||||
|
||||
for (const migration of pending) {
|
||||
await applyMigration(client, migration);
|
||||
}
|
||||
console.log('All pending migrations applied.');
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
@@ -92,6 +207,6 @@ async function run() {
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error('Migration run failed:', err);
|
||||
console.error('\n❌ Migration runner failed:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
97
src/App.jsx
97
src/App.jsx
@@ -1,18 +1,25 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { lazy, Suspense, useEffect, useState } from 'react'
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import PatTestManager from './components/PatTestManager'
|
||||
import AuthPanel from './components/AuthPanel'
|
||||
import { supabase } from './lib/supabaseClient'
|
||||
import Landing from './components/Landing'
|
||||
import SharedAssessmentPage from './components/SharedAssessmentPage'
|
||||
import PublicInfoPage from './components/public/PublicInfoPage'
|
||||
import ProfileTab from './components/ProfileTab'
|
||||
import { useUserProfile } from './hooks/useUserProfile'
|
||||
import { getCountryLabel, getCountryOption } from './data/countries'
|
||||
import CountryFlag from './components/CountryFlag'
|
||||
import { LanguageProvider } from './i18n/LanguageContext'
|
||||
import { getTranslator } from './i18n/translations'
|
||||
|
||||
const PatTestManager = lazy(() => import('./components/PatTestManager'))
|
||||
const AuthPanel = lazy(() => import('./components/AuthPanel'))
|
||||
const Landing = lazy(() => import('./components/Landing'))
|
||||
const SharedAssessmentPage = lazy(() => import('./components/SharedAssessmentPage'))
|
||||
const PublicInfoPage = lazy(() => import('./components/public/PublicInfoPage'))
|
||||
const ProfileTab = lazy(() => import('./components/ProfileTab'))
|
||||
|
||||
const PageSpinner = () => (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 text-gray-600 dark:text-gray-300">
|
||||
<span>Laden…</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
const readShareToken = () => {
|
||||
if (typeof window === 'undefined') return ''
|
||||
return new URLSearchParams(window.location.search).get('share') || ''
|
||||
@@ -156,7 +163,9 @@ function App() {
|
||||
if (shareToken) {
|
||||
return (
|
||||
<LanguageProvider language={language}>
|
||||
<SharedAssessmentPage shareToken={shareToken} themeToggle={renderThemeToggle()} />
|
||||
<Suspense fallback={<PageSpinner />}>
|
||||
<SharedAssessmentPage shareToken={shareToken} themeToggle={renderThemeToggle()} />
|
||||
</Suspense>
|
||||
</LanguageProvider>
|
||||
)
|
||||
}
|
||||
@@ -197,7 +206,9 @@ function App() {
|
||||
if (showAuth) {
|
||||
return (
|
||||
<LanguageProvider language={language}>
|
||||
<AuthPanel onAuth={setSession} themeToggle={renderThemeToggle()} onBack={() => setShowAuth(false)} />
|
||||
<Suspense fallback={<PageSpinner />}>
|
||||
<AuthPanel onAuth={setSession} themeToggle={renderThemeToggle()} onBack={() => setShowAuth(false)} />
|
||||
</Suspense>
|
||||
</LanguageProvider>
|
||||
)
|
||||
}
|
||||
@@ -205,23 +216,27 @@ function App() {
|
||||
if (publicPath === '/datenschutz' || publicPath === '/impressum' || publicPath === '/kontakt') {
|
||||
return (
|
||||
<LanguageProvider language={language}>
|
||||
<PublicInfoPage
|
||||
path={publicPath}
|
||||
onGetStarted={() => setShowAuth(true)}
|
||||
onNavigate={navigatePublic}
|
||||
themeToggle={renderThemeToggle()}
|
||||
/>
|
||||
<Suspense fallback={<PageSpinner />}>
|
||||
<PublicInfoPage
|
||||
path={publicPath}
|
||||
onGetStarted={() => setShowAuth(true)}
|
||||
onNavigate={navigatePublic}
|
||||
themeToggle={renderThemeToggle()}
|
||||
/>
|
||||
</Suspense>
|
||||
</LanguageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<LanguageProvider language={language}>
|
||||
<Landing
|
||||
onGetStarted={() => setShowAuth(true)}
|
||||
onNavigate={navigatePublic}
|
||||
themeToggle={renderThemeToggle()}
|
||||
/>
|
||||
<Suspense fallback={<PageSpinner />}>
|
||||
<Landing
|
||||
onGetStarted={() => setShowAuth(true)}
|
||||
onNavigate={navigatePublic}
|
||||
themeToggle={renderThemeToggle()}
|
||||
/>
|
||||
</Suspense>
|
||||
</LanguageProvider>
|
||||
)
|
||||
}
|
||||
@@ -306,27 +321,29 @@ function App() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{activeTab !== 'profile' && (
|
||||
<PatTestManager
|
||||
user={session?.user}
|
||||
activeTab={activeTab}
|
||||
onEditingStateChange={setIsEditingAssessment}
|
||||
testsVisible={Boolean(profile?.testsVisible)}
|
||||
isProfileLoading={profileLoading}
|
||||
/>
|
||||
)}
|
||||
<Suspense fallback={<PageSpinner />}>
|
||||
{activeTab !== 'profile' && (
|
||||
<PatTestManager
|
||||
user={session?.user}
|
||||
activeTab={activeTab}
|
||||
onEditingStateChange={setIsEditingAssessment}
|
||||
testsVisible={Boolean(profile?.testsVisible)}
|
||||
isProfileLoading={profileLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'profile' && (
|
||||
<ProfileTab
|
||||
session={session}
|
||||
profile={profile}
|
||||
loading={profileLoading}
|
||||
error={profileError}
|
||||
onSave={saveProfile}
|
||||
onDelete={deleteProfileData}
|
||||
onSessionChange={setSession}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'profile' && (
|
||||
<ProfileTab
|
||||
session={session}
|
||||
profile={profile}
|
||||
loading={profileLoading}
|
||||
error={profileError}
|
||||
onSave={saveProfile}
|
||||
onDelete={deleteProfileData}
|
||||
onSessionChange={setSession}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</div>
|
||||
</LanguageProvider>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from '../../i18n/LanguageContext';
|
||||
|
||||
const polarToCartesian = (centerX, centerY, radius, angle) => ({
|
||||
x: centerX + radius * Math.cos(angle),
|
||||
@@ -8,12 +9,13 @@ const polarToCartesian = (centerX, centerY, radius, angle) => ({
|
||||
const truncate = (value, maxLength = 16) => (value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value);
|
||||
|
||||
export default function RadarChart({ radarSeries = [] }) {
|
||||
const t = useTranslation();
|
||||
const data = radarSeries.slice(0, 6);
|
||||
|
||||
if (!data.length) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 bg-white/70 dark:bg-gray-900/70 p-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
Kein Radarprofil verfügbar.
|
||||
{t('radar.no_data')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -40,8 +42,8 @@ export default function RadarChart({ radarSeries = [] }) {
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-4 shadow-sm h-full flex flex-col">
|
||||
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-3">Radar-Profil (Ist/Soll)</h3>
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto max-w-[420px] mx-auto" role="img" aria-label="Radarprofil">
|
||||
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-3">{t('radar.title')}</h3>
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto max-w-[420px] mx-auto" role="img" aria-label={t('radar.aria')}>
|
||||
{[0.25, 0.5, 0.75, 1].map((level) => (
|
||||
<polygon
|
||||
key={level}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from '../../i18n/LanguageContext';
|
||||
|
||||
export default function TrendChart({ trendSeries = [] }) {
|
||||
const t = useTranslation();
|
||||
|
||||
if (trendSeries.length < 2) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 bg-white/70 dark:bg-gray-900/70 p-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
Für den Trend werden mindestens 2 Assessments benötigt.
|
||||
{t('trend.no_data')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,8 +38,8 @@ export default function TrendChart({ trendSeries = [] }) {
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-4 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-3">Trend (Gesamtpunkte)</h3>
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto" role="img" aria-label="Trenddiagramm">
|
||||
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-3">{t('trend.title')}</h3>
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto" role="img" aria-label={t('trend.aria')}>
|
||||
<line x1={left} y1={top} x2={left} y2={top + innerHeight} className="stroke-gray-300 dark:stroke-gray-700" strokeWidth="1" />
|
||||
<line x1={left} y1={top + innerHeight} x2={left + innerWidth} y2={top + innerHeight} className="stroke-gray-300 dark:stroke-gray-700" strokeWidth="1" />
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import { CheckCircle2, FileDown, Lock, Save, Share2 } from 'lucide-react';
|
||||
import ExerciseInput from './ExerciseInput';
|
||||
import SaveDialog from './SaveDialog';
|
||||
import { downloadAssessmentPdf } from '../../utils/pdfExport';
|
||||
import { useTranslation } from '../../i18n/LanguageContext';
|
||||
import { useTranslation, useLanguage } from '../../i18n/LanguageContext';
|
||||
import { translateExerciseName } from '../../i18n/exerciseNames';
|
||||
|
||||
const PatDetail = ({
|
||||
currentAssessment,
|
||||
@@ -32,11 +33,18 @@ const PatDetail = ({
|
||||
onRevokeShare
|
||||
}) => {
|
||||
const t = useTranslation();
|
||||
const language = useLanguage();
|
||||
|
||||
if (!currentAssessment) return null;
|
||||
|
||||
const totalPoints = calculateTotal();
|
||||
const achievement = getAchievement(currentAssessment?.patType, totalPoints);
|
||||
|
||||
const getAchievementName = (name) => {
|
||||
if (name === 'Nicht bestanden') return t('achievement.not_passed');
|
||||
if (name.endsWith(' Teilnahme')) return name.replace(' Teilnahme', ` ${t('achievement.participation')}`);
|
||||
return name;
|
||||
};
|
||||
const handleDownloadPdf = async () => {
|
||||
await downloadAssessmentPdf({
|
||||
assessment: currentAssessment,
|
||||
@@ -104,7 +112,7 @@ const PatDetail = ({
|
||||
<button
|
||||
onClick={onFinalize}
|
||||
disabled={!canFinalize}
|
||||
title={finalizeDisabledReason || 'Test final abschließen'}
|
||||
title={finalizeDisabledReason || t('detail.finalize_title')}
|
||||
className={`px-4 py-2 rounded-lg transition flex items-center gap-2 text-sm ${
|
||||
canFinalize
|
||||
? 'bg-emerald-700 text-white hover:bg-emerald-800'
|
||||
@@ -126,7 +134,7 @@ const PatDetail = ({
|
||||
<button
|
||||
onClick={onShare}
|
||||
disabled={!canShare}
|
||||
title={shareDisabledReason || 'Test als Link teilen'}
|
||||
title={shareDisabledReason || t('detail.share_title')}
|
||||
className={`px-4 py-2 rounded-lg transition flex items-center gap-2 text-sm ${
|
||||
canShare
|
||||
? 'bg-sky-600 text-white hover:bg-sky-700'
|
||||
@@ -191,7 +199,7 @@ const PatDetail = ({
|
||||
|
||||
{currentAssessment?.exercises.map((exercise, exIndex) => (
|
||||
<div key={exIndex} className="mb-6 border-b pb-4 border-gray-200 dark:border-gray-800">
|
||||
<div className="font-bold text-lg mb-2 text-gray-700 dark:text-gray-200">{exercise.name}</div>
|
||||
<div className="font-bold text-lg mb-2 text-gray-700 dark:text-gray-200">{translateExerciseName(exercise.name, language)}</div>
|
||||
|
||||
<ExerciseInput exercise={exercise} exIndex={exIndex} onChange={onUpdateExercise} disabled={isReadOnly} />
|
||||
|
||||
@@ -222,7 +230,7 @@ const PatDetail = ({
|
||||
</div>
|
||||
|
||||
<div className={`mt-4 px-6 py-4 rounded-lg ${achievement.color} ${achievement.text} text-center`}>
|
||||
<p className="text-2xl font-bold">{achievement.name}</p>
|
||||
<p className="text-2xl font-bold">{getAchievementName(achievement.name)}</p>
|
||||
{achievement.subtitle && <p className="text-lg mt-2">{achievement.subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -218,7 +218,7 @@ const PatList = ({
|
||||
<h2 className="text-lg font-bold text-gray-800 dark:text-gray-100">{title}</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('patlist.count', { count: entries.length })}
|
||||
{entries.length !== totalEntries ? ` von ${totalEntries}` : ''}
|
||||
{entries.length !== totalEntries ? ` ${t('patlist.of')} ${totalEntries}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from '../i18n/LanguageContext';
|
||||
import PatList from './PatList/PatList';
|
||||
import PatDetail from './PatDetail/PatDetail';
|
||||
import AnalysisTab from './Analysis/AnalysisTab';
|
||||
const AnalysisTab = lazy(() => import('./Analysis/AnalysisTab'));
|
||||
import { patTypes } from '../data/patTypes';
|
||||
import { useAssessments } from '../hooks/useAssessments';
|
||||
import { getAchievement, getPatTypeColor } from '../utils/patCalculations';
|
||||
@@ -309,13 +309,15 @@ export default function PatTestManager({
|
||||
|
||||
if (activeTab === 'analysis') {
|
||||
return (
|
||||
<AnalysisTab
|
||||
assessments={visibleAssessments}
|
||||
selectedPatType={selectedPatType}
|
||||
onPatTypeChange={setSelectedPatType}
|
||||
patTypes={visiblePatTypes}
|
||||
user={user}
|
||||
/>
|
||||
<Suspense fallback={<div className="min-h-screen flex items-center justify-center text-gray-500 dark:text-gray-400">Laden…</div>}>
|
||||
<AnalysisTab
|
||||
assessments={visibleAssessments}
|
||||
selectedPatType={selectedPatType}
|
||||
onPatTypeChange={setSelectedPatType}
|
||||
patTypes={visiblePatTypes}
|
||||
user={user}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Check, ChevronDown } from 'lucide-react';
|
||||
import { countryOptions, getCountryLabel, getCountryOption } from '../data/countries';
|
||||
import CountryFlag from './CountryFlag';
|
||||
import { useTranslation } from '../i18n/LanguageContext';
|
||||
import { getTranslator } from '../i18n/translations';
|
||||
|
||||
const emptyStatus = {
|
||||
message: '',
|
||||
@@ -81,6 +82,8 @@ export default function ProfileTab({
|
||||
setStatus(emptyStatus);
|
||||
|
||||
const result = await onSave(formValues);
|
||||
// Nach dem Speichern in der neu gewählten Sprache übersetzen
|
||||
const tNew = getTranslator(formValues.language);
|
||||
|
||||
if (result?.ok) {
|
||||
if (result.session) {
|
||||
@@ -89,14 +92,14 @@ export default function ProfileTab({
|
||||
|
||||
setStatus({
|
||||
message: result.emailUpdateRequested
|
||||
? t('profile.saved_email')
|
||||
: t('profile.saved'),
|
||||
? tNew('profile.saved_email')
|
||||
: tNew('profile.saved'),
|
||||
error: ''
|
||||
});
|
||||
} else {
|
||||
setStatus({
|
||||
message: '',
|
||||
error: result?.error?.message || t('profile.error_save')
|
||||
error: result?.error?.message || tNew('profile.error_save')
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import { getTranslator } from './translations';
|
||||
import { getTranslator, SUPPORTED_LANGUAGES } from './translations';
|
||||
|
||||
const LanguageContext = createContext(() => (key) => key);
|
||||
const LanguageContext = createContext({ t: (key) => key, language: 'de' });
|
||||
|
||||
export function LanguageProvider({ language, children }) {
|
||||
const t = useMemo(() => getTranslator(language), [language]);
|
||||
const lang = SUPPORTED_LANGUAGES.includes(language) ? language : 'de';
|
||||
const t = useMemo(() => getTranslator(lang), [lang]);
|
||||
const value = useMemo(() => ({ t, language: lang }), [t, lang]);
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={t}>
|
||||
<LanguageContext.Provider value={value}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTranslation() {
|
||||
return useContext(LanguageContext);
|
||||
return useContext(LanguageContext).t;
|
||||
}
|
||||
|
||||
export function useLanguage() {
|
||||
return useContext(LanguageContext).language;
|
||||
}
|
||||
|
||||
138
src/i18n/exerciseNames.js
Normal file
138
src/i18n/exerciseNames.js
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Übersetzungen für PAT-Übungsnamen.
|
||||
* Der deutsche Name (wie er in der Datenbank steht) ist der Schlüssel.
|
||||
*/
|
||||
const exerciseNameMap = {
|
||||
// ── PAT Start ──────────────────────────────────────────────────────────────
|
||||
'1) Stoß-Geschwindigkeit': {
|
||||
en: '1) Stroke Speed',
|
||||
it: '1) Velocità del colpo',
|
||||
es: '1) Velocidad del golpe',
|
||||
pt: '1) Velocidade do golpe',
|
||||
},
|
||||
'2) Stoß-Gradlinigkeit': {
|
||||
en: '2) Stroke Straightness',
|
||||
it: '2) Linearità del colpo',
|
||||
es: '2) Linealidad del golpe',
|
||||
pt: '2) Linearidade do golpe',
|
||||
},
|
||||
'3) Winkelbälle': {
|
||||
en: '3) Angle Balls',
|
||||
it: '3) Palle angolate',
|
||||
es: '3) Bolas en ángulo',
|
||||
pt: '3) Bolas em ângulo',
|
||||
},
|
||||
'4) Nachlauf Wirkung': {
|
||||
en: '4) Follow-Through Effect',
|
||||
it: '4) Effetto di seguita',
|
||||
es: '4) Efecto de seguimiento',
|
||||
pt: '4) Efeito de avanço',
|
||||
},
|
||||
'5) Rücklauf-Wirkung': {
|
||||
en: '5) Back Spin Effect',
|
||||
it: '5) Effetto di ritorno',
|
||||
es: '5) Efecto de retroceso',
|
||||
pt: '5) Efeito de retorno',
|
||||
},
|
||||
'6) Kl. Positions-Spiel': {
|
||||
en: '6) Sm. Position Play',
|
||||
it: '6) Pic. gioco di posizione',
|
||||
es: '6) Peq. juego de posición',
|
||||
pt: '6) Peq. jogo de posição',
|
||||
},
|
||||
'7) Gr. Pos. Bereich': {
|
||||
en: '7) Lg. Position Range',
|
||||
it: '7) Gr. area di posizione',
|
||||
es: '7) Gr. rango de posición',
|
||||
pt: '7) Gr. área de posição',
|
||||
},
|
||||
'8) Press Bande': {
|
||||
en: '8) Cushion Press',
|
||||
it: '8) Pressione sponda',
|
||||
es: '8) Presión de banda',
|
||||
pt: '8) Pressão de banda',
|
||||
},
|
||||
'9) Standardbälle': {
|
||||
en: '9) Standard Balls',
|
||||
it: '9) Palle standard',
|
||||
es: '9) Bolas estándar',
|
||||
pt: '9) Bolas padrão',
|
||||
},
|
||||
'10) 8-Ball Situation': {
|
||||
en: '10) 8-Ball Situation',
|
||||
it: '10) Situazione 8-Ball',
|
||||
es: '10) Situación 8-Ball',
|
||||
pt: '10) Situação 8-Ball',
|
||||
},
|
||||
|
||||
// ── PAT 1 ──────────────────────────────────────────────────────────────────
|
||||
'1) Geschwindigkeit': {
|
||||
en: '1) Speed',
|
||||
it: '1) Velocità',
|
||||
es: '1) Velocidad',
|
||||
pt: '1) Velocidade',
|
||||
},
|
||||
'2) Geradlinigkeit (Speed 2)': {
|
||||
en: '2) Straightness (Speed 2)',
|
||||
it: '2) Linearità (Speed 2)',
|
||||
es: '2) Linealidad (Speed 2)',
|
||||
pt: '2) Linearidade (Speed 2)',
|
||||
},
|
||||
'3) Nachlauf': {
|
||||
en: '3) Follow-Through',
|
||||
it: '3) Seguita',
|
||||
es: '3) Seguimiento',
|
||||
pt: '3) Avanço',
|
||||
},
|
||||
'4) Rücklauf': {
|
||||
en: '4) Back Spin',
|
||||
it: '4) Effetto retro',
|
||||
es: '4) Retroceso',
|
||||
pt: '4) Retorno',
|
||||
},
|
||||
'5) Kl. Pos.-Bereich': {
|
||||
en: '5) Sm. Position Range',
|
||||
it: '5) Pic. area di posizione',
|
||||
es: '5) Peq. rango de posición',
|
||||
pt: '5) Peq. área de posição',
|
||||
},
|
||||
'6) Gr. Pos.-Bereich': {
|
||||
en: '6) Lg. Position Range',
|
||||
it: '6) Gr. area di posizione',
|
||||
es: '6) Gr. rango de posición',
|
||||
pt: '6) Gr. área de posição',
|
||||
},
|
||||
'7) Press Bande': {
|
||||
en: '7) Cushion Press',
|
||||
it: '7) Pressione sponda',
|
||||
es: '7) Presión de banda',
|
||||
pt: '7) Pressão de banda',
|
||||
},
|
||||
'8) Endlos-Übung (Max: 9)': {
|
||||
en: '8) Continuous Exercise (Max: 9)',
|
||||
it: '8) Esercizio continuo (Max: 9)',
|
||||
es: '8) Ejercicio continuo (Máx: 9)',
|
||||
pt: '8) Exercício contínuo (Máx: 9)',
|
||||
},
|
||||
'9) Standardbälle': {
|
||||
en: '9) Standard Balls',
|
||||
it: '9) Palle standard',
|
||||
es: '9) Bolas estándar',
|
||||
pt: '9) Bolas padrão',
|
||||
},
|
||||
'10) 9-Ball Situation 5': {
|
||||
en: '10) 9-Ball Situation 5',
|
||||
it: '10) Situazione 9-Ball 5',
|
||||
es: '10) Situación 9-Ball 5',
|
||||
pt: '10) Situação 9-Ball 5',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Gibt den übersetzten Übungsnamen zurück.
|
||||
* Fällt auf den deutschen Namen zurück, wenn keine Übersetzung vorhanden.
|
||||
*/
|
||||
export function translateExerciseName(germanName, language) {
|
||||
if (!language || language === 'de') return germanName;
|
||||
return exerciseNameMap[germanName]?.[language] ?? germanName;
|
||||
}
|
||||
@@ -112,6 +112,9 @@ const translations = {
|
||||
'detail.col_factor': 'Faktor',
|
||||
'detail.col_points': 'Points',
|
||||
'detail.col_result': 'Ergebnis',
|
||||
'detail.finalize_title': 'Test final abschließen',
|
||||
'detail.share_title': 'Test als Link teilen',
|
||||
'patlist.of': 'von',
|
||||
|
||||
// ── Speichern-Dialog ─────────────────────────────────────────────────────
|
||||
'savedialog.title': 'Ungespeicherte Änderungen',
|
||||
@@ -182,6 +185,20 @@ const translations = {
|
||||
'training.history': 'Plan-Historie',
|
||||
'training.no_history': 'Keine archivierten Pläne vorhanden.',
|
||||
|
||||
// ── Ergebnis-Bewertungen ─────────────────────────────────────────────────
|
||||
'achievement.not_passed': 'Nicht bestanden',
|
||||
'achievement.participation': 'Teilnahme',
|
||||
|
||||
// ── Radar-Chart ──────────────────────────────────────────────────────────
|
||||
'radar.no_data': 'Kein Radarprofil verfügbar.',
|
||||
'radar.title': 'Radar-Profil (Ist/Soll)',
|
||||
'radar.aria': 'Radarprofil',
|
||||
|
||||
// ── Trend-Chart ──────────────────────────────────────────────────────────
|
||||
'trend.no_data': 'Für den Trend werden mindestens 2 Assessments benötigt.',
|
||||
'trend.title': 'Trend (Gesamtpunkte)',
|
||||
'trend.aria': 'Trenddiagramm',
|
||||
|
||||
// ── Gap-Chart ────────────────────────────────────────────────────────────
|
||||
'gapchart.no_data': 'Kein Gap-Chart verfügbar.',
|
||||
'gapchart.title': 'Gap-Bar-Chart (Priorität)',
|
||||
@@ -347,6 +364,9 @@ const translations = {
|
||||
'detail.col_factor': 'Factor',
|
||||
'detail.col_points': 'Points',
|
||||
'detail.col_result': 'Result',
|
||||
'detail.finalize_title': 'Finalize test',
|
||||
'detail.share_title': 'Share test as link',
|
||||
'patlist.of': 'of',
|
||||
|
||||
'savedialog.title': 'Unsaved changes',
|
||||
'savedialog.message': 'You have unsaved changes. Would you like to save them?',
|
||||
@@ -412,6 +432,14 @@ const translations = {
|
||||
'training.history': 'Plan history',
|
||||
'training.no_history': 'No archived plans available.',
|
||||
|
||||
'achievement.not_passed': 'Not passed',
|
||||
'achievement.participation': 'Participation',
|
||||
'radar.no_data': 'No radar profile available.',
|
||||
'radar.title': 'Radar Profile (Actual/Target)',
|
||||
'radar.aria': 'Radar profile',
|
||||
'trend.no_data': 'At least 2 assessments are needed for the trend.',
|
||||
'trend.title': 'Trend (Total points)',
|
||||
'trend.aria': 'Trend chart',
|
||||
'gapchart.no_data': 'No gap chart available.',
|
||||
'gapchart.title': 'Gap Bar Chart (Priority)',
|
||||
'gapchart.aria': 'Gap bar chart',
|
||||
@@ -573,6 +601,9 @@ const translations = {
|
||||
'detail.col_factor': 'Fattore',
|
||||
'detail.col_points': 'Punti',
|
||||
'detail.col_result': 'Risultato',
|
||||
'detail.finalize_title': 'Finalizza il test',
|
||||
'detail.share_title': 'Condividi il test come link',
|
||||
'patlist.of': 'di',
|
||||
|
||||
'savedialog.title': 'Modifiche non salvate',
|
||||
'savedialog.message': 'Hai modifiche non salvate. Vuoi salvarle?',
|
||||
@@ -638,6 +669,14 @@ const translations = {
|
||||
'training.history': 'Cronologia piani',
|
||||
'training.no_history': 'Nessun piano archiviato disponibile.',
|
||||
|
||||
'achievement.not_passed': 'Non superato',
|
||||
'achievement.participation': 'Partecipazione',
|
||||
'radar.no_data': 'Nessun profilo radar disponibile.',
|
||||
'radar.title': 'Profilo Radar (Effettivo/Obiettivo)',
|
||||
'radar.aria': 'Profilo radar',
|
||||
'trend.no_data': 'Sono necessari almeno 2 assessment per il trend.',
|
||||
'trend.title': 'Trend (Punti totali)',
|
||||
'trend.aria': 'Grafico trend',
|
||||
'gapchart.no_data': 'Nessun grafico gap disponibile.',
|
||||
'gapchart.title': 'Grafico Gap (Priorità)',
|
||||
'gapchart.aria': 'Grafico a barre gap',
|
||||
@@ -799,6 +838,9 @@ const translations = {
|
||||
'detail.col_factor': 'Factor',
|
||||
'detail.col_points': 'Puntos',
|
||||
'detail.col_result': 'Resultado',
|
||||
'detail.finalize_title': 'Finalizar test',
|
||||
'detail.share_title': 'Compartir test como enlace',
|
||||
'patlist.of': 'de',
|
||||
|
||||
'savedialog.title': 'Cambios sin guardar',
|
||||
'savedialog.message': 'Tienes cambios sin guardar. ¿Deseas guardarlos?',
|
||||
@@ -864,6 +906,14 @@ const translations = {
|
||||
'training.history': 'Historial de planes',
|
||||
'training.no_history': 'No hay planes archivados disponibles.',
|
||||
|
||||
'achievement.not_passed': 'No aprobado',
|
||||
'achievement.participation': 'Participación',
|
||||
'radar.no_data': 'No hay perfil radar disponible.',
|
||||
'radar.title': 'Perfil Radar (Real/Objetivo)',
|
||||
'radar.aria': 'Perfil radar',
|
||||
'trend.no_data': 'Se necesitan al menos 2 evaluaciones para la tendencia.',
|
||||
'trend.title': 'Tendencia (Puntos totales)',
|
||||
'trend.aria': 'Gráfico de tendencia',
|
||||
'gapchart.no_data': 'No hay gráfico de gap disponible.',
|
||||
'gapchart.title': 'Gráfico de Gap (Prioridad)',
|
||||
'gapchart.aria': 'Gráfico de barras de gap',
|
||||
@@ -1025,6 +1075,9 @@ const translations = {
|
||||
'detail.col_factor': 'Fator',
|
||||
'detail.col_points': 'Pontos',
|
||||
'detail.col_result': 'Resultado',
|
||||
'detail.finalize_title': 'Finalizar teste',
|
||||
'detail.share_title': 'Partilhar teste como link',
|
||||
'patlist.of': 'de',
|
||||
|
||||
'savedialog.title': 'Alterações não guardadas',
|
||||
'savedialog.message': 'Tem alterações não guardadas. Deseja guardá-las?',
|
||||
@@ -1090,6 +1143,14 @@ const translations = {
|
||||
'training.history': 'Histórico de planos',
|
||||
'training.no_history': 'Sem planos arquivados disponíveis.',
|
||||
|
||||
'achievement.not_passed': 'Não aprovado',
|
||||
'achievement.participation': 'Participação',
|
||||
'radar.no_data': 'Sem perfil radar disponível.',
|
||||
'radar.title': 'Perfil Radar (Real/Objetivo)',
|
||||
'radar.aria': 'Perfil radar',
|
||||
'trend.no_data': 'São necessárias pelo menos 2 avaliações para a tendência.',
|
||||
'trend.title': 'Tendência (Pontos totais)',
|
||||
'trend.aria': 'Gráfico de tendência',
|
||||
'gapchart.no_data': 'Sem gráfico de gap disponível.',
|
||||
'gapchart.title': 'Gráfico de Gap (Prioridade)',
|
||||
'gapchart.aria': 'Gráfico de barras de gap',
|
||||
|
||||
133
src/lib/assessmentShareService.test.js
Normal file
133
src/lib/assessmentShareService.test.js
Normal file
@@ -0,0 +1,133 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const importService = async (supabaseMock) => {
|
||||
vi.resetModules();
|
||||
vi.doMock('./supabaseClient', () => ({
|
||||
supabase: supabaseMock
|
||||
}));
|
||||
return import('./assessmentShareService');
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
vi.doUnmock('./supabaseClient');
|
||||
delete globalThis.window;
|
||||
});
|
||||
|
||||
describe('assessmentShareService', () => {
|
||||
it('creates a share link with the current browser URL', async () => {
|
||||
globalThis.window = {
|
||||
location: {
|
||||
origin: 'https://pat.example.com',
|
||||
pathname: '/manager'
|
||||
}
|
||||
};
|
||||
|
||||
const rpc = vi.fn().mockResolvedValue({
|
||||
data: { share_token: 'token-123' },
|
||||
error: null
|
||||
});
|
||||
|
||||
const { createAssessmentShareLink } = await importService({ rpc });
|
||||
const result = await createAssessmentShareLink({ id: 'assessment-1' });
|
||||
|
||||
expect(rpc).toHaveBeenCalledWith('create_or_get_assessment_share', {
|
||||
p_assessment_id: 'assessment-1'
|
||||
});
|
||||
expect(result).toEqual({
|
||||
token: 'token-123',
|
||||
url: 'https://pat.example.com/manager?share=token-123'
|
||||
});
|
||||
});
|
||||
|
||||
it('maps profile visibility RPC errors to a user-facing German message', async () => {
|
||||
const rpc = vi.fn().mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'Tests are hidden in the profile' }
|
||||
});
|
||||
|
||||
const { createAssessmentShareLink } = await importService({ rpc });
|
||||
|
||||
await expect(createAssessmentShareLink({ id: 'assessment-1' })).rejects.toThrow(
|
||||
'Deine Tests sind im Profil aktuell nicht sichtbar.'
|
||||
);
|
||||
});
|
||||
|
||||
it('lists existing shares by assessment id', async () => {
|
||||
const inMock = vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ assessment_id: 'a1', share_token: 'share-a1' },
|
||||
{ assessment_id: 'a2', share_token: 'share-a2' }
|
||||
],
|
||||
error: null
|
||||
});
|
||||
const selectMock = vi.fn(() => ({ in: inMock }));
|
||||
const fromMock = vi.fn(() => ({ select: selectMock }));
|
||||
|
||||
const { listAssessmentShares } = await importService({ from: fromMock });
|
||||
const result = await listAssessmentShares(['a1', 'a2']);
|
||||
|
||||
expect(fromMock).toHaveBeenCalledWith('assessment_shares');
|
||||
expect(selectMock).toHaveBeenCalledWith('assessment_id, share_token');
|
||||
expect(inMock).toHaveBeenCalledWith('assessment_id', ['a1', 'a2']);
|
||||
expect(result).toEqual({
|
||||
a1: 'share-a1',
|
||||
a2: 'share-a2'
|
||||
});
|
||||
});
|
||||
|
||||
it('hydrates shared assessments and recalculates derived exercise values', async () => {
|
||||
const rpc = vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 'shared-1',
|
||||
pat_type: 'PAT 2',
|
||||
datum: '2026-03-28',
|
||||
name: 'Max Mustermann',
|
||||
exercises: [
|
||||
{
|
||||
name: 'Gerade Einsteiger',
|
||||
soll: 6,
|
||||
faktor: 10,
|
||||
values: ['2', '4', '']
|
||||
},
|
||||
{
|
||||
name: 'Split Drill',
|
||||
soll: 4,
|
||||
faktor: 5,
|
||||
subA: ['1', '3'],
|
||||
subB: ['5', ''],
|
||||
subLabels: ['A', 'B']
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
error: null
|
||||
});
|
||||
|
||||
const { getSharedAssessment } = await importService({ rpc });
|
||||
const result = await getSharedAssessment('share-token');
|
||||
|
||||
expect(rpc).toHaveBeenCalledWith('get_shared_assessment', {
|
||||
p_share_token: 'share-token'
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
id: 'shared-1',
|
||||
patType: 'PAT 2',
|
||||
datum: '2026-03-28',
|
||||
name: 'Max Mustermann'
|
||||
});
|
||||
expect(result.exercises[0]).toMatchObject({
|
||||
name: 'Gerade Einsteiger',
|
||||
durchschnitt: 3,
|
||||
points: 30
|
||||
});
|
||||
expect(result.exercises[1]).toMatchObject({
|
||||
name: 'Split Drill',
|
||||
durchschnitt: 3,
|
||||
points: 15,
|
||||
subLabels: ['A', 'B']
|
||||
});
|
||||
});
|
||||
});
|
||||
171
src/lib/trainingPlanService.test.js
Normal file
171
src/lib/trainingPlanService.test.js
Normal file
@@ -0,0 +1,171 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const importService = async (supabaseMock) => {
|
||||
vi.resetModules();
|
||||
vi.doMock('./supabaseClient', () => ({
|
||||
supabase: supabaseMock
|
||||
}));
|
||||
return import('./trainingPlanService');
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
vi.doUnmock('./supabaseClient');
|
||||
});
|
||||
|
||||
describe('trainingPlanService', () => {
|
||||
it('maps sessions to the RPC shape and normalizes task defaults', async () => {
|
||||
const { mapSessionToRpc } = await importService(null);
|
||||
|
||||
const result = mapSessionToRpc({
|
||||
week_no: '2',
|
||||
session_no: '3',
|
||||
main_exercise: 'Break-Aufbau',
|
||||
secondary_exercises: ['Safety', 'Pattern Play'],
|
||||
tasks: [
|
||||
{
|
||||
type: 'main',
|
||||
title: 'Hauptblock',
|
||||
durationMin: '15',
|
||||
repetitions: 12,
|
||||
intensity: 'hoch',
|
||||
instructions: 'Volle Konzentration'
|
||||
},
|
||||
{
|
||||
durationMin: '',
|
||||
instructions: null
|
||||
}
|
||||
],
|
||||
state: 'done',
|
||||
notes: 'Saubere Linie'
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
weekNo: 2,
|
||||
sessionNo: 3,
|
||||
mainExercise: 'Break-Aufbau',
|
||||
secondaryExercises: ['Safety', 'Pattern Play'],
|
||||
tasks: [
|
||||
{
|
||||
type: 'main',
|
||||
title: 'Hauptblock',
|
||||
durationMin: 15,
|
||||
repetitions: 12,
|
||||
intensity: 'hoch',
|
||||
instructions: 'Volle Konzentration'
|
||||
},
|
||||
{
|
||||
type: 'task',
|
||||
title: '',
|
||||
durationMin: 0,
|
||||
repetitions: null,
|
||||
intensity: null,
|
||||
instructions: ''
|
||||
}
|
||||
],
|
||||
state: 'done',
|
||||
notes: 'Saubere Linie'
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error result when supabase is not configured', async () => {
|
||||
const { saveTrainingPlanWithSessions } = await importService(null);
|
||||
|
||||
const result = await saveTrainingPlanWithSessions({
|
||||
patType: 'PAT 1',
|
||||
analysisSnapshot: {},
|
||||
durationWeeks: 4,
|
||||
sessionsPerWeek: 2,
|
||||
sessions: []
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error.message).toBe('Supabase ist nicht konfiguriert.');
|
||||
});
|
||||
|
||||
it('sends a normalized RPC payload and returns the created plan id', async () => {
|
||||
const rpc = vi.fn().mockResolvedValue({
|
||||
data: 'plan-123',
|
||||
error: null
|
||||
});
|
||||
|
||||
const { saveTrainingPlanWithSessions } = await importService({ rpc });
|
||||
const result = await saveTrainingPlanWithSessions({
|
||||
patType: 'PAT 3',
|
||||
analysisSnapshot: { score: 87 },
|
||||
durationWeeks: '6',
|
||||
sessionsPerWeek: '4',
|
||||
sessions: [
|
||||
{
|
||||
weekNo: '1',
|
||||
sessionNo: '2',
|
||||
mainExercise: 'Lochen lang',
|
||||
secondaryExercises: ['Positionsspiel', 'Safety'],
|
||||
tasks: [
|
||||
{
|
||||
type: 'main',
|
||||
title: 'Serie',
|
||||
durationMin: '25',
|
||||
repetitions: 20,
|
||||
intensity: 'mittel',
|
||||
instructions: 'Tempo konstant'
|
||||
}
|
||||
],
|
||||
state: 'open',
|
||||
notes: 'Tag 1'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(rpc).toHaveBeenCalledWith('create_training_plan_with_sessions', {
|
||||
p_pat_type: 'PAT 3',
|
||||
p_analysis_snapshot: { score: 87 },
|
||||
p_duration_weeks: 6,
|
||||
p_sessions_per_week: 4,
|
||||
p_sessions: [
|
||||
{
|
||||
weekNo: 1,
|
||||
sessionNo: 2,
|
||||
mainExercise: 'Lochen lang',
|
||||
secondaryExercises: ['Positionsspiel', 'Safety'],
|
||||
tasks: [
|
||||
{
|
||||
type: 'main',
|
||||
title: 'Serie',
|
||||
durationMin: 25,
|
||||
repetitions: 20,
|
||||
intensity: 'mittel',
|
||||
instructions: 'Tempo konstant'
|
||||
}
|
||||
],
|
||||
state: 'open',
|
||||
notes: 'Tag 1'
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
planId: 'plan-123'
|
||||
});
|
||||
});
|
||||
|
||||
it('wraps RPC failures into a non-throwing error result', async () => {
|
||||
const rpc = vi.fn().mockResolvedValue({
|
||||
data: null,
|
||||
error: new Error('Datenbank nicht erreichbar')
|
||||
});
|
||||
|
||||
const { saveTrainingPlanWithSessions } = await importService({ rpc });
|
||||
const result = await saveTrainingPlanWithSessions({
|
||||
patType: 'PAT 2',
|
||||
analysisSnapshot: {},
|
||||
durationWeeks: 2,
|
||||
sessionsPerWeek: 2,
|
||||
sessions: []
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error.message).toBe('Datenbank nicht erreichbar');
|
||||
});
|
||||
});
|
||||
65
src/utils/patCalculations.test.js
Normal file
65
src/utils/patCalculations.test.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
calculateExercise,
|
||||
calculateTotal,
|
||||
getAchievement,
|
||||
getPatTypeColor
|
||||
} from './patCalculations';
|
||||
|
||||
describe('patCalculations', () => {
|
||||
it('calculates average and points for regular exercises and ignores invalid values', () => {
|
||||
const result = calculateExercise({
|
||||
values: ['2', '4', 'x', ''],
|
||||
faktor: 10
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
durchschnitt: 3,
|
||||
points: 30
|
||||
});
|
||||
});
|
||||
|
||||
it('calculates average and points across split exercise rows', () => {
|
||||
const result = calculateExercise({
|
||||
subA: ['1', '3'],
|
||||
subB: ['5', ''],
|
||||
faktor: 4
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
durchschnitt: 3,
|
||||
points: 12
|
||||
});
|
||||
});
|
||||
|
||||
it('sums points across exercises and treats missing values as zero', () => {
|
||||
const total = calculateTotal([
|
||||
{ points: 120 },
|
||||
{ points: 0 },
|
||||
{},
|
||||
{ points: 35 }
|
||||
]);
|
||||
|
||||
expect(total).toBe(155);
|
||||
});
|
||||
|
||||
it('returns configured colors and a gray fallback for unknown PAT types', () => {
|
||||
expect(getPatTypeColor('PAT 1')).toBe('bg-blue-100 text-blue-800');
|
||||
expect(getPatTypeColor('Unbekannt')).toBe('bg-gray-100 text-gray-800');
|
||||
});
|
||||
|
||||
it('returns the correct achievement by threshold and falls back to PAT Start rules', () => {
|
||||
expect(getAchievement('PAT 3', 850)).toMatchObject({
|
||||
name: '9. Gold',
|
||||
subtitle: 'internationaler Topsportler'
|
||||
});
|
||||
|
||||
expect(getAchievement('PAT 3', 549)).toMatchObject({
|
||||
name: 'Nicht bestanden'
|
||||
});
|
||||
|
||||
expect(getAchievement('Nicht definiert', 800)).toMatchObject({
|
||||
name: '1. Weiss'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,19 @@ import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
chunkSizeWarningLimit: 600,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'vendor-react': ['react', 'react-dom'],
|
||||
'vendor-supabase': ['@supabase/supabase-js'],
|
||||
'vendor-ui': ['lucide-react', 'country-flag-icons'],
|
||||
'vendor-xlsx': ['xlsx'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.{js,jsx}']
|
||||
|
||||
Reference in New Issue
Block a user