Compare commits

..

4 Commits

Author SHA1 Message Date
Ashikagi
0acece98dc Unit Tests
All checks were successful
Vercel Production Deployment / Deploy-Production (push) Successful in 1m1s
2026-03-28 15:54:02 +01:00
Ashikagi
6b2d0024ed ideen dokument aktualisiert 2026-03-24 00:01:46 +01:00
Ashikagi
35e632e9e0 Merge branch 'main' of http://172.19.0.32:3000/root/Pat-Manager
All checks were successful
Vercel Production Deployment / Deploy-Production (push) Successful in 50s
2026-03-23 23:59:03 +01:00
Ashikagi
fe27ec2beb code spliting und language 2 2026-03-23 23:58:21 +01:00
27 changed files with 1145 additions and 384 deletions

View File

@@ -1,7 +1,10 @@
{ {
"permissions": { "permissions": {
"allow": [ "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:*)"
] ]
} }
} }

1
.env
View File

@@ -1,2 +1,3 @@
VITE_SUPABASE_URL=https://vzudjibddwcvfdnyvhhs.supabase.co 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

View File

@@ -1,6 +1,10 @@
VITE_SUPABASE_URL=https://your-project.supabase.co VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key 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 RESEND_API_KEY=your-resend-api-key
REMINDER_FROM_EMAIL=PAT Stats <noreply@example.com> REMINDER_FROM_EMAIL=PAT Stats <noreply@example.com>
REMINDER_APP_URL=https://your-app.example.com REMINDER_APP_URL=https://your-app.example.com

1
.gitignore vendored
View File

@@ -1 +1,2 @@
node_modules node_modules
dist

View File

