Compare commits

...

2 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
9 changed files with 811 additions and 36 deletions

3
.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

View File

@@ -1,7 +1,5 @@
Offen Offen
Rangliste Rangliste
code splitting
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
@@ -12,7 +10,7 @@ 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 Übersetzen in gängigge Sprachen Englisch Italienisch Spanisch Portugisisch
code splitting
Termninal Termninal
vercel deploy vercel deploy

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

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