@@ -1,7 +1,5 @@
Offen Offen
Rangliste 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 Admin Oberfläche (anzahl user mit Mail Adressen/ anelegten Tests / anbindung von Werbung Links und rechts von den Test Übersichten
2fa 2fa
@@ -11,11 +9,12 @@ Erledigt:
Final Speichern do Final Speichern do
Max Punkte bei Bewertung PAT1 Max Punkte bei Bewertung PAT1
User Profil mit Land und Sprach Auswahl User Profil mit Land und Sprach Auswahl
Übersetzen in gängigge Sprachen Englisch Italienisch Spanisch Portugisisch
code splitting
Termninal Termninal
vercel deploy vercel deploy
git add . git add .
git commit -m "BenutzerProfil " git commit -m "Übersetzung Teil 1"
git push git push

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

7
dist/index.html vendored
View File

@@ -5,8 +5,11 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PAT Test Manager</title> <title>PAT Test Manager</title>
<script type="module" crossorigin src="/assets/index-Cg2UxsRL.js"></script> <script type="module" crossorigin src="/assets/index-Bp6S7Gpx.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BaBfIFek.css"> <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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

285
package-lock.json generated
View File

@@ -25,6 +25,7 @@
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"supabase": "^2.83.0",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"vite": "^5.4.2", "vite": "^5.4.2",
"vitest": "^1.6.1" "vitest": "^1.6.1"
@@ -725,6 +726,19 @@
"node": ">=12" "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": { "node_modules/@jest/schemas": {
"version": "29.6.3", "version": "29.6.3",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
@@ -1482,6 +1496,16 @@
"node": ">=0.8" "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": { "node_modules/all": {
"version": "0.0.0", "version": "0.0.0",
"resolved": "https://registry.npmjs.org/all/-/all-0.0.0.tgz", "resolved": "https://registry.npmjs.org/all/-/all-0.0.0.tgz",
@@ -1597,6 +1621,23 @@
"baseline-browser-mapping": "dist/cli.js" "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": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -1801,6 +1842,26 @@
"node": ">= 6" "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": { "node_modules/codepage": {
"version": "1.15.0", "version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
@@ -1909,6 +1970,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2115,6 +2186,30 @@
"reusify": "^1.0.4" "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": { "node_modules/fflate": {
"version": "0.8.2", "version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
@@ -2134,6 +2229,19 @@
"node": ">=8" "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": { "node_modules/frac": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
@@ -2255,6 +2363,20 @@
"node": ">=8.0.0" "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": { "node_modules/human-signals": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
@@ -2562,6 +2684,29 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/mlly": {
"version": "1.8.0", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", "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": "^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": { "node_modules/node-releases": {
"version": "2.0.27", "version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -2647,6 +2832,16 @@
"node": ">=0.10.0" "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": { "node_modules/npm-run-path": {
"version": "5.3.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", "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": "^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": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -3223,6 +3428,16 @@
"pify": "^2.3.0" "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": { "node_modules/readdirp": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -3524,6 +3739,26 @@
"node": ">=16 || 14 >=14.17" "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": { "node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "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": ">=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": { "node_modules/text-segmentation": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", "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": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -3990,6 +4262,19 @@
"node": ">=0.8" "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": { "node_modules/ws": {
"version": "8.18.3", "version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",

View File

@@ -7,7 +7,9 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "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", "send-finalization-reminders": "node scripts/sendFinalizationReminders.js",
"test": "vitest run" "test": "vitest run"
}, },
@@ -29,6 +31,7 @@
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"supabase": "^2.83.0",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"vite": "^5.4.2", "vite": "^5.4.2",
"vitest": "^1.6.1" "vitest": "^1.6.1"

View File

@@ -1,10 +1,15 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Lightweight migration runner for Supabase. * 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: * Usage:
* - SUPABASE_DB_URL or DATABASE_URL: postgres connection string (service role / connection string) * 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 fs from 'fs';
import path from 'path'; import path from 'path';
@@ -15,15 +20,43 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const migrationsDir = path.resolve(__dirname, '..', 'supabase', 'migrations'); 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) { 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); process.exit(1);
} }
if (!fs.existsSync(migrationsDir)) { if (!fs.existsSync(migrationsDir)) {
console.error(`Migrations directory not found: ${migrationsDir}`); console.error(`Migrations directory not found: ${migrationsDir}`);
process.exit(1); process.exit(1);
} }
@@ -32,38 +65,127 @@ const pool = new Pool({ connectionString });
async function ensureMigrationsTable(client) { async function ensureMigrationsTable(client) {
await client.query(` await client.query(`
create table if not exists public.schema_migrations ( create table if not exists public.schema_migrations (
id serial primary key, id serial primary key,
filename text not null unique, filename text not null unique,
applied_at timestamptz not null default now() applied_at timestamptz not null default now()
); );
`); `);
} }
async function getApplied(client) { async function getApplied(client) {
const { rows } = await client.query('select filename from public.schema_migrations'); const { rows } = await client.query(
return new Set(rows.map((r) => r.filename)); 'select filename, applied_at from public.schema_migrations order by filename'
);
return rows;
} }
function listMigrations() { function listMigrationFiles() {
return fs return fs
.readdirSync(migrationsDir) .readdirSync(migrationsDir)
.filter((file) => file.endsWith('.sql')) .filter((f) => f.endsWith('.sql'))
.sort(); .sort();
} }
async function applyMigration(client, filename) { async function applyMigration(client, filename) {
const filePath = path.join(migrationsDir, filename); const filePath = path.join(migrationsDir, filename);
const sql = fs.readFileSync(filePath, 'utf8'); const sql = fs.readFileSync(filePath, 'utf8');
console.log(`Applying migration: ${filename}`); console.log(`Applying: ${filename}`);
await client.query('begin'); await client.query('begin');
try { try {
await client.query(sql); 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'); await client.query('commit');
console.log(`✓ Applied ${filename}`); console.log(` ✓ Done: ${filename}`);
} catch (err) { } catch (err) {
await client.query('rollback'); 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; throw err;
} }
} }
@@ -71,20 +193,13 @@ async function applyMigration(client, filename) {
async function run() { async function run() {
const client = await pool.connect(); const client = await pool.connect();
try { try {
await ensureMigrationsTable(client); if (command === '--status') {
const applied = await getApplied(client); await cmdStatus(client);
const migrations = listMigrations(); } else if (command === '--rollback') {
await cmdRollback(client);
const pending = migrations.filter((m) => !applied.has(m)); } else {
if (pending.length === 0) { await cmdMigrate(client);
console.log('No pending migrations.');
return;
} }
for (const migration of pending) {
await applyMigration(client, migration);
}
console.log('All pending migrations applied.');
} finally { } finally {
client.release(); client.release();
await pool.end(); await pool.end();
@@ -92,6 +207,6 @@ async function run() {
} }
run().catch((err) => { run().catch((err) => {
console.error('Migration run failed:', err); console.error('\n❌ Migration runner failed:', err.message);
process.exit(1); process.exit(1);
}); });

View File

@@ -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 { Moon, Sun } from 'lucide-react'
import PatTestManager from './components/PatTestManager'
import AuthPanel from './components/AuthPanel'
import { supabase } from './lib/supabaseClient' 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 { useUserProfile } from './hooks/useUserProfile'
import { getCountryLabel, getCountryOption } from './data/countries' import { getCountryLabel, getCountryOption } from './data/countries'
import CountryFlag from './components/CountryFlag' import CountryFlag from './components/CountryFlag'
import { LanguageProvider } from './i18n/LanguageContext' import { LanguageProvider } from './i18n/LanguageContext'
import { getTranslator } from './i18n/translations' 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 = () => { const readShareToken = () => {
if (typeof window === 'undefined') return '' if (typeof window === 'undefined') return ''
return new URLSearchParams(window.location.search).get('share') || '' return new URLSearchParams(window.location.search).get('share') || ''
@@ -156,7 +163,9 @@ function App() {
if (shareToken) { if (shareToken) {
return ( return (
<LanguageProvider language={language}> <LanguageProvider language={language}>
<SharedAssessmentPage shareToken={shareToken} themeToggle={renderThemeToggle()} /> <Suspense fallback={<PageSpinner />}>
<SharedAssessmentPage shareToken={shareToken} themeToggle={renderThemeToggle()} />
</Suspense>
</LanguageProvider> </LanguageProvider>
) )
} }
@@ -197,7 +206,9 @@ function App() {
if (showAuth) { if (showAuth) {
return ( return (
<LanguageProvider language={language}> <LanguageProvider language={language}>
<AuthPanel onAuth={setSession} themeToggle={renderThemeToggle()} onBack={() => setShowAuth(false)} /> <Suspense fallback={<PageSpinner />}>
<AuthPanel onAuth={setSession} themeToggle={renderThemeToggle()} onBack={() => setShowAuth(false)} />
</Suspense>
</LanguageProvider> </LanguageProvider>
) )
} }
@@ -205,23 +216,27 @@ function App() {
if (publicPath === '/datenschutz' || publicPath === '/impressum' || publicPath === '/kontakt') { if (publicPath === '/datenschutz' || publicPath === '/impressum' || publicPath === '/kontakt') {
return ( return (
<LanguageProvider language={language}> <LanguageProvider language={language}>
<PublicInfoPage <Suspense fallback={<PageSpinner />}>
path={publicPath} <PublicInfoPage
onGetStarted={() => setShowAuth(true)} path={publicPath}
onNavigate={navigatePublic} onGetStarted={() => setShowAuth(true)}
themeToggle={renderThemeToggle()} onNavigate={navigatePublic}
/> themeToggle={renderThemeToggle()}
/>
</Suspense>
</LanguageProvider> </LanguageProvider>
) )
} }
return ( return (
<LanguageProvider language={language}> <LanguageProvider language={language}>
<Landing <Suspense fallback={<PageSpinner />}>
onGetStarted={() => setShowAuth(true)} <Landing
onNavigate={navigatePublic} onGetStarted={() => setShowAuth(true)}
themeToggle={renderThemeToggle()} onNavigate={navigatePublic}
/> themeToggle={renderThemeToggle()}
/>
</Suspense>
</LanguageProvider> </LanguageProvider>
) )
} }
@@ -306,27 +321,29 @@ function App() {
</div> </div>
</header> </header>
{activeTab !== 'profile' && ( <Suspense fallback={<PageSpinner />}>
<PatTestManager {activeTab !== 'profile' && (
user={session?.user} <PatTestManager
activeTab={activeTab} user={session?.user}
onEditingStateChange={setIsEditingAssessment} activeTab={activeTab}
testsVisible={Boolean(profile?.testsVisible)} onEditingStateChange={setIsEditingAssessment}
isProfileLoading={profileLoading} testsVisible={Boolean(profile?.testsVisible)}
/> isProfileLoading={profileLoading}
)} />
)}
{activeTab === 'profile' && ( {activeTab === 'profile' && (
<ProfileTab <ProfileTab
session={session} session={session}
profile={profile} profile={profile}
loading={profileLoading} loading={profileLoading}
error={profileError} error={profileError}
onSave={saveProfile} onSave={saveProfile}
onDelete={deleteProfileData} onDelete={deleteProfileData}
onSessionChange={setSession} onSessionChange={setSession}
/> />
)} )}
</Suspense>
</div> </div>
</LanguageProvider> </LanguageProvider>
) )

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { useTranslation } from '../../i18n/LanguageContext';
const polarToCartesian = (centerX, centerY, radius, angle) => ({ const polarToCartesian = (centerX, centerY, radius, angle) => ({
x: centerX + radius * Math.cos(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); const truncate = (value, maxLength = 16) => (value.length > maxLength ? `${value.slice(0, maxLength - 1)}` : value);
export default function RadarChart({ radarSeries = [] }) { export default function RadarChart({ radarSeries = [] }) {
const t = useTranslation();
const data = radarSeries.slice(0, 6); const data = radarSeries.slice(0, 6);
if (!data.length) { if (!data.length) {
return ( 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"> <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> </div>
); );
} }
@@ -40,8 +42,8 @@ export default function RadarChart({ radarSeries = [] }) {
return ( 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"> <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> <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="Radarprofil"> <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) => ( {[0.25, 0.5, 0.75, 1].map((level) => (
<polygon <polygon
key={level} key={level}

View File

@@ -1,10 +1,13 @@
import React from 'react'; import React from 'react';
import { useTranslation } from '../../i18n/LanguageContext';
export default function TrendChart({ trendSeries = [] }) { export default function TrendChart({ trendSeries = [] }) {
const t = useTranslation();
if (trendSeries.length < 2) { if (trendSeries.length < 2) {
return ( 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"> <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> </div>
); );
} }
@@ -35,8 +38,8 @@ export default function TrendChart({ trendSeries = [] }) {
return ( return (
<div className="rounded-2xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-4 shadow-sm"> <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> <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="Trenddiagramm"> <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} 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" /> <line x1={left} y1={top + innerHeight} x2={left + innerWidth} y2={top + innerHeight} className="stroke-gray-300 dark:stroke-gray-700" strokeWidth="1" />

View File

@@ -3,7 +3,8 @@ import { CheckCircle2, FileDown, Lock, Save, Share2 } from 'lucide-react';
import ExerciseInput from './ExerciseInput'; import ExerciseInput from './ExerciseInput';
import SaveDialog from './SaveDialog'; import SaveDialog from './SaveDialog';
import { downloadAssessmentPdf } from '../../utils/pdfExport'; import { downloadAssessmentPdf } from '../../utils/pdfExport';
import { useTranslation } from '../../i18n/LanguageContext'; import { useTranslation, useLanguage } from '../../i18n/LanguageContext';
import { translateExerciseName } from '../../i18n/exerciseNames';
const PatDetail = ({ const PatDetail = ({
currentAssessment, currentAssessment,
@@ -32,11 +33,18 @@ const PatDetail = ({
onRevokeShare onRevokeShare
}) => { }) => {
const t = useTranslation(); const t = useTranslation();
const language = useLanguage();
if (!currentAssessment) return null; if (!currentAssessment) return null;
const totalPoints = calculateTotal(); const totalPoints = calculateTotal();
const achievement = getAchievement(currentAssessment?.patType, totalPoints); 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 () => { const handleDownloadPdf = async () => {
await downloadAssessmentPdf({ await downloadAssessmentPdf({
assessment: currentAssessment, assessment: currentAssessment,
@@ -104,7 +112,7 @@ const PatDetail = ({
<button <button
onClick={onFinalize} onClick={onFinalize}
disabled={!canFinalize} 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 ${ className={`px-4 py-2 rounded-lg transition flex items-center gap-2 text-sm ${
canFinalize canFinalize
? 'bg-emerald-700 text-white hover:bg-emerald-800' ? 'bg-emerald-700 text-white hover:bg-emerald-800'
@@ -126,7 +134,7 @@ const PatDetail = ({
<button <button
onClick={onShare} onClick={onShare}
disabled={!canShare} 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 ${ className={`px-4 py-2 rounded-lg transition flex items-center gap-2 text-sm ${
canShare canShare
? 'bg-sky-600 text-white hover:bg-sky-700' ? 'bg-sky-600 text-white hover:bg-sky-700'
@@ -191,7 +199,7 @@ const PatDetail = ({
{currentAssessment?.exercises.map((exercise, exIndex) => ( {currentAssessment?.exercises.map((exercise, exIndex) => (
<div key={exIndex} className="mb-6 border-b pb-4 border-gray-200 dark:border-gray-800"> <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} /> <ExerciseInput exercise={exercise} exIndex={exIndex} onChange={onUpdateExercise} disabled={isReadOnly} />
@@ -222,7 +230,7 @@ const PatDetail = ({
</div> </div>
<div className={`mt-4 px-6 py-4 rounded-lg ${achievement.color} ${achievement.text} text-center`}> <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>} {achievement.subtitle && <p className="text-lg mt-2">{achievement.subtitle}</p>}
</div> </div>
</div> </div>

View File

@@ -218,7 +218,7 @@ const PatList = ({
<h2 className="text-lg font-bold text-gray-800 dark:text-gray-100">{title}</h2> <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"> <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{t('patlist.count', { count: entries.length })} {t('patlist.count', { count: entries.length })}
{entries.length !== totalEntries ? ` von ${totalEntries}` : ''} {entries.length !== totalEntries ? ` ${t('patlist.of')} ${totalEntries}` : ''}
</p> </p>
</div> </div>

View File

@@ -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 { useTranslation } from '../i18n/LanguageContext';
import PatList from './PatList/PatList'; import PatList from './PatList/PatList';
import PatDetail from './PatDetail/PatDetail'; import PatDetail from './PatDetail/PatDetail';
import AnalysisTab from './Analysis/AnalysisTab'; const AnalysisTab = lazy(() => import('./Analysis/AnalysisTab'));
import { patTypes } from '../data/patTypes'; import { patTypes } from '../data/patTypes';
import { useAssessments } from '../hooks/useAssessments'; import { useAssessments } from '../hooks/useAssessments';
import { getAchievement, getPatTypeColor } from '../utils/patCalculations'; import { getAchievement, getPatTypeColor } from '../utils/patCalculations';
@@ -309,13 +309,15 @@ export default function PatTestManager({
if (activeTab === 'analysis') { if (activeTab === 'analysis') {
return ( return (
<AnalysisTab <Suspense fallback={<div className="min-h-screen flex items-center justify-center text-gray-500 dark:text-gray-400">Laden</div>}>
assessments={visibleAssessments} <AnalysisTab
selectedPatType={selectedPatType} assessments={visibleAssessments}
onPatTypeChange={setSelectedPatType} selectedPatType={selectedPatType}
patTypes={visiblePatTypes} onPatTypeChange={setSelectedPatType}
user={user} patTypes={visiblePatTypes}
/> user={user}
/>
</Suspense>
); );
} }

View File

@@ -3,6 +3,7 @@ import { Check, ChevronDown } from 'lucide-react';
import { countryOptions, getCountryLabel, getCountryOption } from '../data/countries'; import { countryOptions, getCountryLabel, getCountryOption } from '../data/countries';
import CountryFlag from './CountryFlag'; import CountryFlag from './CountryFlag';
import { useTranslation } from '../i18n/LanguageContext'; import { useTranslation } from '../i18n/LanguageContext';
import { getTranslator } from '../i18n/translations';
const emptyStatus = { const emptyStatus = {
message: '', message: '',
@@ -81,6 +82,8 @@ export default function ProfileTab({
setStatus(emptyStatus); setStatus(emptyStatus);
const result = await onSave(formValues); 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?.ok) {
if (result.session) { if (result.session) {
@@ -89,14 +92,14 @@ export default function ProfileTab({
setStatus({ setStatus({
message: result.emailUpdateRequested message: result.emailUpdateRequested
? t('profile.saved_email') ? tNew('profile.saved_email')
: t('profile.saved'), : tNew('profile.saved'),
error: '' error: ''
}); });
} else { } else {
setStatus({ setStatus({
message: '', message: '',
error: result?.error?.message || t('profile.error_save') error: result?.error?.message || tNew('profile.error_save')
}); });
} }

View File

@@ -1,18 +1,24 @@
import React, { createContext, useContext, useMemo } from 'react'; 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 }) { 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 ( return (
<LanguageContext.Provider value={t}> <LanguageContext.Provider value={value}>
{children} {children}
</LanguageContext.Provider> </LanguageContext.Provider>
); );
} }
export function useTranslation() { export function useTranslation() {
return useContext(LanguageContext); return useContext(LanguageContext).t;
}
export function useLanguage() {
return useContext(LanguageContext).language;
} }

138
src/i18n/exerciseNames.js Normal file
View 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;
}

View File

@@ -112,6 +112,9 @@ const translations = {
'detail.col_factor': 'Faktor', 'detail.col_factor': 'Faktor',
'detail.col_points': 'Points', 'detail.col_points': 'Points',
'detail.col_result': 'Ergebnis', 'detail.col_result': 'Ergebnis',
'detail.finalize_title': 'Test final abschließen',
'detail.share_title': 'Test als Link teilen',
'patlist.of': 'von',
// ── Speichern-Dialog ───────────────────────────────────────────────────── // ── Speichern-Dialog ─────────────────────────────────────────────────────
'savedialog.title': 'Ungespeicherte Änderungen', 'savedialog.title': 'Ungespeicherte Änderungen',
@@ -182,6 +185,20 @@ const translations = {
'training.history': 'Plan-Historie', 'training.history': 'Plan-Historie',
'training.no_history': 'Keine archivierten Pläne vorhanden.', '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 ──────────────────────────────────────────────────────────── // ── Gap-Chart ────────────────────────────────────────────────────────────
'gapchart.no_data': 'Kein Gap-Chart verfügbar.', 'gapchart.no_data': 'Kein Gap-Chart verfügbar.',
'gapchart.title': 'Gap-Bar-Chart (Priorität)', 'gapchart.title': 'Gap-Bar-Chart (Priorität)',
@@ -347,6 +364,9 @@ const translations = {
'detail.col_factor': 'Factor', 'detail.col_factor': 'Factor',
'detail.col_points': 'Points', 'detail.col_points': 'Points',
'detail.col_result': 'Result', 'detail.col_result': 'Result',
'detail.finalize_title': 'Finalize test',
'detail.share_title': 'Share test as link',
'patlist.of': 'of',
'savedialog.title': 'Unsaved changes', 'savedialog.title': 'Unsaved changes',
'savedialog.message': 'You have unsaved changes. Would you like to save them?', 'savedialog.message': 'You have unsaved changes. Would you like to save them?',
@@ -412,6 +432,14 @@ const translations = {
'training.history': 'Plan history', 'training.history': 'Plan history',
'training.no_history': 'No archived plans available.', '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.no_data': 'No gap chart available.',
'gapchart.title': 'Gap Bar Chart (Priority)', 'gapchart.title': 'Gap Bar Chart (Priority)',
'gapchart.aria': 'Gap bar chart', 'gapchart.aria': 'Gap bar chart',
@@ -573,6 +601,9 @@ const translations = {
'detail.col_factor': 'Fattore', 'detail.col_factor': 'Fattore',
'detail.col_points': 'Punti', 'detail.col_points': 'Punti',
'detail.col_result': 'Risultato', '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.title': 'Modifiche non salvate',
'savedialog.message': 'Hai modifiche non salvate. Vuoi salvarle?', 'savedialog.message': 'Hai modifiche non salvate. Vuoi salvarle?',
@@ -638,6 +669,14 @@ const translations = {
'training.history': 'Cronologia piani', 'training.history': 'Cronologia piani',
'training.no_history': 'Nessun piano archiviato disponibile.', '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.no_data': 'Nessun grafico gap disponibile.',
'gapchart.title': 'Grafico Gap (Priorità)', 'gapchart.title': 'Grafico Gap (Priorità)',
'gapchart.aria': 'Grafico a barre gap', 'gapchart.aria': 'Grafico a barre gap',
@@ -799,6 +838,9 @@ const translations = {
'detail.col_factor': 'Factor', 'detail.col_factor': 'Factor',
'detail.col_points': 'Puntos', 'detail.col_points': 'Puntos',
'detail.col_result': 'Resultado', '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.title': 'Cambios sin guardar',
'savedialog.message': 'Tienes cambios sin guardar. ¿Deseas guardarlos?', 'savedialog.message': 'Tienes cambios sin guardar. ¿Deseas guardarlos?',
@@ -864,6 +906,14 @@ const translations = {
'training.history': 'Historial de planes', 'training.history': 'Historial de planes',
'training.no_history': 'No hay planes archivados disponibles.', '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.no_data': 'No hay gráfico de gap disponible.',
'gapchart.title': 'Gráfico de Gap (Prioridad)', 'gapchart.title': 'Gráfico de Gap (Prioridad)',
'gapchart.aria': 'Gráfico de barras de gap', 'gapchart.aria': 'Gráfico de barras de gap',
@@ -1025,6 +1075,9 @@ const translations = {
'detail.col_factor': 'Fator', 'detail.col_factor': 'Fator',
'detail.col_points': 'Pontos', 'detail.col_points': 'Pontos',
'detail.col_result': 'Resultado', '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.title': 'Alterações não guardadas',
'savedialog.message': 'Tem alterações não guardadas. Deseja guardá-las?', 'savedialog.message': 'Tem alterações não guardadas. Deseja guardá-las?',
@@ -1090,6 +1143,14 @@ const translations = {
'training.history': 'Histórico de planos', 'training.history': 'Histórico de planos',
'training.no_history': 'Sem planos arquivados disponíveis.', '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.no_data': 'Sem gráfico de gap disponível.',
'gapchart.title': 'Gráfico de Gap (Prioridade)', 'gapchart.title': 'Gráfico de Gap (Prioridade)',
'gapchart.aria': 'Gráfico de barras de gap', 'gapchart.aria': 'Gráfico de barras de gap',

View 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']
});
});
});

View 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');
});
});

View 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'
});
});
});

View File

@@ -3,6 +3,19 @@ import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], 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: { test: {
environment: 'node', environment: 'node',
include: ['src/**/*.test.{js,jsx}'] include: ['src/**/*.test.{js,jsx}']