Initial commit

This commit is contained in:
Ashikagi
2026-03-23 20:49:30 +01:00
commit f5338ea3b2
82 changed files with 11979 additions and 0 deletions

2
.env Normal file
View File

@@ -0,0 +1,2 @@
VITE_SUPABASE_URL=https://vzudjibddwcvfdnyvhhs.supabase.co
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZ6dWRqaWJkZHdjdmZkbnl2aGhzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjUwMjk4MDksImV4cCI6MjA4MDYwNTgwOX0.wE8R1mpd2FY6m6d5-Hf3hlCG8OfeCkra6SjUvbt1mD0

6
.env.example Normal file
View File

@@ -0,0 +1,6 @@
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_DB_URL=postgresql://postgres:password@db.your-project.supabase.co:5432/postgres
RESEND_API_KEY=your-resend-api-key
REMINDER_FROM_EMAIL=PAT Stats <noreply@example.com>
REMINDER_APP_URL=https://your-app.example.com

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.vercel
node_modules

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"chatgpt.openOnStartup": true
}

BIN
Screenshot_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

886
Startseite.html Normal file
View File

@@ -0,0 +1,886 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PAT Test Manager | Professionelle Leistungsdiagnostik für Billard</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #0f172a;
--primary-light: #1e293b;
--accent: #3b82f6;
--accent-hover: #2563eb;
--success: #10b981;
--warning: #f59e0b;
--text: #f8fafc;
--text-muted: #94a3b8;
--surface: rgba(30, 41, 59, 0.6);
--border: rgba(148, 163, 184, 0.1);
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--primary);
color: var(--text);
line-height: 1.6;
overflow-x: hidden;
}
/* Animated Background */
.bg-animation {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
background:
radial-gradient(ellipse at 20% 80%, rgba(59, 130, 246, 0.15) 0%, transparent 50%),
radial-gradient(ellipse at 80% 20%, rgba(16, 185, 129, 0.1) 0%, transparent 50%),
radial-gradient(ellipse at 40% 40%, rgba(245, 158, 11, 0.05) 0%, transparent 40%);
}
.grid-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image:
linear-gradient(rgba(148, 163, 184, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 184, 0.03) 1px, transparent 1px);
background-size: 60px 60px;
z-index: -1;
mask-image: linear-gradient(to bottom, transparent, black 20%, black 80%, transparent);
}
/* Navigation */
nav {
position: fixed;
top: 0;
width: 100%;
padding: 1.5rem 5%;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 1000;
background: rgba(15, 23, 42, 0.8);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border);
}
.logo {
font-size: 1.5rem;
font-weight: 800;
background: linear-gradient(135deg, var(--text) 0%, var(--accent) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
display: flex;
align-items: center;
gap: 0.75rem;
}
.logo-icon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, var(--accent), var(--success));
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
-webkit-text-fill-color: white;
font-size: 1.2rem;
}
.nav-links {
display: flex;
gap: 2.5rem;
list-style: none;
}
.nav-links a {
color: var(--text-muted);
text-decoration: none;
font-weight: 500;
transition: color 0.3s;
position: relative;
}
.nav-links a:hover {
color: var(--text);
}
.nav-links a::after {
content: '';
position: absolute;
bottom: -5px;
left: 0;
width: 0;
height: 2px;
background: var(--accent);
transition: width 0.3s;
}
.nav-links a:hover::after {
width: 100%;
}
.nav-cta {
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
color: white;
padding: 0.75rem 1.5rem;
border-radius: 8px;
text-decoration: none;
font-weight: 600;
transition: transform 0.3s, box-shadow 0.3s;
border: none;
cursor: pointer;
}
.nav-cta:hover {
transform: translateY(-2px);
box-shadow: 0 10px 30px rgba(59, 130, 246, 0.3);
}
/* Hero Section */
.hero {
min-height: 100vh;
display: flex;
align-items: center;
padding: 8rem 5% 4rem;
position: relative;
}
.hero-content {
max-width: 1400px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rem;
align-items: center;
}
.hero-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.2);
padding: 0.5rem 1rem;
border-radius: 50px;
font-size: 0.875rem;
color: var(--accent);
margin-bottom: 1.5rem;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.hero h1 {
font-size: clamp(2.5rem, 5vw, 4.5rem);
font-weight: 800;
line-height: 1.1;
margin-bottom: 1.5rem;
letter-spacing: -0.02em;
}
.hero h1 span {
background: linear-gradient(135deg, var(--accent) 0%, var(--success) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.hero p {
font-size: 1.25rem;
color: var(--text-muted);
margin-bottom: 2.5rem;
max-width: 540px;
line-height: 1.7;
}
.hero-buttons {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.btn-primary {
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
color: white;
padding: 1rem 2rem;
border-radius: 12px;
text-decoration: none;
font-weight: 600;
font-size: 1.1rem;
transition: all 0.3s;
display: inline-flex;
align-items: center;
gap: 0.5rem;
box-shadow: 0 4px 20px rgba(59, 130, 246, 0.3);
}
.btn-primary:hover {
transform: translateY(-3px);
box-shadow: 0 10px 40px rgba(59, 130, 246, 0.4);
}
.btn-secondary {
background: transparent;
color: var(--text);
padding: 1rem 2rem;
border-radius: 12px;
text-decoration: none;
font-weight: 600;
font-size: 1.1rem;
border: 1px solid var(--border);
transition: all 0.3s;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn-secondary:hover {
background: var(--surface);
border-color: var(--accent);
}
/* Hero Visual */
.hero-visual {
position: relative;
perspective: 1000px;
}
.dashboard-preview {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 24px;
padding: 2rem;
backdrop-filter: blur(20px);
transform: rotateY(-5deg) rotateX(5deg);
transition: transform 0.5s;
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.05);
}
.dashboard-preview:hover {
transform: rotateY(0deg) rotateX(0deg);
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.preview-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.stat-card {
background: rgba(15, 23, 42, 0.5);
padding: 1.5rem;
border-radius: 16px;
border: 1px solid var(--border);
}
.stat-label {
font-size: 0.875rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--accent);
}
.progress-bar {
height: 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
margin-top: 0.75rem;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--success));
border-radius: 4px;
width: 75%;
animation: fillProgress 2s ease-out;
}
@keyframes fillProgress {
from { width: 0; }
to { width: 75%; }
}
/* Features Section */
.features {
padding: 6rem 5%;
position: relative;
}
.section-header {
text-align: center;
max-width: 800px;
margin: 0 auto 4rem;
}
.section-tag {
display: inline-block;
background: rgba(16, 185, 129, 0.1);
color: var(--success);
padding: 0.5rem 1rem;
border-radius: 50px;
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 1rem;
border: 1px solid rgba(16, 185, 129, 0.2);
}
.section-title {
font-size: clamp(2rem, 4vw, 3rem);
font-weight: 700;
margin-bottom: 1rem;
}
.section-subtitle {
color: var(--text-muted);
font-size: 1.25rem;
}
.features-grid {
max-width: 1400px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
}
.feature-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 20px;
padding: 2.5rem;
transition: all 0.4s;
position: relative;
overflow: hidden;
}
.feature-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
opacity: 0;
transition: opacity 0.4s;
}
.feature-card:hover {
transform: translateY(-5px);
border-color: rgba(59, 130, 246, 0.3);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
}
.feature-card:hover::before {
opacity: 1;
}
.feature-icon {
width: 60px;
height: 60px;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(16, 185, 129, 0.2));
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
margin-bottom: 1.5rem;
border: 1px solid rgba(59, 130, 246, 0.2);
}
.feature-card h3 {
font-size: 1.5rem;
margin-bottom: 1rem;
font-weight: 600;
}
.feature-card p {
color: var(--text-muted);
line-height: 1.7;
}
/* How it Works */
.how-it-works {
padding: 6rem 5%;
background: linear-gradient(to bottom, transparent, rgba(30, 41, 59, 0.3), transparent);
}
.steps-container {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
gap: 2rem;
position: relative;
}
.steps-container::before {
content: '';
position: absolute;
top: 40px;
left: 10%;
right: 10%;
height: 2px;
background: linear-gradient(90deg, var(--accent), var(--success));
opacity: 0.3;
z-index: 0;
}
.step {
flex: 1;
text-align: center;
position: relative;
z-index: 1;
}
.step-number {
width: 80px;
height: 80px;
background: var(--primary);
border: 2px solid var(--accent);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
font-weight: 700;
color: var(--accent);
margin: 0 auto 1.5rem;
position: relative;
box-shadow: 0 0 30px rgba(59, 130, 246, 0.3);
}
.step h3 {
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
.step p {
color: var(--text-muted);
font-size: 0.95rem;
}
/* Stats Section */
.stats-section {
padding: 4rem 5%;
}
.stats-grid {
max-width: 1200px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 2rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 24px;
padding: 3rem;
backdrop-filter: blur(20px);
}
.stat-item {
text-align: center;
padding: 1rem;
}
.stat-item-number {
font-size: 3rem;
font-weight: 800;
background: linear-gradient(135deg, var(--text), var(--accent));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
display: block;
}
.stat-item-label {
color: var(--text-muted);
font-size: 0.95rem;
margin-top: 0.5rem;
}
/* CTA Section */
.cta-section {
padding: 6rem 5%;
text-align: center;
}
.cta-box {
max-width: 900px;
margin: 0 auto;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), rgba(16, 185, 129, 0.1));
border: 1px solid rgba(59, 130, 246, 0.2);
border-radius: 32px;
padding: 4rem;
position: relative;
overflow: hidden;
}
.cta-box::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(59, 130, 246, 0.1) 0%, transparent 70%);
animation: rotate 20s linear infinite;
}
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.cta-content {
position: relative;
z-index: 1;
}
.cta-box h2 {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.cta-box p {
color: var(--text-muted);
font-size: 1.2rem;
margin-bottom: 2rem;
}
/* Footer */
footer {
padding: 3rem 5%;
border-top: 1px solid var(--border);
text-align: center;
color: var(--text-muted);
}
.footer-links {
display: flex;
justify-content: center;
gap: 2rem;
margin-bottom: 1.5rem;
list-style: none;
}
.footer-links a {
color: var(--text-muted);
text-decoration: none;
transition: color 0.3s;
}
.footer-links a:hover {
color: var(--accent);
}
/* Mobile Responsive */
@media (max-width: 968px) {
.hero-content {
grid-template-columns: 1fr;
text-align: center;
}
.hero p {
margin-left: auto;
margin-right: auto;
}
.hero-visual {
order: -1;
}
.dashboard-preview {
transform: none;
}
.nav-links {
display: none;
}
.steps-container {
flex-direction: column;
}
.steps-container::before {
display: none;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* Scroll Animations */
.fade-in {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.6s, transform 0.6s;
}
.fade-in.visible {
opacity: 1;
transform: translateY(0);
}
</style>
</head>
<body>
<div class="bg-animation"></div>
<div class="grid-overlay"></div>
<!-- Navigation -->
<nav>
<div class="logo">
<div class="logo-icon">🎯</div>
PAT Manager
</div>
<ul class="nav-links">
<li><a href="#features">Features</a></li>
<li><a href="#how-it-works">So funktioniert's</a></li>
<li><a href="#stats">Statistiken</a></li>
</ul>
<a href="https://pat.web-sache.cloud/" class="nav-cta">Jetzt starten →</a>
</nav>
<!-- Hero Section -->
<section class="hero">
<div class="hero-content">
<div class="hero-text">
<div class="hero-badge">
<span></span> Jetzt live verfügbar
</div>
<h1>
Professionelle <span>PAT-Test</span> Verwaltung
</h1>
<p>
Erfasse, analysiere und verfolge Pool Ability Tests digital.
Von der Eingabe bis zum Report alles in einer intuitiven Plattform
für Trainer, Vereine und Spieler.
</p>
<div class="hero-buttons">
<a href="https://pat.web-sache.cloud/" class="btn-primary">
Kostenlos testen →
</a>
<a href="#features" class="btn-secondary">
Mehr erfahren
</a>
</div>
</div>
<div class="hero-visual">
<div class="dashboard-preview">
<div class="preview-header">
<div style="font-weight: 600;">Live Übersicht</div>
<div style="color: var(--success); font-size: 0.875rem;">● Online</div>
</div>
<div class="preview-stats">
<div class="stat-card">
<div class="stat-label">PAT Aufschlag</div>
<div class="stat-value">4/6</div>
<div class="progress-bar">
<div class="progress-fill"></div>
</div>
</div>
<div class="stat-card">
<div class="stat-label">Gesamtpunktzahl</div>
<div class="stat-value" style="color: var(--success);">87%</div>
<div class="progress-bar">
<div class="progress-fill" style="width: 87%;"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Features Section -->
<section class="features" id="features">
<div class="section-header fade-in">
<div class="section-tag">Funktionen</div>
<h2 class="section-title">Alles, was du für PAT-Tests brauchst</h2>
<p class="section-subtitle">
Von der digitalen Erfassung bis zur teamweiten Auswertung
optimiert für den Einsatz am Billardtisch.
</p>
</div>
<div class="features-grid">
<div class="feature-card fade-in">
<div class="feature-icon">📊</div>
<h3>Smarte Auswertungen</h3>
<p>Automatische Berechnungen für jede PAT-Test-Kategorie.
Erkenne Stärken und Schwächen auf einen Blick mit visuellen Dashboards.</p>
</div>
<div class="feature-card fade-in">
<div class="feature-icon">🔒</div>
<h3>Sichere Speicherung</h3>
<p>Supabase-Backend mit verschlüsselten Daten.
Granulare Nutzersteuerung für dein gesamtes Team oder Trainingsteam.</p>
</div>
<div class="feature-card fade-in">
<div class="feature-icon">👥</div>
<h3>Team-ready</h3>
<p>Gemeinsam testen, bewerten und Versionen verwalten.
Kein Chaos mehr mit Excel-Tabellen oder Zettelwirtschaft.</p>
</div>
<div class="feature-card fade-in">
<div class="feature-icon">📈</div>
<h3>Performance-Insights</h3>
<p>Verfolge Fortschritte über Zeit, identifiziere Trends
und optimiere dein Training mit datengestützten Entscheidungen.</p>
</div>
<div class="feature-card fade-in">
<div class="feature-icon"></div>
<h3>Mobile Erfassung</h3>
<p>Direkte Dateneingabe am Court schnell, einfach und
ohne Umwege. Perfekt für den Einsatz während des Trainings.</p>
</div>
<div class="feature-card fade-in">
<div class="feature-icon">📄</div>
<h3>Export & Reports</h3>
<p>Generiere professionelle Reports für Spieler, Trainer
oder Vereine. Teile Ergebnisse mit einem Klick.</p>
</div>
</div>
</section>
<!-- How it Works -->
<section class="how-it-works" id="how-it-works">
<div class="section-header fade-in">
<div class="section-tag">Prozess</div>
<h2 class="section-title">So läuft PAT mit uns</h2>
<p class="section-subtitle">Von der Testvorlage bis zum Report alles in einem Flow</p>
</div>
<div class="steps-container fade-in">
<div class="step">
<div class="step-number">1</div>
<h3>Vorlage wählen</h3>
<p>Wähle den PAT-Test, setze Namen und Datum los geht's.</p>
</div>
<div class="step">
<div class="step-number">2</div>
<h3>Daten erfassen</h3>
<p>Einzelwerte direkt am Court eingeben, automatisch verrechnen lassen.</p>
</div>
<div class="step">
<div class="step-number">3</div>
<h3>Analysieren</h3>
<p>Ergebnisse speichern, visualisieren und mit dem Team teilen.</p>
</div>
</div>
</section>
<!-- Stats Section -->
<section class="stats-section" id="stats">
<div class="stats-grid fade-in">
<div class="stat-item">
<span class="stat-item-number">100%</span>
<div class="stat-item-label">Digital</div>
</div>
<div class="stat-item">
<span class="stat-item-number">0€</span>
<div class="stat-item-label">Kostenlos starten</div>
</div>
<div class="stat-item">
<span class="stat-item-number">SSL</span>
<div class="stat-item-label">Verschlüsselt</div>
</div>
<div class="stat-item">
<span class="stat-item-number">24/7</span>
<div class="stat-item-label">Verfügbar</div>
</div>
</div>
</section>
<!-- CTA Section -->
<section class="cta-section">
<div class="cta-box fade-in">
<div class="cta-content">
<h2>Bereit, deine PAT-Tests zu professionalisieren?</h2>
<p>Starte in Minuten. Keine Kreditkarte erforderlich.</p>
<a href="https://pat.web-sache.cloud/" class="btn-primary" style="font-size: 1.2rem; padding: 1.25rem 2.5rem;">
Jetzt kostenlos ausprobieren →
</a>
</div>
</div>
</section>
<!-- Footer -->
<footer>
<ul class="footer-links">
<li><a href="https://pat.web-sache.cloud/">App öffnen</a></li>
<li><a href="#">Datenschutz</a></li>
<li><a href="#">Impressum</a></li>
<li><a href="#">Kontakt</a></li>
</ul>
<p>&copy; 2024 PAT Test Manager. Alle Rechte vorbehalten.</p>
</footer>
<script>
// Scroll Animation
const observerOptions = {
threshold: 0.1,
rootMargin: "0px 0px -50px 0px"
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
}
});
}, observerOptions);
document.querySelectorAll('.fade-in').forEach(el => observer.observe(el));
// Smooth Scroll
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function (e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
});
</script>
</body>
</html>

14
Weitere Ideen.txt Normal file
View File

@@ -0,0 +1,14 @@
Offen
nere Landing Page
Rangliste
Übersetzen in gängigge Sprachen Englisch Italienisch Spanisch Portugisisch
Max Punkte bei Bewertung PAT Start, Pat2 und Pat3
Admin Oberfläche (anzahl user mit Mail Adressen/ anelegten Tests / anbindung von Werbung Links und rechts von den Test Übersichten
2fa
Erledigt
Final Speichern do
Max Punkte bei Bewertung PAT1
Termninal
vercel deploy

22
dist/assets/html2canvas.esm-CBrSDip1.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/index-BQ19ytI3.css vendored Normal file

File diff suppressed because one or more lines are too long

84
dist/assets/index-DlZengFf.js vendored Normal file

File diff suppressed because one or more lines are too long

18
dist/assets/index.es-UMgmYbRN.js vendored Normal file

File diff suppressed because one or more lines are too long

170
dist/assets/jspdf.es.min-CzU92G48.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
dist/assets/purify.es-BgtpMKW3.js vendored Normal file

File diff suppressed because one or more lines are too long

14
dist/index.html vendored Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PAT Test Manager</title>
<script type="module" crossorigin src="/assets/index-DlZengFf.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BQ19ytI3.css">
</head>
<body>
<div id="root"></div>
</body>

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PAT Test Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

4058
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "pat-test-manager",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"migrate": "node scripts/applyMigrations.js",
"send-finalization-reminders": "node scripts/sendFinalizationReminders.js",
"test": "vitest run"
},
"dependencies": {
"@supabase/supabase-js": "^2.45.4",
"all": "^0.0.0",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"lucide-react": "^0.263.1",
"pg": "^8.11.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.1",
"vite": "^5.4.2",
"vitest": "^1.6.1"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env node
/**
* Lightweight migration runner for Supabase.
* Reads SQL files in supabase/migrations (sorted) and applies any that have not been recorded.
*
* Env:
* - SUPABASE_DB_URL or DATABASE_URL: postgres connection string (service role / connection string)
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { Pool } from 'pg';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const migrationsDir = path.resolve(__dirname, '..', 'supabase', 'migrations');
const connectionString = process.env.SUPABASE_DB_URL || process.env.DATABASE_URL;
if (!connectionString) {
console.error('Missing SUPABASE_DB_URL (or DATABASE_URL) environment variable. Aborting migrations.');
process.exit(1);
}
if (!fs.existsSync(migrationsDir)) {
console.error(`Migrations directory not found: ${migrationsDir}`);
process.exit(1);
}
const pool = new Pool({ connectionString });
async function ensureMigrationsTable(client) {
await client.query(`
create table if not exists public.schema_migrations (
id serial primary key,
filename text not null unique,
applied_at timestamptz not null default now()
);
`);
}
async function getApplied(client) {
const { rows } = await client.query('select filename from public.schema_migrations');
return new Set(rows.map((r) => r.filename));
}
function listMigrations() {
return fs
.readdirSync(migrationsDir)
.filter((file) => file.endsWith('.sql'))
.sort();
}
async function applyMigration(client, filename) {
const filePath = path.join(migrationsDir, filename);
const sql = fs.readFileSync(filePath, 'utf8');
console.log(`Applying migration: ${filename}`);
await client.query('begin');
try {
await client.query(sql);
await client.query('insert into public.schema_migrations (filename) values ($1)', [filename]);
await client.query('commit');
console.log(`✓ Applied ${filename}`);
} catch (err) {
await client.query('rollback');
console.error(`✗ Failed ${filename}:`, err.message);
throw err;
}
}
async function run() {
const client = await pool.connect();
try {
await ensureMigrationsTable(client);
const applied = await getApplied(client);
const migrations = listMigrations();
const pending = migrations.filter((m) => !applied.has(m));
if (pending.length === 0) {
console.log('No pending migrations.');
return;
}
for (const migration of pending) {
await applyMigration(client, migration);
}
console.log('All pending migrations applied.');
} finally {
client.release();
await pool.end();
}
}
run().catch((err) => {
console.error('Migration run failed:', err);
process.exit(1);
});

View File

@@ -0,0 +1,130 @@
#!/usr/bin/env node
import { Pool } from 'pg';
const connectionString = process.env.SUPABASE_DB_URL || process.env.DATABASE_URL;
const resendApiKey = process.env.RESEND_API_KEY;
const fromEmail = process.env.REMINDER_FROM_EMAIL;
const appUrl = process.env.REMINDER_APP_URL || 'http://localhost:5173';
const dryRun = process.env.REMINDER_DRY_RUN === 'true';
if (!connectionString) {
console.error('Missing SUPABASE_DB_URL (or DATABASE_URL).');
process.exit(1);
}
if (!dryRun && (!resendApiKey || !fromEmail)) {
console.error('Missing RESEND_API_KEY or REMINDER_FROM_EMAIL.');
process.exit(1);
}
const pool = new Pool({ connectionString });
const findPendingReminders = async (client) => {
const { rows } = await client.query(`
select
a.id,
a.name,
a.pat_type,
a.datum,
a.created_at,
u.email
from public.assessments a
join auth.users u on u.id = a.user_id
where a.is_finalized = false
and a.created_at <= now() - interval '3 days'
and a.finalization_reminder_sent_at is null
and coalesce(u.email, '') <> ''
order by a.created_at asc
`);
return rows;
};
const buildEmailPayload = (row) => {
const subject = `PAT Test noch abschließen: ${row.name || row.pat_type}`;
const html = `
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #111827;">
<h2 style="margin-bottom: 12px;">PAT Test noch abschließen</h2>
<p>Dein offener Test wurde seit mehr als 3 Tagen nicht final abgeschlossen.</p>
<ul>
<li><strong>Name:</strong> ${row.name || '—'}</li>
<li><strong>PAT Typ:</strong> ${row.pat_type || '—'}</li>
<li><strong>Datum:</strong> ${row.datum || '—'}</li>
</ul>
<p>Bitte öffne PAT Stats und schließe den Test final ab.</p>
<p>
<a href="${appUrl}" style="display: inline-block; margin-top: 8px; padding: 10px 16px; background: #059669; color: #ffffff; text-decoration: none; border-radius: 6px;">
PAT Stats öffnen
</a>
</p>
</div>
`;
return {
from: fromEmail,
to: [row.email],
subject,
html
};
};
const sendEmail = async (payload) => {
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${resendApiKey}`
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const message = await response.text();
throw new Error(`Resend request failed: ${response.status} ${message}`);
}
};
const markReminderSent = async (client, assessmentId) => {
await client.query(
`
update public.assessments
set finalization_reminder_sent_at = now()
where id = $1
and is_finalized = false
and finalization_reminder_sent_at is null
`,
[assessmentId]
);
};
const run = async () => {
const client = await pool.connect();
try {
const rows = await findPendingReminders(client);
if (!rows.length) {
console.log('No pending finalization reminders.');
return;
}
for (const row of rows) {
if (dryRun) {
console.log(`[dry-run] Would remind ${row.email} for assessment ${row.id}`);
continue;
}
await sendEmail(buildEmailPayload(row));
await markReminderSent(client, row.id);
console.log(`Reminder sent for assessment ${row.id} to ${row.email}`);
}
} finally {
client.release();
await pool.end();
}
};
run().catch((error) => {
console.error('Failed to send finalization reminders:', error.message);
process.exit(1);
});

255
src/App.jsx Normal file
View File

@@ -0,0 +1,255 @@
import React, { useEffect, useState } from 'react'
import { Moon, Sun } from 'lucide-react'
import PatTestManager from './components/PatTestManager'
import AuthPanel from './components/AuthPanel'
import { supabase } from './lib/supabaseClient'
import Landing from './components/Landing'
import SharedAssessmentPage from './components/SharedAssessmentPage'
import PublicInfoPage from './components/public/PublicInfoPage'
const readShareToken = () => {
if (typeof window === 'undefined') return ''
return new URLSearchParams(window.location.search).get('share') || ''
}
const readPublicPath = () => {
if (typeof window === 'undefined') return '/'
const hash = window.location.hash || ''
return hash.startsWith('#/') ? hash.slice(1) : '/'
}
function App() {
const [session, setSession] = useState(null)
const [loading, setLoading] = useState(true)
const [showAuth, setShowAuth] = useState(false)
const [activeTab, setActiveTab] = useState('assessments')
const [isEditingAssessment, setIsEditingAssessment] = useState(false)
const [shareToken, setShareToken] = useState(() => readShareToken())
const [publicPath, setPublicPath] = useState(() => readPublicPath())
const [theme, setTheme] = useState(() => {
if (typeof window === 'undefined') return 'light'
const stored = localStorage.getItem('theme')
if (stored === 'light' || stored === 'dark') return stored
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
})
useEffect(() => {
const root = document.documentElement
if (theme === 'dark') {
root.classList.add('dark')
} else {
root.classList.remove('dark')
}
localStorage.setItem('theme', theme)
}, [theme])
useEffect(() => {
const syncLocationState = () => {
setShareToken(readShareToken())
setPublicPath(readPublicPath())
}
window.addEventListener('popstate', syncLocationState)
window.addEventListener('hashchange', syncLocationState)
return () => {
window.removeEventListener('popstate', syncLocationState)
window.removeEventListener('hashchange', syncLocationState)
}
}, [])
useEffect(() => {
if (shareToken) {
setLoading(false)
return
}
if (!supabase) {
setLoading(false)
return
}
const fetchSession = async () => {
const { data, error } = await supabase.auth.getSession()
if (!error) {
setSession(data.session)
}
setLoading(false)
}
fetchSession()
const { data: authListener } = supabase.auth.onAuthStateChange((_event, newSession) => {
setSession(newSession)
})
return () => {
authListener?.subscription.unsubscribe()
}
}, [shareToken])
useEffect(() => {
if (isEditingAssessment && activeTab === 'analysis') {
setActiveTab('assessments')
}
}, [activeTab, isEditingAssessment])
const handleSignOut = async () => {
if (!supabase) return
await supabase.auth.signOut()
setSession(null)
setShowAuth(false)
setActiveTab('assessments')
setIsEditingAssessment(false)
navigatePublic('/')
}
const toggleTheme = () => setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'))
const navigatePublic = (path) => {
if (typeof window === 'undefined') return
const nextPath = path || '/'
const nextHash = nextPath === '/' ? '' : `#${nextPath}`
if (window.location.hash === nextHash) {
setPublicPath(nextPath)
window.scrollTo({ top: 0, behavior: 'smooth' })
return
}
const nextUrl = `${window.location.pathname}${window.location.search}${nextHash}`
window.history.pushState({}, '', nextUrl)
setPublicPath(nextPath)
setShareToken(readShareToken())
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const renderThemeToggle = () => (
<button
onClick={toggleTheme}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 shadow-sm hover:shadow transition"
aria-label="Darstellung wechseln"
type="button"
>
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
<span className="text-sm font-medium">{theme === 'dark' ? 'Light Mode' : 'Dark Mode'}</span>
</button>
)
if (shareToken) {
return <SharedAssessmentPage shareToken={shareToken} themeToggle={renderThemeToggle()} />
}
if (!supabase) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100 flex items-center justify-center p-6">
<div className="absolute top-4 right-4">{renderThemeToggle()}</div>
<div className="max-w-xl w-full bg-white/90 dark:bg-gray-900/80 border border-dashed border-gray-300 dark:border-gray-700 rounded-xl shadow-sm p-6">
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-2">Supabase noch nicht konfiguriert</h2>
<p className="text-gray-600 dark:text-gray-300">
Lege eine <code>.env</code> an (siehe <code>.env.example</code>) und trage
<code> VITE_SUPABASE_URL </code> sowie <code>VITE_SUPABASE_ANON_KEY</code> ein, um Login und
Registrierung zu aktivieren.
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-3">
Anschließend Vite neu starten, damit die Variablen geladen werden.
</p>
</div>
</div>
)
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 text-gray-600 dark:text-gray-300">
<div className="absolute top-4 right-4">{renderThemeToggle()}</div>
<span>Auth wird geladen...</span>
</div>
)
}
if (!session) {
if (showAuth) {
return <AuthPanel onAuth={setSession} themeToggle={renderThemeToggle()} onBack={() => setShowAuth(false)} />
}
if (publicPath === '/datenschutz' || publicPath === '/impressum' || publicPath === '/kontakt') {
return (
<PublicInfoPage
path={publicPath}
onGetStarted={() => setShowAuth(true)}
onNavigate={navigatePublic}
themeToggle={renderThemeToggle()}
/>
)
}
return (
<Landing
onGetStarted={() => setShowAuth(true)}
onNavigate={navigatePublic}
themeToggle={renderThemeToggle()}
/>
)
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
<header className="bg-white/80 dark:bg-gray-900/70 backdrop-blur border-b border-gray-200 dark:border-gray-800">
<div className="max-w-7xl mx-auto px-6 py-3 flex items-center justify-between gap-3 flex-wrap">
<div>
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Angemeldet</p>
<p className="text-sm font-semibold text-gray-800 dark:text-gray-100">{session?.user?.email}</p>
</div>
<nav className="flex items-center gap-2">
<button
type="button"
onClick={() => setActiveTab('assessments')}
className={`px-4 py-2 rounded-lg border text-sm font-semibold transition ${
activeTab === 'assessments'
? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900 border-gray-900 dark:border-gray-100'
: 'bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
Bewertungen
</button>
<button
type="button"
onClick={() => setActiveTab('analysis')}
disabled={isEditingAssessment}
className={`px-4 py-2 rounded-lg border text-sm font-semibold transition ${
activeTab === 'analysis'
? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900 border-gray-900 dark:border-gray-100'
: 'bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800'
} ${isEditingAssessment ? 'opacity-50 cursor-not-allowed' : ''}`}
title={isEditingAssessment ? 'Analyse ist während der Bearbeitung deaktiviert' : 'Analyse öffnen'}
>
Analyse
</button>
</nav>
<div className="flex items-center gap-3">
{renderThemeToggle()}
<button
onClick={handleSignOut}
className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-200 transition"
>
Abmelden
</button>
</div>
</div>
</header>
<PatTestManager
user={session?.user}
activeTab={activeTab}
onEditingStateChange={setIsEditingAssessment}
/>
</div>
)
}
export default App

View File

@@ -0,0 +1,164 @@
import React, { useEffect, useMemo } from 'react';
import { Activity, AlertTriangle, Brain, Target } from 'lucide-react';
import { buildAnalysis } from '../../utils/analysisEngine';
import { useTrainingPlans } from '../../hooks/useTrainingPlans';
import GapBarChart from './GapBarChart';
import RadarChart from './RadarChart';
import StrengthList from './StrengthList';
import TrainingPlanPanel from './TrainingPlanPanel';
import TrendChart from './TrendChart';
import WeaknessPriorityList from './WeaknessPriorityList';
export default function AnalysisTab({
assessments,
selectedPatType,
onPatTypeChange,
patTypes,
user
}) {
const patTypeOptions = Object.keys(patTypes || {});
useEffect(() => {
if (!selectedPatType && patTypeOptions.length) {
onPatTypeChange(patTypeOptions[0]);
}
}, [onPatTypeChange, patTypeOptions, selectedPatType]);
const analysis = useMemo(
() =>
buildAnalysis(assessments, selectedPatType, {
windowSize: 5,
missingStrategy: 'zero'
}),
[assessments, selectedPatType]
);
const selectedPatAssessments = assessments.filter((assessment) => assessment.patType === selectedPatType);
const hasAssessments = assessments.length > 0;
const {
activePlan,
historyPlans,
loading,
saving,
error,
saveGeneratedPlan,
updateSessionState,
updateSessionPartial,
loadPlans
} = useTrainingPlans({
user,
patType: selectedPatType
});
const handleSavePlan = ({ durationWeeks, sessionsPerWeek, sessions }) =>
saveGeneratedPlan({
analysis,
durationWeeks,
sessionsPerWeek,
sessions
});
return (
<div className="min-h-screen bg-gradient-to-br from-cyan-50 via-white to-emerald-50 dark:from-gray-950 dark:via-gray-950 dark:to-gray-900 p-6 text-gray-900 dark:text-gray-100">
<div className="max-w-7xl mx-auto space-y-6">
<section className="rounded-2xl border border-gray-200 dark:border-gray-800 bg-white/90 dark:bg-gray-900/80 backdrop-blur p-5 shadow-sm">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-cyan-700 dark:text-cyan-300">Analyse</p>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Wo solltest du besser werden?</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Priorisierung nach Gap × Faktor × Konstanz basierend auf den letzten 5 Assessments.
</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{patTypeOptions.map((patType) => {
const isActive = patType === selectedPatType;
return (
<button
key={patType}
type="button"
onClick={() => onPatTypeChange(patType)}
className={`px-4 py-2 rounded-lg border text-sm font-semibold transition ${
isActive
? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900 border-gray-900 dark:border-gray-100'
: 'bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
{patType}
</button>
);
})}
</div>
<div className="mt-4 flex flex-wrap gap-2 text-xs">
<span className="px-3 py-1 rounded-full border border-cyan-200 dark:border-cyan-800 bg-cyan-50 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-200 font-semibold">
<Activity className="w-3 h-3 inline-block mr-1" />
Assessments: {selectedPatAssessments.length}
</span>
<span className="px-3 py-1 rounded-full border border-indigo-200 dark:border-indigo-800 bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-200 font-semibold">
<Target className="w-3 h-3 inline-block mr-1" />
Analysefenster: {analysis.windowedAssessments.length}/5
</span>
{analysis.isLimitedData && (
<span className="px-3 py-1 rounded-full border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-200 font-semibold">
<AlertTriangle className="w-3 h-3 inline-block mr-1" />
Begrenzte Datenbasis
</span>
)}
</div>
</section>
{!hasAssessments ? (
<section className="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900/70 p-6 text-center">
<Brain className="w-10 h-10 mx-auto mb-3 text-gray-400 dark:text-gray-500" />
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-1">Noch keine Daten für die Analyse</h2>
<p className="text-sm text-gray-600 dark:text-gray-300">Lege zuerst eine Bewertung im Bereich "Bewertungen" an.</p>
</section>
) : selectedPatAssessments.length === 0 ? (
<section className="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900/70 p-6 text-center">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-1">Keine Bewertungen für {selectedPatType}</h2>
<p className="text-sm text-gray-600 dark:text-gray-300">Wähle einen anderen PAT-Typ oder erstelle eine neue Bewertung.</p>
</section>
) : (
<>
<section className="grid lg:grid-cols-2 gap-4">
<div className="h-full">
<GapBarChart metrics={analysis.exerciseMetrics} />
</div>
<div className="h-full">
<RadarChart radarSeries={analysis.radarSeries} />
</div>
<div className="lg:col-span-2">
<TrendChart trendSeries={analysis.trendSeries} />
</div>
</section>
<section className="grid lg:grid-cols-2 gap-4">
<WeaknessPriorityList weaknesses={analysis.topWeaknesses} />
<StrengthList strengths={analysis.topStrengths} />
</section>
<TrainingPlanPanel
patType={selectedPatType}
analysis={analysis}
activePlan={activePlan}
historyPlans={historyPlans}
loading={loading}
saving={saving}
error={error}
onSavePlan={handleSavePlan}
onUpdateSessionState={updateSessionState}
onUpdateSessionPartial={updateSessionPartial}
onReload={loadPlans}
/>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import React from 'react';
const truncate = (value, maxLength = 26) =>
value.length > maxLength ? `${value.slice(0, maxLength - 1)}` : value;
export default function GapBarChart({ metrics = [] }) {
const data = [...metrics]
.sort((left, right) => right.priorityScore - left.priorityScore)
.slice(0, 8);
if (!data.length) {
return (
<div className="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 bg-white/70 dark:bg-gray-900/70 p-4 text-sm text-gray-600 dark:text-gray-300">
Kein Gap-Chart verfügbar.
</div>
);
}
const width = 760;
const leftPad = 220;
const rightPad = 24;
const rowHeight = 38;
const topPad = 20;
const bottomPad = 24;
const height = topPad + bottomPad + rowHeight * data.length;
const maxScore = Math.max(...data.map((item) => item.priorityScore), 0.01);
const chartWidth = width - leftPad - rightPad;
return (
<div className="rounded-2xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-4 shadow-sm h-full flex flex-col">
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-3">Gap-Bar-Chart (Priorität)</h3>
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto" role="img" aria-label="Gap-Balkendiagramm">
{data.map((item, index) => {
const y = topPad + index * rowHeight;
const barWidth = (item.priorityScore / maxScore) * chartWidth;
return (
<g key={item.name}>
<text
x={10}
y={y + 20}
className="fill-gray-700 dark:fill-gray-200"
style={{ fontSize: 12, fontWeight: 500 }}
>
{truncate(item.name)}
</text>
<rect
x={leftPad}
y={y + 8}
width={chartWidth}
height={16}
rx={8}
className="fill-gray-200 dark:fill-gray-800"
/>
<rect
x={leftPad}
y={y + 8}
width={Math.max(3, barWidth)}
height={16}
rx={8}
className="fill-rose-500"
/>
<text
x={leftPad + Math.max(8, barWidth) + 8}
y={y + 20}
className="fill-gray-700 dark:fill-gray-300"
style={{ fontSize: 11, fontWeight: 600 }}
>
{item.priorityScore.toFixed(2)}
</text>
</g>
);
})}
</svg>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import React from 'react';
const polarToCartesian = (centerX, centerY, radius, angle) => ({
x: centerX + radius * Math.cos(angle),
y: centerY + radius * Math.sin(angle)
});
const truncate = (value, maxLength = 16) => (value.length > maxLength ? `${value.slice(0, maxLength - 1)}` : value);
export default function RadarChart({ radarSeries = [] }) {
const data = radarSeries.slice(0, 6);
if (!data.length) {
return (
<div className="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 bg-white/70 dark:bg-gray-900/70 p-4 text-sm text-gray-600 dark:text-gray-300">
Kein Radarprofil verfügbar.
</div>
);
}
const width = 420;
const height = 320;
const centerX = width / 2;
const centerY = height / 2;
const radius = 105;
const angleStep = (Math.PI * 2) / data.length;
const toPoint = (ratio, idx) => {
const angle = -Math.PI / 2 + idx * angleStep;
return polarToCartesian(centerX, centerY, radius * (Math.max(0, Math.min(1.2, ratio)) / 1.2), angle);
};
const polygon = data
.map((item, idx) => {
const point = toPoint(item.ratio, idx);
return `${point.x},${point.y}`;
})
.join(' ');
return (
<div className="rounded-2xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-4 shadow-sm h-full flex flex-col">
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-3">Radar-Profil (Ist/Soll)</h3>
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto max-w-[420px] mx-auto" role="img" aria-label="Radarprofil">
{[0.25, 0.5, 0.75, 1].map((level) => (
<polygon
key={level}
points={data
.map((_, idx) => {
const angle = -Math.PI / 2 + idx * angleStep;
const point = polarToCartesian(centerX, centerY, radius * level, angle);
return `${point.x},${point.y}`;
})
.join(' ')}
fill="none"
className="stroke-gray-300 dark:stroke-gray-700"
strokeWidth="1"
/>
))}
{data.map((item, idx) => {
const angle = -Math.PI / 2 + idx * angleStep;
const outerPoint = polarToCartesian(centerX, centerY, radius + 16, angle);
const axisEnd = polarToCartesian(centerX, centerY, radius, angle);
return (
<g key={item.name}>
<line
x1={centerX}
y1={centerY}
x2={axisEnd.x}
y2={axisEnd.y}
className="stroke-gray-300 dark:stroke-gray-700"
strokeWidth="1"
/>
<text
x={outerPoint.x}
y={outerPoint.y}
textAnchor="middle"
className="fill-gray-600 dark:fill-gray-300"
style={{ fontSize: 10 }}
>
{truncate(item.name)}
</text>
</g>
);
})}
<polygon points={polygon} className="fill-sky-300/40 dark:fill-sky-500/30 stroke-sky-600 dark:stroke-sky-400" strokeWidth="2" />
</svg>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import React from 'react';
const scoreToPercent = (score) => Math.round(Math.max(0, Math.min(1.5, score)) * 100);
export default function StrengthList({ strengths = [] }) {
if (!strengths.length) {
return (
<div className="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 bg-white/70 dark:bg-gray-900/70 p-4 text-sm text-gray-600 dark:text-gray-300">
Keine Stärken-Daten verfügbar.
</div>
);
}
return (
<div className="rounded-2xl border border-emerald-200 dark:border-emerald-900 bg-white dark:bg-gray-900 p-5 shadow-sm">
<h3 className="text-lg font-semibold text-emerald-700 dark:text-emerald-300 mb-4">Top-3 Stärken</h3>
<div className="space-y-3">
{strengths.map((item, index) => (
<div
key={item.name}
className="rounded-xl border border-emerald-100 dark:border-emerald-900/60 bg-emerald-50/60 dark:bg-emerald-950/20 p-3"
>
<div className="flex items-center justify-between gap-3">
<p className="font-semibold text-gray-900 dark:text-gray-100">
{index + 1}. {item.name}
</p>
<span className="text-xs px-2 py-1 rounded-full bg-emerald-200/70 dark:bg-emerald-800/60 text-emerald-800 dark:text-emerald-200 font-semibold">
{scoreToPercent(item.strengthScore)}
</span>
</div>
<div className="mt-2 grid grid-cols-3 gap-2 text-xs text-gray-600 dark:text-gray-300">
<p>Ist: {item.actualMean.toFixed(2)}</p>
<p>Soll: {item.soll.toFixed(2)}</p>
<p>Trend: {item.trendDelta.toFixed(2)}</p>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,358 @@
import React, { useMemo, useState } from 'react';
import { generateTrainingPlan } from '../../utils/trainingPlanGenerator';
const formatDateTime = (value) => {
if (!value) return '—';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(parsed);
};
const taskWithUpdatedIntensity = (task, intensity) => {
if (task.type !== 'main') return task;
const repetitionsByIntensity = {
hoch: 24,
mittel: 18,
leicht: 12
};
return {
...task,
intensity,
repetitions: repetitionsByIntensity[intensity] || 18
};
};
const statusButtonClass = (active) =>
active
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-700';
export default function TrainingPlanPanel({
patType,
analysis,
activePlan,
historyPlans,
loading,
saving,
error,
onSavePlan,
onUpdateSessionState,
onUpdateSessionPartial,
onReload
}) {
const [durationWeeks, setDurationWeeks] = useState(4);
const [sessionsPerWeek, setSessionsPerWeek] = useState(3);
const [draftSessions, setDraftSessions] = useState([]);
const [uiMessage, setUiMessage] = useState('');
const draftSummary = useMemo(() => {
const total = draftSessions.length;
const high = draftSessions.filter((session) => session.intensity === 'hoch').length;
const medium = draftSessions.filter((session) => session.intensity === 'mittel').length;
const low = draftSessions.filter((session) => session.intensity === 'leicht').length;
return { total, high, medium, low };
}, [draftSessions]);
const handleGenerateDraft = () => {
const generated = generateTrainingPlan({
topWeaknesses: analysis?.topWeaknesses || [],
topStrengths: analysis?.topStrengths || [],
durationWeeks,
sessionsPerWeek,
sessionMix: '1-main-2-secondary'
});
setDraftSessions(generated.sessions);
setUiMessage('Neuer Entwurf erstellt. Du kannst jetzt Intensität und Übungen je Session anpassen.');
};
const updateDraftSession = (index, patch) => {
setDraftSessions((previous) =>
previous.map((session, currentIndex) => {
if (currentIndex !== index) return session;
const next = {
...session,
...patch
};
if (Object.prototype.hasOwnProperty.call(patch, 'intensity')) {
next.tasks = next.tasks.map((task) => taskWithUpdatedIntensity(task, patch.intensity));
}
return next;
})
);
};
const handleSaveDraft = async () => {
if (!draftSessions.length) {
setUiMessage('Erstelle zuerst einen Entwurf.');
return;
}
const result = await onSavePlan({
analysis,
durationWeeks,
sessionsPerWeek,
sessions: draftSessions
});
if (!result?.ok) {
setUiMessage(result?.error?.message || 'Trainingsplan konnte nicht gespeichert werden.');
return;
}
setUiMessage('Trainingsplan gespeichert. Der vorherige aktive Plan wurde archiviert.');
setDraftSessions([]);
};
const renderSessionEditor = (session, index) => {
const secondaryAsText = session.secondaryExercises.join(', ');
return (
<div key={`draft-${index}`} className="rounded-xl border border-sky-100 dark:border-sky-900/60 bg-sky-50/60 dark:bg-sky-950/20 p-3">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-semibold text-sky-800 dark:text-sky-200">
Woche {session.weekNo} · Einheit {session.sessionNo}
</p>
<span className="text-xs text-sky-700 dark:text-sky-300">{session.intensity}</span>
</div>
<div className="grid md:grid-cols-3 gap-2 text-sm">
<input
value={session.mainExercise}
onChange={(event) => updateDraftSession(index, { mainExercise: event.target.value })}
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900"
placeholder="Hauptübung"
/>
<input
value={secondaryAsText}
onChange={(event) => {
const secondaryExercises = event.target.value
.split(',')
.map((value) => value.trim())
.filter(Boolean)
.slice(0, 2);
updateDraftSession(index, {
secondaryExercises:
secondaryExercises.length === 2
? secondaryExercises
: [...secondaryExercises, 'Positionsspiel Basis'].slice(0, 2)
});
}}
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900"
placeholder="Nebenübungen, getrennt mit Komma"
/>
<select
value={session.intensity}
onChange={(event) => updateDraftSession(index, { intensity: event.target.value })}
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900"
>
<option value="hoch">hoch</option>
<option value="mittel">mittel</option>
<option value="leicht">leicht</option>
</select>
</div>
</div>
);
};
return (
<section className="rounded-2xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-5 shadow-sm">
<div className="flex items-start justify-between flex-wrap gap-3 mb-4">
<div>
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Trainingsplan ({patType})</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
1 aktiver Plan + Historie, Session-Status: offen / erledigt / übersprungen.
</p>
</div>
<button
onClick={onReload}
type="button"
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800"
>
Neu laden
</button>
</div>
{error && (
<div className="mb-3 rounded-lg border border-red-300 dark:border-red-800 bg-red-50 dark:bg-red-900/30 p-3 text-sm text-red-700 dark:text-red-200">
{error}
</div>
)}
<div className="grid md:grid-cols-4 gap-3 mb-4">
<label className="text-sm text-gray-700 dark:text-gray-200">
Dauer
<select
value={durationWeeks}
onChange={(event) => setDurationWeeks(Number(event.target.value))}
className="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900"
>
<option value={2}>2 Wochen</option>
<option value={4}>4 Wochen</option>
<option value={6}>6 Wochen</option>
</select>
</label>
<label className="text-sm text-gray-700 dark:text-gray-200">
Einheiten/Woche
<select
value={sessionsPerWeek}
onChange={(event) => setSessionsPerWeek(Number(event.target.value))}
className="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900"
>
<option value={2}>2</option>
<option value={3}>3</option>
<option value={4}>4</option>
</select>
</label>
<button
onClick={handleGenerateDraft}
type="button"
className="md:self-end px-4 py-2 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700"
>
Plan (neu) generieren
</button>
<button
onClick={handleSaveDraft}
type="button"
disabled={saving || !draftSessions.length}
className="md:self-end px-4 py-2 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-60"
>
{saving ? 'Speichert…' : 'Entwurf speichern'}
</button>
</div>
{uiMessage && (
<div className="mb-4 rounded-lg border border-indigo-200 dark:border-indigo-800 bg-indigo-50 dark:bg-indigo-900/30 p-3 text-sm text-indigo-700 dark:text-indigo-200">
{uiMessage}
</div>
)}
{draftSessions.length > 0 && (
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Entwurf (teilweise editierbar)</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">
Sessions: {draftSummary.total} · hoch {draftSummary.high} · mittel {draftSummary.medium} · leicht {draftSummary.low}
</p>
</div>
<div className="max-h-80 overflow-y-auto space-y-2 pr-1">{draftSessions.map(renderSessionEditor)}</div>
</div>
)}
<div className="grid lg:grid-cols-2 gap-4">
<div className="rounded-xl border border-emerald-100 dark:border-emerald-900/60 p-3 bg-emerald-50/50 dark:bg-emerald-950/20">
<h4 className="font-semibold text-emerald-800 dark:text-emerald-200 mb-2">Aktiver Plan</h4>
{loading ? (
<p className="text-sm text-gray-600 dark:text-gray-300">Lädt</p>
) : !activePlan ? (
<p className="text-sm text-gray-600 dark:text-gray-300">Noch kein aktiver Trainingsplan vorhanden.</p>
) : (
<>
<p className="text-xs text-gray-600 dark:text-gray-300 mb-2">
Generiert: {formatDateTime(activePlan.generatedAt)} · {activePlan.durationWeeks} Wochen · {activePlan.sessionsPerWeek} Einheiten/Woche
</p>
<div className="max-h-80 overflow-y-auto space-y-2 pr-1">
{activePlan.sessions.map((session) => (
<div key={session.id} className="rounded-lg border border-emerald-200/70 dark:border-emerald-800 bg-white dark:bg-gray-900 p-3">
<div className="flex items-center justify-between gap-2 mb-2">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
W{session.weekNo} · E{session.sessionNo}
</p>
<div className="flex gap-1">
{['open', 'done', 'skipped'].map((state) => (
<button
key={state}
type="button"
onClick={() => onUpdateSessionState(session.id, state)}
className={`text-xs px-2 py-1 border rounded ${statusButtonClass(session.state === state)}`}
>
{state === 'open' ? 'offen' : state === 'done' ? 'erledigt' : 'übersprungen'}
</button>
))}
</div>
</div>
<input
defaultValue={session.mainExercise}
onBlur={(event) =>
onUpdateSessionPartial(session.id, {
mainExercise: event.target.value
})
}
className="w-full mb-2 px-3 py-2 text-sm rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900"
/>
<input
defaultValue={session.secondaryExercises.join(', ')}
onBlur={(event) =>
onUpdateSessionPartial(session.id, {
secondaryExercises: event.target.value
.split(',')
.map((value) => value.trim())
.filter(Boolean)
.slice(0, 2)
})
}
className="w-full mb-2 px-3 py-2 text-sm rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900"
/>
<textarea
defaultValue={session.notes || ''}
onBlur={(event) => onUpdateSessionPartial(session.id, { notes: event.target.value })}
placeholder="Notiz"
className="w-full px-3 py-2 text-sm rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900"
rows={2}
/>
</div>
))}
</div>
</>
)}
</div>
<div className="rounded-xl border border-gray-200 dark:border-gray-800 p-3 bg-gray-50 dark:bg-gray-950/40">
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">Plan-Historie</h4>
{!historyPlans.length ? (
<p className="text-sm text-gray-600 dark:text-gray-300">Keine archivierten Pläne vorhanden.</p>
) : (
<div className="space-y-2 max-h-80 overflow-y-auto pr-1">
{historyPlans.map((plan) => (
<div key={plan.id} className="rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-3">
<p className="text-sm font-semibold text-gray-800 dark:text-gray-100">
{formatDateTime(plan.generatedAt)}
</p>
<p className="text-xs text-gray-600 dark:text-gray-300">
{plan.durationWeeks} Wochen · {plan.sessionsPerWeek} Einheiten/Woche · {plan.sessions.length} Sessions
</p>
</div>
))}
</div>
)}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,59 @@
import React from 'react';
export default function TrendChart({ trendSeries = [] }) {
if (trendSeries.length < 2) {
return (
<div className="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 bg-white/70 dark:bg-gray-900/70 p-4 text-sm text-gray-600 dark:text-gray-300">
Für den Trend werden mindestens 2 Assessments benötigt.
</div>
);
}
const width = 760;
const height = 280;
const left = 50;
const right = 20;
const top = 16;
const bottom = 52;
const values = trendSeries.map((entry) => entry.totalPoints);
const minValue = Math.min(...values);
const maxValue = Math.max(...values);
const range = maxValue - minValue || 1;
const innerWidth = width - left - right;
const innerHeight = height - top - bottom;
const points = trendSeries.map((entry, index) => {
const x = left + (index / (trendSeries.length - 1)) * innerWidth;
const normalized = (entry.totalPoints - minValue) / range;
const y = top + innerHeight - normalized * innerHeight;
return { ...entry, x, y };
});
const path = points.map((point, index) => `${index === 0 ? 'M' : 'L'}${point.x},${point.y}`).join(' ');
return (
<div className="rounded-2xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-4 shadow-sm">
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-3">Trend (Gesamtpunkte)</h3>
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto" role="img" aria-label="Trenddiagramm">
<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" />
<path d={path} fill="none" className="stroke-indigo-500" strokeWidth="3" strokeLinecap="round" />
{points.map((point) => (
<g key={point.assessmentId}>
<circle cx={point.x} cy={point.y} r="4" className="fill-indigo-500" />
<text x={point.x} y={point.y - 10} textAnchor="middle" className="fill-gray-700 dark:fill-gray-200" style={{ fontSize: 11, fontWeight: 600 }}>
{point.totalPoints}
</text>
<text x={point.x} y={height - 18} textAnchor="middle" className="fill-gray-500 dark:fill-gray-400" style={{ fontSize: 10 }}>
{point.label}
</text>
</g>
))}
</svg>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import React from 'react';
const scoreToPercent = (score) => Math.round(Math.max(0, Math.min(1.5, score)) * 100);
export default function WeaknessPriorityList({ weaknesses = [] }) {
if (!weaknesses.length) {
return (
<div className="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 bg-white/70 dark:bg-gray-900/70 p-4 text-sm text-gray-600 dark:text-gray-300">
Keine Schwächen-Daten verfügbar.
</div>
);
}
return (
<div className="rounded-2xl border border-rose-200 dark:border-rose-900 bg-white dark:bg-gray-900 p-5 shadow-sm">
<h3 className="text-lg font-semibold text-rose-700 dark:text-rose-300 mb-4">Top-3 Schwächen</h3>
<div className="space-y-3">
{weaknesses.map((item, index) => (
<div
key={item.name}
className="rounded-xl border border-rose-100 dark:border-rose-900/60 bg-rose-50/60 dark:bg-rose-950/20 p-3"
>
<div className="flex items-center justify-between gap-3">
<p className="font-semibold text-gray-900 dark:text-gray-100">
{index + 1}. {item.name}
</p>
<span className="text-xs px-2 py-1 rounded-full bg-rose-200/70 dark:bg-rose-800/60 text-rose-800 dark:text-rose-200 font-semibold">
{scoreToPercent(item.priorityScore)}
</span>
</div>
<div className="mt-2 grid grid-cols-3 gap-2 text-xs text-gray-600 dark:text-gray-300">
<p>Gap: {item.gapScore.toFixed(2)}</p>
<p>Konstanz: {item.consistencyScore.toFixed(2)}</p>
<p>Trend: {item.trendDelta.toFixed(2)}</p>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,142 @@
import React, { useState } from 'react'
import { ArrowLeft } from 'lucide-react'
import { supabase } from '../lib/supabaseClient'
export default function AuthPanel({ onAuth, themeToggle, onBack }) {
const [mode, setMode] = useState('login')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (event) => {
event.preventDefault()
if (!supabase) return
setLoading(true)
setError('')
setMessage('')
try {
if (mode === 'login') {
const { data, error: signInError } = await supabase.auth.signInWithPassword({
email,
password
})
if (signInError) {
setError(signInError.message)
} else {
setMessage('Erfolgreich angemeldet')
onAuth?.(data.session)
}
} else {
const { data, error: signUpError } = await supabase.auth.signUp({
email,
password
})
if (signUpError) {
setError(signUpError.message)
} else if (!data.session) {
setMessage('Registrierung abgeschlossen. Prüfe dein Postfach zur Bestätigung.')
} else {
onAuth?.(data.session)
}
}
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-sky-100 via-blue-50 to-emerald-100 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 flex items-center justify-center p-6 text-gray-900 dark:text-gray-100">
<div className="absolute top-4 left-4 flex items-center gap-3">
{onBack && (
<button
type="button"
onClick={onBack}
className="inline-flex items-center gap-2 rounded-full border border-gray-200/70 bg-white/70 px-3 py-2 text-sm font-medium text-gray-800 shadow-sm backdrop-blur transition hover:-translate-y-[1px] hover:shadow-md dark:border-gray-800 dark:bg-gray-900/70 dark:text-gray-100"
>
<ArrowLeft className="h-4 w-4" />
Zurück
</button>
)}
</div>
<div className="absolute top-4 right-4">{themeToggle}</div>
<div className="w-full max-w-lg bg-white/90 dark:bg-gray-900/80 backdrop-blur rounded-2xl shadow-xl border border-white/70 dark:border-gray-800">
<div className="px-8 py-6 border-b border-gray-100 dark:border-gray-800">
<p className="text-sm uppercase tracking-wide text-indigo-600 dark:text-indigo-300 font-semibold">PAT Stats</p>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mt-1">Anmelden oder registrieren</h1>
<p className="text-gray-600 dark:text-gray-300 mt-2">
Melde dich mit deinem PAT-Test Konto an, um deine Bewertungen sicher zu verwalten.
</p>
</div>
<form onSubmit={handleSubmit} className="px-8 py-6 space-y-4">
<div className="flex items-center gap-2 text-sm font-medium">
<button
type="button"
onClick={() => setMode('login')}
className={`px-4 py-2 rounded-full transition ${mode === 'login' ? 'bg-indigo-600 text-white' : 'text-gray-600 dark:text-gray-300 hover:bg-indigo-50 dark:hover:bg-indigo-900/40'}`}
>
Login
</button>
<button
type="button"
onClick={() => setMode('register')}
className={`px-4 py-2 rounded-full transition ${mode === 'register' ? 'bg-emerald-600 text-white' : 'text-gray-600 dark:text-gray-300 hover:bg-emerald-50 dark:hover:bg-emerald-900/40'}`}
>
Registrieren
</button>
</div>
<label className="block">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">E-Mail</span>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 w-full px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-800 dark:text-gray-100"
placeholder="you@example.com"
/>
</label>
<label className="block">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">Passwort</span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="mt-1 w-full px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-800 dark:text-gray-100"
placeholder="••••••••"
/>
</label>
{error && (
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-200 text-sm">
{error}
</div>
)}
{message && (
<div className="p-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/30 border border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-200 text-sm">
{message}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3 rounded-lg font-semibold text-white bg-gradient-to-r from-indigo-600 to-emerald-600 shadow-md hover:shadow-lg transition disabled:opacity-60 disabled:cursor-not-allowed"
>
{loading ? 'Wird gesendet...' : mode === 'login' ? 'Einloggen' : 'Konto erstellen'}
</button>
</form>
</div>
</div>
)
}

259
src/components/Landing.jsx Normal file
View File

@@ -0,0 +1,259 @@
import React from 'react'
import {
ArrowRight,
CheckCircle2,
FileDown,
ShieldCheck,
Smartphone,
Sparkles,
TrendingUp,
Users
} from 'lucide-react'
import PublicLayout from './public/PublicLayout'
const features = [
{
title: 'Smarte Auswertungen',
body: 'Automatische Berechnungen für jede PAT-Kategorie mit klaren Kennzahlen, Trends und Fortschrittsbildern.',
icon: Sparkles
},
{
title: 'Sichere Speicherung',
body: 'Nutzergebundene Datenhaltung, finale Abschlüsse und kontrollierte Freigaben für Trainer, Verein und Staff.',
icon: ShieldCheck
},
{
title: 'Team-ready',
body: 'Bewertungen erfassen, öffnen, teilen und wieder sperren, ohne Excel-Chaos oder doppelte Listen.',
icon: Users
},
{
title: 'Performance-Insights',
body: 'Schwächen erkennen, Entwicklung verfolgen und Training gezielt auf die kritischen Übungen ausrichten.',
icon: TrendingUp
},
{
title: 'Mobile Erfassung',
body: 'Direkte Eingabe am Tisch mit klaren Grenzen pro Übung und sofortiger Berechnung der Punkte.',
icon: Smartphone
},
{
title: 'Export & Reports',
body: 'Saubere PDF-Ausgabe, öffentliche Freigabelinks und strukturierte Ergebnisansichten für Athleten und Staff.',
icon: FileDown
}
]
const steps = [
{
title: 'Vorlage wählen',
body: 'PAT Start, PAT 1, PAT 2 oder PAT 3 auswählen und die Bewertung sofort anlegen.'
},
{
title: 'Werte erfassen',
body: 'Einzelwerte direkt eingeben, automatisch begrenzen lassen und live berechnen.'
},
{
title: 'Abschließen & teilen',
body: 'Bewertung final abschließen, als PDF exportieren oder per Link freigeben.'
}
]
const stats = [
{ value: '100%', label: 'Digitaler Ablauf' },
{ value: '0€', label: 'Kostenlos starten' },
{ value: 'SSL', label: 'Verschlüsselte Übertragung' },
{ value: '24/7', label: 'Jederzeit verfügbar' }
]
const scrollToSection = (sectionId) => {
if (typeof document === 'undefined') return
const section = document.getElementById(sectionId)
if (!section) return
section.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
export default function Landing({ onGetStarted, onNavigate, themeToggle }) {
return (
<PublicLayout
themeToggle={themeToggle}
onGetStarted={onGetStarted}
onNavigate={onNavigate}
currentPath="/"
navLinks={[
{ label: 'Features', onClick: () => scrollToSection('features') },
{ label: 'So funktioniert es', onClick: () => scrollToSection('how-it-works') },
{ label: 'Statistiken', onClick: () => scrollToSection('stats') }
]}
>
<section className="public-hero">
<div>
<span className="public-hero__badge">
<Sparkles className="h-4 w-4" aria-hidden="true" />
Jetzt live verfügbar
</span>
<h1 className="public-hero__title">
Professionelle
<span className="public-hero__title-accent"> PAT-Test Verwaltung</span>
</h1>
<p className="public-hero__copy">
Erfasse, analysiere und verfolge Pool Ability Tests digital. Von der Eingabe bis zum Report läuft alles in
einer klaren Oberfläche für Trainer, Vereine und Spieler.
</p>
<div className="public-hero__actions">
<button type="button" className="public-site__button" onClick={onGetStarted}>
Kostenlos testen
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
className="public-site__button--secondary"
onClick={() => scrollToSection('features')}
>
Mehr erfahren
</button>
</div>
<div className="public-hero__meta">
<span className="public-chip">
<CheckCircle2 className="h-4 w-4 text-blue-300" aria-hidden="true" />
PAT Start bis PAT 3
</span>
<span className="public-chip">
<CheckCircle2 className="h-4 w-4 text-blue-300" aria-hidden="true" />
Teilen, PDF und Finalisierung
</span>
</div>
</div>
<div className="public-preview__panel">
<div className="public-preview__head">
<strong>Live Übersicht</strong>
<span className="public-preview__status">Online</span>
</div>
<div className="public-preview__grid">
<div className="public-preview__card">
<div className="public-preview__label">PAT 1 · Übung 1</div>
<div className="public-preview__value public-preview__value--accent">4 / 4</div>
<div className="public-progress">
<span style={{ width: '100%' }} />
</div>
</div>
<div className="public-preview__card">
<div className="public-preview__label">Gesamtpunktzahl</div>
<div className="public-preview__value public-preview__value--success">87%</div>
<div className="public-progress">
<span style={{ width: '87%' }} />
</div>
</div>
</div>
<div className="public-preview__meta">
<div className="public-preview__meta-card">
<div className="public-preview__label">Aktive Bewertung</div>
<strong>Stefan · PAT 1</strong>
</div>
<div className="public-preview__meta-card">
<div className="public-preview__label">Status</div>
<strong>Freigabe aktiv</strong>
</div>
<div className="public-preview__meta-card">
<div className="public-preview__label">Workflow</div>
<strong>Erfassen · Finalisieren</strong>
</div>
<div className="public-preview__meta-card">
<div className="public-preview__label">Auswertung</div>
<strong>PDF & Analyse</strong>
</div>
</div>
</div>
</section>
<section className="public-section" id="features">
<div className="public-section__header">
<span className="public-section__tag">Funktionen</span>
<h2 className="public-section__title">Alles, was du für PAT-Tests brauchst</h2>
<p className="public-section__copy">
Von der digitalen Erfassung bis zur teamweiten Auswertung. Die neue Startseite orientiert sich an deiner
Vorlage, sitzt aber sauber im bestehenden Produktstil.
</p>
</div>
<div className="public-features">
{features.map(({ title, body, icon: Icon }) => (
<article key={title} className="public-card">
<span className="public-card__icon">
<Icon className="h-5 w-5" aria-hidden="true" />
</span>
<h3 className="public-card__title">{title}</h3>
<p className="public-card__copy">{body}</p>
</article>
))}
</div>
</section>
<section className="public-section" id="how-it-works">
<div className="public-section__header">
<span className="public-section__tag">Prozess</span>
<h2 className="public-section__title">So läuft PAT mit uns</h2>
<p className="public-section__copy">Von der Testvorlage bis zum Report bleibt der Ablauf kurz, klar und sicher.</p>
</div>
<div className="public-steps">
{steps.map((step, index) => (
<article key={step.title} className="public-card public-step">
<span className="public-step__number">{index + 1}</span>
<h3 className="public-step__title">{step.title}</h3>
<p className="public-step__copy">{step.body}</p>
</article>
))}
</div>
</section>
<section className="public-section" id="stats">
<div className="public-section__header">
<span className="public-section__tag">Statistiken</span>
<h2 className="public-section__title">Klare Vorteile für den Alltag am Tisch</h2>
<p className="public-section__copy">
Weniger Listenpflege, weniger Medienbrüche und deutlich schneller von der Eingabe zur Auswertung.
</p>
</div>
<div className="public-stats">
{stats.map((item) => (
<article key={item.label} className="public-stats__card">
<div className="public-stats__value">{item.value}</div>
<div className="public-stats__label">{item.label}</div>
</article>
))}
</div>
</section>
<section className="public-section">
<div className="public-cta-panel">
<div>
<span className="public-section__tag">Bereit?</span>
<h2 className="public-cta-panel__title">Starte deine nächste PAT-Bewertung ohne Tabellenchaos.</h2>
<p className="public-cta-panel__copy">
Login öffnen, Bewertung anlegen, Werte eintragen und Ergebnisse direkt sauber speichern oder teilen.
</p>
</div>
<div className="public-cta-panel__actions">
<button type="button" className="public-site__button" onClick={onGetStarted}>
Jetzt kostenlos ausprobieren
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</button>
<button type="button" className="public-site__button--secondary" onClick={() => onNavigate('/kontakt')}>
Kontakt öffnen
</button>
</div>
</div>
</section>
</PublicLayout>
)
}

View File

@@ -0,0 +1,131 @@
import React, { useMemo } from 'react'
import { Activity, BarChart3, Clock, TrendingUp } from 'lucide-react'
import { calculateTotal } from '../utils/patCalculations'
const formatDate = (value) => {
if (!value) return '—'
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return value
return new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(parsed)
}
export default function LiveOverview({ assessments, loading = false, error }) {
const stats = useMemo(() => {
if (loading || !assessments) {
return {
total: 0,
average: 0,
latest: null,
best: null
}
}
if (!assessments?.length) {
return {
total: 0,
average: 0,
latest: null,
best: null
}
}
const scored = assessments.map((entry) => ({
...entry,
totalPoints: Math.round(calculateTotal(entry.exercises || []))
}))
const average =
scored.reduce((sum, item) => sum + (item.totalPoints || 0), 0) / (scored.length || 1)
const latest = scored[0]
const best = scored.reduce((top, current) =>
!top || (current.totalPoints || 0) > (top.totalPoints || 0) ? current : top,
null)
return {
total: scored.length,
average: Math.round(average),
latest,
best
}
}, [assessments])
return (
<div className="bg-white/90 dark:bg-gray-900/80 border border-gray-100 dark:border-gray-800 rounded-2xl shadow-lg p-6 mb-6">
<div className="flex items-center justify-between flex-wrap gap-3 mb-4">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-emerald-600 dark:text-emerald-300">Live Übersicht</p>
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100">Aktuelle Statistik</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">Basis: alle Bewertungen, geladen beim Seitenaufruf</p>
</div>
<span className="rounded-full border border-emerald-200/70 dark:border-emerald-700 px-3 py-1 text-xs font-semibold text-emerald-700 dark:text-emerald-200 bg-emerald-50 dark:bg-emerald-900/40">
{loading ? 'Lädt…' : 'Stand: jetzt'}
</span>
</div>
{error && (
<div className="mb-3 rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/30 p-3 text-sm text-red-700 dark:text-red-200">
{error}
</div>
)}
{loading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[1, 2, 3, 4].map((key) => (
<div key={key} className="h-28 rounded-2xl border border-gray-200/60 dark:border-gray-800 bg-gray-100 dark:bg-gray-800 animate-pulse" />
))}
</div>
) : stats.total === 0 ? (
<div className="rounded-xl border border-dashed border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/60 p-4 text-sm text-gray-600 dark:text-gray-300">
Noch keine Bewertungen vorhanden. Lege eine neue Bewertung an, um hier Live-Werte zu sehen.
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="rounded-2xl border border-emerald-200/70 dark:border-emerald-800 bg-gradient-to-br from-emerald-50 via-white to-emerald-50 dark:from-emerald-900/30 dark:via-gray-900 dark:to-emerald-900/20 p-4 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-emerald-700 dark:text-emerald-200">Bewertungen</p>
<Activity className="h-4 w-4 text-emerald-600 dark:text-emerald-300" aria-hidden="true" />
</div>
<p className="mt-3 text-3xl font-bold text-emerald-800 dark:text-emerald-100">{stats.total}</p>
<p className="text-xs text-emerald-700/80 dark:text-emerald-200/70">In der PAT Datenbank hinterlegt</p>
</div>
<div className="rounded-2xl border border-sky-200/70 dark:border-sky-800 bg-gradient-to-br from-sky-50 via-white to-sky-50 dark:from-sky-900/30 dark:via-gray-900 dark:to-sky-900/20 p-4 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-sky-700 dark:text-sky-200">Ø Gesamtpunkte</p>
<TrendingUp className="h-4 w-4 text-sky-600 dark:text-sky-300" aria-hidden="true" />
</div>
<p className="mt-3 text-3xl font-bold text-sky-800 dark:text-sky-100">{stats.average}</p>
<p className="text-xs text-sky-700/80 dark:text-sky-200/70">Durchschnitt aller Assessments</p>
</div>
<div className="rounded-2xl border border-indigo-200/70 dark:border-indigo-800 bg-gradient-to-br from-indigo-50 via-white to-indigo-50 dark:from-indigo-900/30 dark:via-gray-900 dark:to-indigo-900/20 p-4 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-indigo-700 dark:text-indigo-200">Letzte Bewertung</p>
<Clock className="h-4 w-4 text-indigo-600 dark:text-indigo-300" aria-hidden="true" />
</div>
<p className="mt-3 text-lg font-semibold text-indigo-900 dark:text-indigo-100">
{stats.latest?.name || stats.latest?.patType || 'Unbenannt'}
</p>
<p className="text-xs text-indigo-700/80 dark:text-indigo-200/70">
{formatDate(stats.latest?.datum)} · {stats.latest?.patType || 'PAT'}
</p>
</div>
<div className="rounded-2xl border border-amber-200/70 dark:border-amber-800 bg-gradient-to-br from-amber-50 via-white to-amber-50 dark:from-amber-900/30 dark:via-gray-900 dark:to-amber-900/20 p-4 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-amber-700 dark:text-amber-200">Top Ergebnis</p>
<BarChart3 className="h-4 w-4 text-amber-600 dark:text-amber-300" aria-hidden="true" />
</div>
<p className="mt-3 text-3xl font-bold text-amber-800 dark:text-amber-100">
{stats.best?.totalPoints ?? 0}
</p>
<p className="text-xs text-amber-700/80 dark:text-amber-200/70">
{stats.best?.name || stats.best?.patType || '—'} · {stats.best?.patType || 'PAT'}
</p>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,81 @@
import React from 'react';
import { normalizeExerciseValue } from '../../utils/exerciseInputRules';
const inputClassName = (disabled) =>
`px-3 py-2 border border-gray-300 dark:border-gray-700 rounded text-center bg-white dark:bg-gray-900 ${
disabled ? 'opacity-70 cursor-not-allowed bg-gray-100 dark:bg-gray-800' : ''
}`;
const ExerciseInput = ({ exercise, exIndex, onChange, disabled = false }) => {
const handleInputChange = (valueIndex, subType = null) => (event) => {
onChange(exIndex, valueIndex, normalizeExerciseValue(exercise, event.target.value, subType), subType);
};
if (exercise.subA && exercise.subB) {
const [subLabelA = 'a)', subLabelB = 'b)'] = exercise.subLabels || [];
return (
<>
<div className="grid grid-cols-5 gap-2 mb-2">
<div></div>
<div className="font-semibold">{subLabelA}</div>
{exercise.subA.map((val, i) => (
<input
key={`a-${i}`}
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="off"
value={val}
onChange={handleInputChange(i, 'a')}
disabled={disabled}
className={inputClassName(disabled)}
/>
))}
</div>
<div className="grid grid-cols-5 gap-2 mb-2">
<div></div>
<div className="font-semibold">{subLabelB}</div>
{exercise.subB.map((val, i) => (
<input
key={`b-${i}`}
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="off"
value={val}
onChange={handleInputChange(i, 'b')}
disabled={disabled}
className={inputClassName(disabled)}
/>
))}
</div>
</>
);
}
const gridCols =
exercise.values.length === 4 ? 'grid-cols-6' : exercise.values.length === 5 ? 'grid-cols-7' : 'grid-cols-5';
return (
<div className={`grid ${gridCols} gap-2 mb-2`}>
<div></div>
<div></div>
{exercise.values.map((val, i) => (
<input
key={i}
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="off"
value={val}
onChange={handleInputChange(i)}
disabled={disabled}
className={inputClassName(disabled)}
/>
))}
</div>
);
};
export default ExerciseInput;

View File

@@ -0,0 +1,227 @@
import React from 'react';
import { CheckCircle2, FileDown, Lock, Save, Share2 } from 'lucide-react';
import ExerciseInput from './ExerciseInput';
import SaveDialog from './SaveDialog';
import { downloadAssessmentPdf } from '../../utils/pdfExport';
const PatDetail = ({
currentAssessment,
hasUnsavedChanges,
onBack,
onSave,
onAssessmentChange,
onUpdateExercise,
onMarkDirty,
showSaveDialog,
onSaveAndExit,
onDiscardAndExit,
onCancelExit,
getPatTypeColor,
getAchievement,
calculateTotal,
isReadOnly = false,
canFinalize = false,
finalizeDisabledReason = '',
onFinalize,
isShareActive = false,
canShare = false,
shareDisabledReason = '',
onShare,
onRevokeShare
}) => {
if (!currentAssessment) return null;
const totalPoints = calculateTotal();
const achievement = getAchievement(currentAssessment?.patType, totalPoints);
const handleDownloadPdf = async () => {
await downloadAssessmentPdf({
assessment: currentAssessment,
totalPoints,
achievement
});
};
return (
<div className="min-h-screen bg-gray-100 dark:bg-gray-950 p-6 text-gray-900 dark:text-gray-100">
<div className="max-w-5xl mx-auto">
<SaveDialog
show={showSaveDialog}
onSave={onSaveAndExit}
onDiscard={onDiscardAndExit}
onCancel={onCancelExit}
/>
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 border border-gray-200 dark:border-gray-800">
<div className="flex justify-between items-center mb-6">
<div className="flex items-center gap-4">
<button
onClick={onBack}
className="text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-gray-100 font-medium"
>
Zurück zur Übersicht
</button>
{hasUnsavedChanges && (
<span className="text-orange-500 dark:text-orange-300 text-sm font-semibold">
Ungespeicherte Änderungen
</span>
)}
<span
className={`px-4 py-2 rounded-full text-sm font-semibold ${getPatTypeColor(
currentAssessment?.patType
)}`}
>
{currentAssessment?.patType}
</span>
{isShareActive && (
<span className="px-4 py-2 rounded-full text-sm font-semibold bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-200">
Freigabe aktiv
</span>
)}
{currentAssessment?.isFinalized && (
<span className="px-4 py-2 rounded-full text-sm font-semibold bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200">
Final abgeschlossen
</span>
)}
</div>
<div className="flex gap-2">
{!isReadOnly && (
<>
<button
onClick={onSave}
className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition flex items-center gap-2 text-sm"
>
<Save className="w-4 h-4" aria-hidden="true" />
Speichern
</button>
<button
onClick={onFinalize}
disabled={!canFinalize}
title={finalizeDisabledReason || 'Test final abschließen'}
className={`px-4 py-2 rounded-lg transition flex items-center gap-2 text-sm ${
canFinalize
? 'bg-emerald-700 text-white hover:bg-emerald-800'
: 'bg-gray-300 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed'
}`}
>
<CheckCircle2 className="w-4 h-4" aria-hidden="true" />
Final Abschließen
</button>
</>
)}
<button
onClick={handleDownloadPdf}
className="bg-amber-600 text-white px-4 py-2 rounded-lg hover:bg-amber-700 transition flex items-center gap-2 text-sm"
>
<FileDown className="w-4 h-4" aria-hidden="true" />
PDF
</button>
<button
onClick={onShare}
disabled={!canShare}
title={shareDisabledReason || 'Test als Link teilen'}
className={`px-4 py-2 rounded-lg transition flex items-center gap-2 text-sm ${
canShare
? 'bg-sky-600 text-white hover:bg-sky-700'
: 'bg-gray-300 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed'
}`}
>
<Share2 className="w-4 h-4" aria-hidden="true" />
Teilen
</button>
{isShareActive && (
<button
onClick={onRevokeShare}
className="border border-rose-300 text-rose-700 dark:border-rose-700 dark:text-rose-300 px-4 py-2 rounded-lg hover:bg-rose-50 dark:hover:bg-rose-950/40 transition text-sm"
>
Freigabe aufheben
</button>
)}
</div>
</div>
{isReadOnly && (
<div className="mb-6 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-emerald-800 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-200">
<div className="flex items-center gap-2 font-semibold">
<Lock className="h-4 w-4" aria-hidden="true" />
Nur Lese-Modus
</div>
<p className="mt-1 text-sm">
Diese Bewertung wurde final abgeschlossen und kann nicht mehr bearbeitet werden.
</p>
</div>
)}
<div className="grid grid-cols-4 gap-2 mb-6 bg-gray-50 dark:bg-gray-800 p-4 rounded">
<div className="font-bold">Datum / Name</div>
<div></div>
<input
type="date"
value={currentAssessment?.datum || ''}
disabled={isReadOnly}
onChange={(e) => {
onAssessmentChange({ ...currentAssessment, datum: e.target.value });
onMarkDirty();
}}
className={`px-3 py-2 border border-gray-300 dark:border-gray-700 rounded bg-white dark:bg-gray-900 ${
isReadOnly ? 'opacity-70 cursor-not-allowed bg-gray-100 dark:bg-gray-800' : ''
}`}
/>
<input
type="text"
value={currentAssessment?.name || ''}
disabled={isReadOnly}
onChange={(e) => {
onAssessmentChange({ ...currentAssessment, name: e.target.value });
onMarkDirty();
}}
placeholder="Name"
className={`px-3 py-2 border border-gray-300 dark:border-gray-700 rounded bg-white dark:bg-gray-900 ${
isReadOnly ? 'opacity-70 cursor-not-allowed bg-gray-100 dark:bg-gray-800' : ''
}`}
/>
</div>
{currentAssessment?.exercises.map((exercise, exIndex) => (
<div key={exIndex} className="mb-6 border-b pb-4 border-gray-200 dark:border-gray-800">
<div className="font-bold text-lg mb-2 text-gray-700 dark:text-gray-200">{exercise.name}</div>
<ExerciseInput exercise={exercise} exIndex={exIndex} onChange={onUpdateExercise} disabled={isReadOnly} />
<div className="grid grid-cols-5 gap-2 mb-1 bg-blue-50 dark:bg-blue-900/30 p-2 rounded">
<div className="font-semibold">Durchschnitt Soll {exercise.soll}</div>
<div className="font-semibold">Ist</div>
<div className="font-bold text-center">{exercise.durchschnitt.toFixed(2)}</div>
</div>
<div className="grid grid-cols-5 gap-2 mb-1">
<div>Faktor</div>
<div></div>
<div className="text-center font-semibold">{exercise.faktor}</div>
</div>
<div className="grid grid-cols-5 gap-2 bg-green-50 dark:bg-green-900/30 p-2 rounded">
<div className="font-bold">Points</div>
<div></div>
<div className="text-center font-bold text-green-600 dark:text-green-300">{exercise.points.toFixed(0)}</div>
</div>
</div>
))}
<div className="mt-6 bg-gradient-to-r from-green-100 to-blue-100 dark:from-gray-800 dark:to-gray-700 p-6 rounded-lg">
<div className="flex justify-between items-center mb-4">
<span className="text-2xl font-bold text-gray-800 dark:text-gray-100">Ergebnis</span>
<span className="text-4xl font-bold text-green-600 dark:text-green-300">{totalPoints.toFixed(0)}</span>
</div>
<div className={`mt-4 px-6 py-4 rounded-lg ${achievement.color} ${achievement.text} text-center`}>
<p className="text-2xl font-bold">{achievement.name}</p>
{achievement.subtitle && <p className="text-lg mt-2">{achievement.subtitle}</p>}
</div>
</div>
</div>
</div>
</div>
);
};
export default PatDetail;

View File

@@ -0,0 +1,36 @@
import React from 'react';
const SaveDialog = ({ show, onSave, onDiscard, onCancel }) => {
if (!show) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl p-6 max-w-md w-full mx-4 border border-gray-200 dark:border-gray-800">
<h3 className="text-xl font-bold text-gray-800 dark:text-gray-100 mb-4">Ungespeicherte Änderungen</h3>
<p className="text-gray-600 dark:text-gray-300 mb-6">Sie haben ungespeicherte Änderungen. Möchten Sie diese speichern?</p>
<div className="flex gap-3">
<button
onClick={onSave}
className="flex-1 bg-green-600 text-white px-4 py-3 rounded-lg hover:bg-green-700 transition font-semibold"
>
Ja, speichern
</button>
<button
onClick={onDiscard}
className="flex-1 bg-red-600 text-white px-4 py-3 rounded-lg hover:bg-red-700 transition font-semibold"
>
Nein, verwerfen
</button>
<button
onClick={onCancel}
className="flex-1 bg-gray-500 dark:bg-gray-700 text-white px-4 py-3 rounded-lg hover:bg-gray-600 dark:hover:bg-gray-600 transition font-semibold"
>
Abbrechen
</button>
</div>
</div>
</div>
);
};
export default SaveDialog;

View File

@@ -0,0 +1,526 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
ArrowUpDown,
Calendar,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Plus,
Trash2,
User
} from 'lucide-react';
const CALENDAR_DAY_LABELS = ['M', 'D', 'M', 'D', 'F', 'S', 'S'];
const toIsoDate = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const parseIsoDate = (value) => {
if (!value || !value.includes('-')) return null;
const [year, month, day] = value.split('-').map(Number);
return new Date(year, month - 1, day);
};
const getCalendarDays = (monthDate) => {
const startOfMonth = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1);
const offset = (startOfMonth.getDay() + 6) % 7;
const calendarStart = new Date(startOfMonth.getFullYear(), startOfMonth.getMonth(), 1 - offset);
return Array.from({ length: 42 }, (_, index) => {
const day = new Date(calendarStart.getFullYear(), calendarStart.getMonth(), calendarStart.getDate() + index);
return {
key: toIsoDate(day),
date: day,
isoValue: toIsoDate(day),
isCurrentMonth: day.getMonth() === monthDate.getMonth()
};
});
};
const PatList = ({
patTypes,
assessments,
overview,
assessmentShareTokens = {},
onCreate,
onEdit,
onDelete,
getPatTypeColor,
getAchievement
}) => {
const datePickerRef = useRef(null);
const [showCreateMenu, setShowCreateMenu] = useState(false);
const [activeDatePicker, setActiveDatePicker] = useState(null);
const [sortConfig, setSortConfig] = useState({ key: 'datum', direction: 'desc' });
const [filters, setFilters] = useState({ name: '', datum: '' });
const [visibleMonth, setVisibleMonth] = useState(() => {
const today = new Date();
return new Date(today.getFullYear(), today.getMonth(), 1);
});
const patTypeOptions = Object.keys(patTypes || {});
const openAssessments = assessments.filter((assessment) => !assessment.isFinalized);
const finalizedAssessments = assessments.filter((assessment) => assessment.isFinalized);
const getResultValue = (assessment) =>
assessment.exercises.reduce((sum, ex) => sum + (ex.points || 0), 0);
const normalize = (value) => String(value || '').toLowerCase().trim();
const formatDateForFilter = (value) => {
if (!value || !value.includes('-')) return '';
const [year, month, day] = value.split('-');
return `${day}.${month}.${year}`;
};
const formatDateForRow = (value) => {
if (!value || !value.includes('-')) return value || '';
const [year, month, day] = value.split('-');
return `${year}.${month}.${day}`;
};
const monthLabel = useMemo(
() =>
new Intl.DateTimeFormat('de-DE', {
month: 'long',
year: 'numeric'
}).format(visibleMonth),
[visibleMonth]
);
const calendarDays = useMemo(() => getCalendarDays(visibleMonth), [visibleMonth]);
useEffect(() => {
if (!activeDatePicker) return undefined;
const handlePointerDown = (event) => {
if (datePickerRef.current && !datePickerRef.current.contains(event.target)) {
setActiveDatePicker(null);
}
};
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
setActiveDatePicker(null);
}
};
document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [activeDatePicker]);
const matchesFilters = (assessment) => {
const matchesName = !filters.name || normalize(assessment.name).includes(normalize(filters.name));
const matchesDate = !filters.datum || normalize(assessment.datum).includes(normalize(filters.datum));
return matchesName && matchesDate;
};
const getSortValue = (assessment, key) => {
if (key === 'name') return (assessment.name || '').toLowerCase();
if (key === 'datum') return assessment.datum || '';
if (key === 'result') return getResultValue(assessment);
return '';
};
const sortAssessments = (entries) =>
[...entries].sort((left, right) => {
const leftValue = getSortValue(left, sortConfig.key);
const rightValue = getSortValue(right, sortConfig.key);
if (leftValue < rightValue) return sortConfig.direction === 'asc' ? -1 : 1;
if (leftValue > rightValue) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
});
const sortedOpenAssessments = useMemo(
() => sortAssessments(openAssessments.filter(matchesFilters)),
[openAssessments, sortConfig, filters]
);
const sortedFinalizedAssessments = useMemo(
() => sortAssessments(finalizedAssessments.filter(matchesFilters)),
[finalizedAssessments, sortConfig, filters]
);
const handleSort = (key) => {
setSortConfig((current) =>
current.key === key
? { key, direction: current.direction === 'asc' ? 'desc' : 'asc' }
: { key, direction: key === 'datum' ? 'desc' : 'asc' }
);
};
const renderSortIcon = (key) => {
if (sortConfig.key !== key) {
return <ArrowUpDown className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true" />;
}
return sortConfig.direction === 'asc' ? (
<ChevronUp className="w-4 h-4" aria-hidden="true" />
) : (
<ChevronDown className="w-4 h-4" aria-hidden="true" />
);
};
const handleFilterChange = (key, value) => {
setFilters((current) => ({
...current,
[key]: value
}));
};
const syncVisibleMonth = (value) => {
const parsedDate = parseIsoDate(value);
const baseDate = parsedDate || new Date();
setVisibleMonth(new Date(baseDate.getFullYear(), baseDate.getMonth(), 1));
};
const toggleDatePicker = (tableKey) => {
setActiveDatePicker((current) => {
if (current !== tableKey) {
syncVisibleMonth(filters.datum);
return tableKey;
}
return null;
});
};
const selectDate = (value) => {
handleFilterChange('datum', value);
syncVisibleMonth(value);
setActiveDatePicker(null);
};
const clearDate = () => {
handleFilterChange('datum', '');
syncVisibleMonth('');
setActiveDatePicker(null);
};
const selectToday = () => {
selectDate(toIsoDate(new Date()));
};
const renderAssessmentTable = (tableKey, title, entries, totalEntries, emptyMessage) => {
const isDatePickerOpen = activeDatePicker === tableKey;
return (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-lg border border-gray-100 dark:border-gray-800 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/70">
<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">
Anzahl: {entries.length}
{entries.length !== totalEntries ? ` von ${totalEntries}` : ''}
</p>
</div>
{totalEntries === 0 ? (
<div className="px-6 py-8 text-sm text-gray-500 dark:text-gray-400">{emptyMessage}</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-100 dark:bg-gray-800 text-left">
<tr>
<th className="px-6 py-3 text-sm font-semibold text-gray-700 dark:text-gray-200">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => handleSort('name')}
className="inline-flex items-center gap-2 hover:text-gray-900 dark:hover:text-white transition"
>
Name
{renderSortIcon('name')}
</button>
<input
type="text"
value={filters.name}
onChange={(event) => handleFilterChange('name', event.target.value)}
aria-label="Nach Name filtern"
className="w-40 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-950 px-3 py-2 text-sm font-normal text-gray-700 dark:text-gray-200"
/>
</div>
</th>
<th className="px-6 py-3 text-sm font-semibold text-gray-700 dark:text-gray-200">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => handleSort('datum')}
className="inline-flex items-center gap-2 hover:text-gray-900 dark:hover:text-white transition"
>
Datum
{renderSortIcon('datum')}
</button>
<div ref={isDatePickerOpen ? datePickerRef : null} className="relative">
<button
type="button"
onClick={() => toggleDatePicker(tableKey)}
aria-label="Nach Datum filtern"
aria-expanded={isDatePickerOpen}
className="flex w-36 items-center justify-between rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-950 px-3 py-2 text-left text-sm font-normal text-gray-700 dark:text-gray-200"
>
<span>{formatDateForFilter(filters.datum) || 'tt.mm.jjjj'}</span>
<Calendar className="h-4 w-4 text-gray-500 dark:text-gray-400" aria-hidden="true" />
</button>
{isDatePickerOpen && (
<div className="absolute left-0 top-full z-20 mt-2 w-80 rounded-xl border border-gray-200 bg-white p-3 shadow-2xl dark:border-gray-700 dark:bg-gray-900">
<div className="mb-3 flex items-center justify-between">
<button
type="button"
onClick={() =>
setVisibleMonth(
(current) => new Date(current.getFullYear(), current.getMonth() - 1, 1)
)
}
className="rounded-lg p-2 text-gray-500 transition hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
aria-label="Vorheriger Monat"
>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
</button>
<span className="text-sm font-semibold capitalize text-gray-800 dark:text-gray-100">
{monthLabel}
</span>
<button
type="button"
onClick={() =>
setVisibleMonth(
(current) => new Date(current.getFullYear(), current.getMonth() + 1, 1)
)
}
className="rounded-lg p-2 text-gray-500 transition hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
aria-label="Nächster Monat"
>
<ChevronRight className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<div className="grid grid-cols-7 gap-1 text-center text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{CALENDAR_DAY_LABELS.map((label, index) => (
<span key={`${label}-${index}`}>{label}</span>
))}
</div>
<div className="mt-2 grid grid-cols-7 gap-1">
{calendarDays.map((day) => {
const isSelected = day.isoValue === filters.datum;
return (
<button
key={day.key}
type="button"
onClick={() => selectDate(day.isoValue)}
className={`flex h-10 items-center justify-center rounded-lg text-sm transition ${
isSelected
? 'bg-indigo-600 text-white'
: day.isCurrentMonth
? 'text-gray-800 hover:bg-gray-100 dark:text-gray-100 dark:hover:bg-gray-800'
: 'text-gray-400 hover:bg-gray-100 dark:text-gray-600 dark:hover:bg-gray-800'
}`}
>
{day.date.getDate()}
</button>
);
})}
</div>
<div className="mt-3 flex items-center justify-between border-t border-gray-200 pt-3 dark:border-gray-700">
<button
type="button"
onClick={selectToday}
className="rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
>
Heute
</button>
<button
type="button"
onClick={clearDate}
className="rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
>
Löschen
</button>
</div>
</div>
)}
</div>
</div>
</th>
<th className="px-6 py-3 text-sm font-semibold text-gray-700 dark:text-gray-200">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => handleSort('result')}
className="inline-flex items-center gap-2 hover:text-gray-900 dark:hover:text-white transition"
>
Ergebnis
{renderSortIcon('result')}
</button>
</div>
</th>
<th className="px-6 py-3 text-sm font-semibold text-gray-700 dark:text-gray-200 text-right"></th>
</tr>
</thead>
<tbody>
{entries.length === 0 ? (
<tr className="border-t border-gray-200 dark:border-gray-800">
<td colSpan="4" className="px-6 py-8 text-sm text-gray-500 dark:text-gray-400">
{emptyMessage}
</td>
</tr>
) : entries.map((assessment) => {
const shareToken = assessmentShareTokens[assessment.id];
const hasShare = Boolean(shareToken);
const totalPoints = getResultValue(assessment);
const achievement = getAchievement(assessment.patType, totalPoints);
return (
<tr
key={assessment.id}
className="border-t border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/40"
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2 text-sm">
<span className="font-semibold text-gray-900 dark:text-gray-100">{assessment.name}</span>
<span
className={`px-3 py-1 rounded-full text-xs font-semibold ${getPatTypeColor(
assessment.patType
)}`}
>
{assessment.patType}
</span>
{hasShare && (
<span className="px-3 py-1 rounded-full text-xs font-semibold bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-200">
Freigabe aktiv
</span>
)}
{assessment.isFinalized && (
<span className="px-3 py-1 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200">
Final abgeschlossen
</span>
)}
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300 whitespace-nowrap">
<div className="inline-flex items-center gap-2">
<Calendar className="w-4 h-4" aria-hidden="true" />
{formatDateForRow(assessment.datum)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-3">
<span className="text-xl font-bold text-green-600 dark:text-green-300">
{totalPoints.toFixed(0)}
</span>
<span className={`inline-flex px-2 py-1 rounded text-xs font-semibold ${achievement.color} ${achievement.text}`}>
{achievement.name}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => onEdit(assessment)}
className="bg-indigo-600 text-white px-4 py-2 rounded hover:bg-indigo-700 transition text-sm"
>
Öffnen
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(assessment.id);
}}
className="text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 p-2"
title="Löschen"
>
<Trash2 className="w-5 h-5" aria-hidden="true" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
};
return (
<div className="min-h-screen bg-gradient-to-br from-green-50 to-blue-100 dark:from-gray-950 dark:to-gray-900 p-6 text-gray-900 dark:text-gray-100">
<div className="max-w-7xl mx-auto">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 mb-6 border border-gray-100 dark:border-gray-800">
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold text-gray-800 dark:text-gray-100">PAT Test Manager</h1>
<div className="relative">
<button
onClick={() => setShowCreateMenu((current) => !current)}
className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition flex items-center justify-center gap-2"
>
<Plus className="w-4 h-4" />
Neue Bewertung
</button>
{showCreateMenu && (
<div className="absolute right-0 top-full mt-2 w-48 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-lg z-10 overflow-hidden">
{patTypeOptions.map((patType) => (
<button
key={patType}
type="button"
onClick={() => {
setShowCreateMenu(false);
onCreate(patType);
}}
className="w-full px-4 py-3 text-left text-sm font-medium text-gray-800 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 transition"
>
{patType}
</button>
))}
</div>
)}
</div>
</div>
</div>
{overview && <div className="mt-6">{overview}</div>}
{assessments.length > 0 && (
<div className="space-y-6">
{renderAssessmentTable(
'open',
'Noch offene Bewertungen',
sortedOpenAssessments,
openAssessments.length,
'Keine offenen Bewertungen mit den aktuellen Suchfiltern gefunden'
)}
{renderAssessmentTable(
'finalized',
'Abgeschlossene Bewertungen',
sortedFinalizedAssessments,
finalizedAssessments.length,
'Keine abgeschlossenen Bewertungen mit den aktuellen Suchfiltern gefunden'
)}
</div>
)}
{assessments.length === 0 && (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-12 text-center border border-gray-100 dark:border-gray-800">
<User className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-700" aria-hidden="true" />
<p className="text-gray-600 dark:text-gray-300 mb-4">Noch keine Bewertungen vorhanden</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Lege oben eine neue Bewertung an und wähle dabei PAT Start, PAT 1, PAT 2 oder PAT 3</p>
</div>
)}
</div>
</div>
);
};
export default PatList;

View File

@@ -0,0 +1,317 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import PatList from './PatList/PatList';
import PatDetail from './PatDetail/PatDetail';
import AnalysisTab from './Analysis/AnalysisTab';
import { patTypes } from '../data/patTypes';
import { useAssessments } from '../hooks/useAssessments';
import { getAchievement, getPatTypeColor } from '../utils/patCalculations';
import LiveOverview from './LiveOverview';
import { buildAnalysis } from '../utils/analysisEngine';
import { generateTrainingPlan } from '../utils/trainingPlanGenerator';
import { saveTrainingPlanWithSessions } from '../lib/trainingPlanService';
import {
createAssessmentShareLink,
listAssessmentShares,
revokeAssessmentShare
} from '../lib/assessmentShareService';
import { getFinalizeDisabledReason } from '../utils/assessmentState';
const copyToClipboard = async (value) => {
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) return false;
await navigator.clipboard.writeText(value);
return true;
};
const isLocalhostShare = () =>
typeof window !== 'undefined' &&
['localhost', '127.0.0.1'].includes(window.location.hostname);
export default function PatTestManager({
user,
activeTab = 'assessments',
onEditingStateChange
}) {
const visiblePatTypes = useMemo(() => patTypes || {}, []);
const patTypeOptions = Object.keys(visiblePatTypes);
const [showList, setShowList] = useState(true);
const [selectedPatType, setSelectedPatType] = useState(() => patTypeOptions[0] || '');
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [assessmentShareTokens, setAssessmentShareTokens] = useState({});
useEffect(() => {
onEditingStateChange?.(!showList);
}, [onEditingStateChange, showList]);
useEffect(() => {
if (!patTypeOptions.length) return;
if (!selectedPatType || !patTypeOptions.includes(selectedPatType)) {
setSelectedPatType(patTypeOptions[0]);
}
}, [patTypeOptions, selectedPatType]);
const regenerateTrainingPlan = useCallback(async ({ savedAssessment, assessments: nextAssessments }) => {
if (!savedAssessment?.patType || !nextAssessments?.length) return;
const analysis = buildAnalysis(nextAssessments, savedAssessment.patType, {
windowSize: 5,
missingStrategy: 'zero'
});
if (!analysis.windowedAssessments.length) return;
const generated = generateTrainingPlan({
topWeaknesses: analysis.topWeaknesses,
topStrengths: analysis.topStrengths,
durationWeeks: 4,
sessionsPerWeek: 3,
sessionMix: '1-main-2-secondary'
});
const result = await saveTrainingPlanWithSessions({
patType: savedAssessment.patType,
analysisSnapshot: analysis,
durationWeeks: 4,
sessionsPerWeek: 3,
sessions: generated.sessions
});
if (!result.ok) {
console.warn('Automatische Plan-Neugenerierung fehlgeschlagen:', result.error?.message || result.error);
}
}, []);
const {
assessments,
currentAssessment,
setCurrentAssessment,
hasUnsavedChanges,
setHasUnsavedChanges,
createNewAssessment,
openAssessment,
handleSave,
finalizeAssessment,
deleteAssessment,
updateExerciseValue,
calculateTotal
} = useAssessments(visiblePatTypes, user, {
onAfterSave: regenerateTrainingPlan
});
const visibleAssessments = assessments;
const visibleAssessmentIds = useMemo(
() => visibleAssessments.map((assessment) => assessment.id),
[visibleAssessments]
);
const isCurrentAssessmentPersisted = useMemo(
() => assessments.some((assessment) => assessment.id === currentAssessment?.id),
[assessments, currentAssessment?.id]
);
const isCurrentAssessmentShareActive = Boolean(
currentAssessment?.id && assessmentShareTokens[currentAssessment.id]
);
const isCurrentAssessmentReadOnly = Boolean(currentAssessment?.isFinalized);
useEffect(() => {
let ignore = false;
const loadAssessmentShares = async () => {
if (!user?.id || !visibleAssessmentIds.length) {
setAssessmentShareTokens({});
return;
}
try {
const shareTokens = await listAssessmentShares(visibleAssessmentIds);
if (!ignore) {
setAssessmentShareTokens(shareTokens);
}
} catch (error) {
console.warn('Freigaben konnten nicht geladen werden:', error);
if (!ignore) {
setAssessmentShareTokens({});
}
}
};
loadAssessmentShares();
return () => {
ignore = true;
};
}, [user?.id, visibleAssessmentIds]);
const handleBackToList = () => {
if (hasUnsavedChanges && !isCurrentAssessmentReadOnly) {
setShowSaveDialog(true);
} else {
setShowList(true);
}
};
const executeSave = async () => {
const result = await handleSave();
if (result?.ok) {
alert('Erfolgreich gespeichert!');
}
return result;
};
const handleFinalize = useCallback(async () => {
const result = await finalizeAssessment();
if (result?.ok) {
alert('Bewertung wurde final abgeschlossen und ist jetzt nur noch lesbar.');
}
}, [finalizeAssessment]);
const handleSaveAndExit = async () => {
if (!currentAssessment?.name || !currentAssessment?.datum) {
alert('Bitte Name und Datum ausfüllen bevor Sie speichern');
return;
}
const result = await executeSave();
if (result?.ok) {
setShowSaveDialog(false);
setShowList(true);
}
};
const handleDiscardAndExit = () => {
setShowSaveDialog(false);
setHasUnsavedChanges(false);
setShowList(true);
};
const handleCancelExit = () => {
setShowSaveDialog(false);
};
const handleShare = useCallback(async (assessment) => {
try {
const { token, url } = await createAssessmentShareLink(assessment);
const label = assessment?.name || assessment?.patType || 'dieser Test';
const localhostHint = isLocalhostShare()
? '\n\nHinweis: Der Link zeigt aktuell auf localhost und ist nur erreichbar, wenn die App von außen erreichbar ist.'
: '';
setAssessmentShareTokens((prev) => ({
...prev,
[assessment.id]: token
}));
try {
const copied = await copyToClipboard(url);
if (copied) {
alert(`Freigabelink für "${label}" wurde in die Zwischenablage kopiert.${localhostHint}`);
return;
}
} catch (clipboardError) {
console.warn('Kopieren in die Zwischenablage fehlgeschlagen:', clipboardError);
}
window.prompt(`Freigabelink für "${label}"${localhostHint}`, url);
} catch (error) {
alert(`Freigabelink konnte nicht erstellt werden: ${error.message || error}`);
}
}, []);
const handleRevokeShare = useCallback(async (assessment) => {
const label = assessment?.name || assessment?.patType || 'dieser Test';
try {
await revokeAssessmentShare(assessment.id);
setAssessmentShareTokens((prev) => {
const next = { ...prev };
delete next[assessment.id];
return next;
});
alert(`Freigabe für "${label}" wurde aufgehoben.`);
} catch (error) {
alert(`Freigabe konnte nicht aufgehoben werden: ${error.message || error}`);
}
}, []);
const handleShareFromDetail = useCallback(() => {
if (!currentAssessment) return;
if (!isCurrentAssessmentPersisted) {
alert('Bitte den Test zuerst speichern, bevor du ihn teilen kannst.');
return;
}
if (hasUnsavedChanges) {
alert('Bitte erst speichern, damit der aktuelle Stand geteilt wird.');
return;
}
handleShare(currentAssessment);
}, [currentAssessment, handleShare, hasUnsavedChanges, isCurrentAssessmentPersisted]);
const handleRevokeShareFromDetail = useCallback(() => {
if (!currentAssessment || !isCurrentAssessmentShareActive) return;
handleRevokeShare(currentAssessment);
}, [currentAssessment, handleRevokeShare, isCurrentAssessmentShareActive]);
const shareDisabledReason = !isCurrentAssessmentPersisted
? 'Bitte zuerst speichern'
: hasUnsavedChanges
? 'Bitte erst speichern, damit der aktuelle Stand geteilt wird'
: '';
const finalizeDisabledReason = getFinalizeDisabledReason(currentAssessment);
if (!showList) {
return (
<PatDetail
currentAssessment={currentAssessment}
hasUnsavedChanges={hasUnsavedChanges}
onBack={handleBackToList}
onSave={executeSave}
onAssessmentChange={setCurrentAssessment}
onUpdateExercise={updateExerciseValue}
onMarkDirty={() => setHasUnsavedChanges(true)}
showSaveDialog={showSaveDialog}
onSaveAndExit={handleSaveAndExit}
onDiscardAndExit={handleDiscardAndExit}
onCancelExit={handleCancelExit}
getPatTypeColor={getPatTypeColor}
getAchievement={getAchievement}
calculateTotal={calculateTotal}
isReadOnly={isCurrentAssessmentReadOnly}
canFinalize={!finalizeDisabledReason}
finalizeDisabledReason={finalizeDisabledReason}
onFinalize={handleFinalize}
isShareActive={isCurrentAssessmentShareActive}
canShare={isCurrentAssessmentPersisted && !hasUnsavedChanges}
shareDisabledReason={shareDisabledReason}
onShare={handleShareFromDetail}
onRevokeShare={handleRevokeShareFromDetail}
/>
);
}
if (activeTab === 'analysis') {
return (
<AnalysisTab
assessments={visibleAssessments}
selectedPatType={selectedPatType}
onPatTypeChange={setSelectedPatType}
patTypes={visiblePatTypes}
user={user}
/>
);
}
return (
<PatList
patTypes={visiblePatTypes}
assessments={visibleAssessments}
overview={<LiveOverview assessments={visibleAssessments} />}
assessmentShareTokens={assessmentShareTokens}
selectedPatType={selectedPatType}
onPatTypeChange={setSelectedPatType}
onCreate={(patType) => {
createNewAssessment(patType);
setShowList(false);
}}
onEdit={(assessment) => {
openAssessment(assessment);
setShowList(false);
}}
onDelete={deleteAssessment}
getPatTypeColor={getPatTypeColor}
getAchievement={getAchievement}
/>
);
}

View File

@@ -0,0 +1,76 @@
import React, { useEffect, useState } from 'react'
import SharedAssessmentView from './SharedAssessmentView'
import { getSharedAssessment } from '../lib/assessmentShareService'
export default function SharedAssessmentPage({ shareToken, themeToggle }) {
const [assessment, setAssessment] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
let ignore = false
const loadAssessment = async () => {
if (!shareToken) {
setError('Freigabelink fehlt.')
setLoading(false)
return
}
setLoading(true)
setError('')
try {
const sharedAssessment = await getSharedAssessment(shareToken)
if (ignore) return
if (!sharedAssessment) {
setAssessment(null)
setError('Dieser Freigabelink ist ungültig oder nicht mehr verfügbar.')
return
}
setAssessment(sharedAssessment)
} catch (err) {
if (ignore) return
setAssessment(null)
setError(err.message || 'Geteilter Test konnte nicht geladen werden.')
} finally {
if (!ignore) {
setLoading(false)
}
}
}
loadAssessment()
return () => {
ignore = true
}
}, [shareToken])
return (
<div className="min-h-screen bg-gray-100 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
<div className="absolute top-4 right-4 z-20">{themeToggle}</div>
{loading ? (
<div className="min-h-screen flex items-center justify-center p-6">
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm p-6">
Geteilter Test wird geladen...
</div>
</div>
) : error ? (
<div className="min-h-screen flex items-center justify-center p-6">
<div className="max-w-xl w-full bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm p-6">
<p className="text-xs uppercase tracking-[0.2em] text-rose-600 dark:text-rose-300">Freigabe</p>
<h1 className="text-2xl font-bold mt-2">Geteilter Test nicht verfügbar</h1>
<p className="text-gray-600 dark:text-gray-300 mt-3">{error}</p>
</div>
</div>
) : (
<SharedAssessmentView assessment={assessment} />
)}
</div>
)
}

View File

@@ -0,0 +1,137 @@
import React from 'react'
import { calculateTotal, getAchievement, getPatTypeColor } from '../utils/patCalculations'
const formatDate = (value) => {
if (!value) return '—'
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return value
return new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(parsed)
}
const formatValue = (value) => {
if (value === '' || value === null || typeof value === 'undefined') return '—'
return value
}
const ValueRow = ({ label, values = [] }) => (
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-10 text-sm font-semibold text-gray-600 dark:text-gray-300">{label}</span>
{values.map((value, index) => (
<span
key={`${label}-${index}`}
className="min-w-12 rounded-md border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-center text-sm font-medium text-gray-800 dark:text-gray-100"
>
{formatValue(value)}
</span>
))}
</div>
)
export default function SharedAssessmentView({ assessment }) {
if (!assessment) return null
const totalPoints = calculateTotal(assessment.exercises || [])
const achievement = getAchievement(assessment.patType, totalPoints)
return (
<div className="min-h-screen bg-gray-100 dark:bg-gray-950 p-6 text-gray-900 dark:text-gray-100">
<div className="max-w-5xl mx-auto">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 border border-gray-200 dark:border-gray-800">
<div className="flex justify-between items-start gap-4 mb-6 flex-wrap">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-sky-700 dark:text-sky-300">Geteilter Test</p>
<h1 className="text-3xl font-bold text-gray-800 dark:text-gray-100 mt-2">
{assessment.name || 'PAT Test'}
</h1>
<p className="text-sm text-gray-600 dark:text-gray-300 mt-2">
Dieser Link zeigt nur diesen einen Test im Nur-Lesen-Modus.
</p>
</div>
<span
className={`px-4 py-2 rounded-full text-sm font-semibold ${getPatTypeColor(assessment.patType)}`}
>
{assessment.patType}
</span>
</div>
<div className="grid md:grid-cols-3 gap-3 mb-6 bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
<div>
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Datum</p>
<p className="mt-1 text-lg font-semibold">{formatDate(assessment.datum)}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Gesamtpunkte</p>
<p className="mt-1 text-lg font-semibold text-green-600 dark:text-green-300">
{totalPoints.toFixed(0)}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Bewertung</p>
<p className="mt-1 text-lg font-semibold">{achievement.name}</p>
</div>
</div>
{(assessment.exercises || []).map((exercise, index) => (
<div key={`${exercise.name}-${index}`} className="mb-6 border-b pb-4 border-gray-200 dark:border-gray-800">
<div className="flex items-start justify-between gap-3 mb-3 flex-wrap">
<div>
<h2 className="font-bold text-lg text-gray-700 dark:text-gray-200">{exercise.name}</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
Soll {exercise.soll} · Faktor {exercise.faktor}
</p>
</div>
<div className="rounded-lg bg-green-50 dark:bg-green-900/30 px-4 py-2">
<p className="text-xs uppercase tracking-wide text-green-700 dark:text-green-300">Points</p>
<p className="text-xl font-bold text-green-700 dark:text-green-200">
{(exercise.points || 0).toFixed(0)}
</p>
</div>
</div>
<div className="space-y-3">
{exercise.subA && exercise.subB ? (
<>
<ValueRow label={exercise.subLabels?.[0] || 'a)'} values={exercise.subA} />
<ValueRow label={exercise.subLabels?.[1] || 'b)'} values={exercise.subB} />
</>
) : (
<ValueRow label="Werte" values={exercise.values || []} />
)}
</div>
<div className="grid md:grid-cols-3 gap-3 mt-4">
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/30 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-blue-700 dark:text-blue-300">Durchschnitt</p>
<p className="text-lg font-semibold text-blue-700 dark:text-blue-200">
{(exercise.durchschnitt || 0).toFixed(2)}
</p>
</div>
<div className="rounded-lg bg-gray-50 dark:bg-gray-800 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Soll</p>
<p className="text-lg font-semibold">{exercise.soll}</p>
</div>
<div className="rounded-lg bg-gray-50 dark:bg-gray-800 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Faktor</p>
<p className="text-lg font-semibold">{exercise.faktor}</p>
</div>
</div>
</div>
))}
<div className="mt-6 bg-gradient-to-r from-green-100 to-blue-100 dark:from-gray-800 dark:to-gray-700 p-6 rounded-lg">
<div className="flex justify-between items-center gap-4 flex-wrap mb-4">
<span className="text-2xl font-bold text-gray-800 dark:text-gray-100">Ergebnis</span>
<span className="text-4xl font-bold text-green-600 dark:text-green-300">{totalPoints.toFixed(0)}</span>
</div>
<div className={`mt-4 px-6 py-4 rounded-lg ${achievement.color} ${achievement.text} text-center`}>
<p className="text-2xl font-bold">{achievement.name}</p>
{achievement.subtitle && <p className="text-lg mt-2">{achievement.subtitle}</p>}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,211 @@
import React from 'react'
import { ArrowRight, FileText, Mail, ShieldCheck } from 'lucide-react'
import PublicLayout from './PublicLayout'
const pageContent = {
'/datenschutz': {
tag: 'Datenschutz',
title: 'Datenschutzhinweise für PAT Manager',
intro:
'Diese Seite ist als saubere öffentliche Datenschutzansicht angelegt. Inhaltlich solltest du sie vor dem Livegang noch mit deinen finalen Verantwortlichen, Kontaktdaten und juristisch geprüften Angaben vervollständigen.',
cards: [
{
title: 'Verantwortliche Stelle',
body:
'Trage hier den Vereinsnamen, die verantwortliche Person oder das Unternehmen ein, das PAT Manager betreibt. Ergänze außerdem Anschrift und eine echte Kontaktmöglichkeit.'
},
{
title: 'Welche Daten verarbeitet werden',
body:
'Je nach Nutzung können Anmeldedaten, Bewertungsdaten, Freigaben, PDF-Exporte und technische Zugriffsdaten verarbeitet werden. Bei geteilten Bewertungen sehen anonyme Nutzer nur die explizit freigegebene Ansicht.'
},
{
title: 'Zweck der Verarbeitung',
body:
'Die Verarbeitung dient der Durchführung von PAT-Bewertungen, der Auswertung von Leistungsdaten, der Teamorganisation sowie dem sicheren Speichern und Teilen einzelner Testergebnisse.'
},
{
title: 'Speicherung und Dienstleister',
body:
'Die Anwendung nutzt Supabase als technische Datenbasis. Prüfe vor dem produktiven Einsatz die Auftragsverarbeitung, Speicherregion und die tatsächlich eingesetzten Zusatzdienste.'
},
{
title: 'Rechte der betroffenen Personen',
items: [
'Auskunft über gespeicherte Daten',
'Berichtigung unrichtiger Daten',
'Löschung oder Einschränkung der Verarbeitung',
'Widerspruch gegen einzelne Verarbeitungen',
'Beschwerde bei der zuständigen Aufsichtsbehörde'
]
},
{
title: 'Wichtiger Hinweis',
wide: true,
notice:
'Diese Datenschutzseite ist eine strukturierte Vorlage. Vor der Veröffentlichung müssen echte Kontaktdaten, Rechtsgrundlagen, Speicherfristen und Dienstleisterangaben ergänzt und geprüft werden.'
}
]
},
'/impressum': {
tag: 'Impressum',
title: 'Impressum für PAT Manager',
intro:
'Die Seite ist technisch fertig eingebunden, damit deine Footer- und Navigationslinks nicht mehr ins Leere laufen. Die konkreten Pflichtangaben musst du jetzt noch mit deinen echten Daten ergänzen.',
cards: [
{
title: 'Angaben gemäß § 5 TMG',
body:
'Ergänze hier Betreibername, Rechtsform, vertretungsberechtigte Person sowie die vollständige ladungsfähige Anschrift.'
},
{
title: 'Kontakt',
body:
'Trage hier eine echte E-Mail-Adresse, optional Telefonnummer und weitere Kommunikationswege ein, unter denen Nutzer dich rechtssicher erreichen können.'
},
{
title: 'Verantwortlich für den Inhalt',
body:
'Wenn erforderlich, ergänze die verantwortliche Person nach § 18 Abs. 2 MStV mit Name und Anschrift.'
},
{
title: 'Haftungs- und Linkhinweise',
body:
'Wenn du externe Dienste, Inhalte oder Verlinkungen nutzt, prüfe bitte deine endgültigen Texte zu Haftung, Urheberrecht und externen Verweisen.'
},
{
title: 'Wichtiger Hinweis',
wide: true,
notice:
'Dieses Impressum enthält absichtlich keine erfundenen Unternehmensdaten. Bitte ersetze die Platzhalter vor Veröffentlichung durch deine echten Pflichtangaben.'
}
]
},
'/kontakt': {
tag: 'Kontakt',
title: 'Kontakt und Rückfragen',
intro:
'Hier ist eine öffentliche Kontaktseite im Stil der neuen Startseite eingebunden. Wenn du reale Ansprechpartner und Kanäle ergänzt, kannst du sie direkt produktiv nutzen.',
cards: [
{
title: 'Produkt & Demo',
body:
'Nutze diesen Bereich für Anfragen zu PAT Manager, Demos, Vereinsnutzung oder Einführung im Trainingsalltag.'
},
{
title: 'Technischer Support',
body:
'Hier kannst du später Support-Zeiten, E-Mail-Adresse, Reaktionszeiten oder einen Helpdesk-Link ergänzen.'
},
{
title: 'Kooperationen',
body:
'Platz für Kontakte zu Vereinen, Trainern, Leistungszentren oder Partnern, die das System einsetzen oder testen möchten.'
},
{
title: 'Empfohlene nächste Ergänzungen',
items: [
'Echte Support-E-Mail oder Kontaktformular verknüpfen',
'Antwortzeiten und Zuständigkeiten ergänzen',
'Optional Telefonnummer oder Vereinsanschrift angeben'
]
},
{
title: 'Schnellzugriff',
wide: true,
action: true,
body:
'Wenn du direkt in die Anwendung wechseln willst, kannst du von hier aus sofort den Login öffnen und weiterarbeiten.'
}
]
}
}
export default function PublicInfoPage({ path, onGetStarted, onNavigate, themeToggle }) {
const content = pageContent[path] || pageContent['/kontakt']
return (
<PublicLayout
themeToggle={themeToggle}
onGetStarted={onGetStarted}
onNavigate={onNavigate}
currentPath={path}
navLinks={[
{ label: 'Startseite', onClick: () => onNavigate('/') },
{ label: 'Datenschutz', onClick: () => onNavigate('/datenschutz'), active: path === '/datenschutz' },
{ label: 'Impressum', onClick: () => onNavigate('/impressum'), active: path === '/impressum' },
{ label: 'Kontakt', onClick: () => onNavigate('/kontakt'), active: path === '/kontakt' }
]}
>
<section className="public-page">
<div className="public-page__hero">
<span className="public-section__tag">
{path === '/kontakt' ? <Mail className="h-4 w-4" aria-hidden="true" /> : <FileText className="h-4 w-4" aria-hidden="true" />}
{content.tag}
</span>
<h1 className="public-page__title">{content.title}</h1>
<p className="public-page__copy">{content.intro}</p>
</div>
<div className="public-page__grid">
{content.cards.map((card) => (
<article
key={card.title}
className={`public-page__card${card.wide ? ' public-page__card--wide' : ''}`}
>
<h2 className="public-page__card-title">{card.title}</h2>
{card.body && <p className="public-page__card-copy">{card.body}</p>}
{card.items && (
<ul className="public-page__list">
{card.items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
)}
{card.notice && <div className="public-page__notice">{card.notice}</div>}
{card.action && (
<div className="public-hero__actions">
<button type="button" className="public-site__button" onClick={onGetStarted}>
Login öffnen
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</button>
<button type="button" className="public-site__button--secondary" onClick={() => onNavigate('/')}>
Zur Startseite
</button>
</div>
)}
</article>
))}
</div>
<section className="public-section">
<div className="public-cta-panel">
<div>
<span className="public-section__tag">
<ShieldCheck className="h-4 w-4" aria-hidden="true" />
Öffentliche Seiten fertig
</span>
<h2 className="public-cta-panel__title">Die verlinkten Info-Seiten sind jetzt Bestandteil der App.</h2>
<p className="public-cta-panel__copy">
Ergänze jetzt noch deine echten Vereins-, Kontakt- und Rechtstexte. Danach laufen Navigation und Footer
sauber ohne Platzhalterlinks.
</p>
</div>
<div className="public-cta-panel__actions">
<button type="button" className="public-site__button" onClick={() => onNavigate('/')}>
Zur Startseite
</button>
<button type="button" className="public-site__button--secondary" onClick={onGetStarted}>
App öffnen
</button>
</div>
</div>
</section>
</section>
</PublicLayout>
)
}

View File

@@ -0,0 +1,82 @@
import React from 'react'
import { ArrowRight, Target } from 'lucide-react'
import './publicSite.css'
const footerPages = [
{ label: 'Datenschutz', path: '/datenschutz' },
{ label: 'Impressum', path: '/impressum' },
{ label: 'Kontakt', path: '/kontakt' }
]
export default function PublicLayout({
children,
themeToggle,
onGetStarted,
onNavigate,
navLinks = [],
currentPath = '/'
}) {
return (
<div className="public-site">
<div className="public-site__backdrop" />
<div className="public-site__grid" />
<header className="public-site__nav">
<button type="button" className="public-site__brand" onClick={() => onNavigate('/')}>
<span className="public-site__brand-mark">
<Target className="h-5 w-5" aria-hidden="true" />
</span>
<span className="public-site__brand-copy">
<span className="public-site__brand-title">PAT Manager</span>
<span className="public-site__brand-subtitle">Digitale Leistungsdiagnostik für Billard</span>
</span>
</button>
<nav className="public-site__nav-links" aria-label="Öffentliche Navigation">
{navLinks.map((link) => (
<button
key={link.label}
type="button"
className={`public-site__nav-link${link.active ? ' is-active' : ''}`}
onClick={link.onClick}
>
{link.label}
</button>
))}
</nav>
<div className="public-site__nav-actions">
{themeToggle && <div className="public-site__theme-toggle">{themeToggle}</div>}
<button type="button" className="public-site__cta public-site__cta--nav" onClick={onGetStarted}>
Anmelden
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</button>
</div>
</header>
<main className="public-site__main">{children}</main>
<footer className="public-site__footer">
<div className="public-site__footer-inner">
<div className="public-site__footer-links">
<button type="button" className="public-site__footer-link" onClick={onGetStarted}>
Anmelden
</button>
{footerPages.map((page) => (
<button
key={page.path}
type="button"
className={`public-site__footer-link${currentPath === page.path ? ' is-active' : ''}`}
onClick={() => onNavigate(page.path)}
>
{page.label}
</button>
))}
</div>
<p className="public-site__footer-meta">PAT Manager · Öffentliche Seiten</p>
</div>
</footer>
</div>
)
}

View File

@@ -0,0 +1,772 @@
.public-site {
--public-bg: #07131f;
--public-bg-strong: #0b1726;
--public-surface: rgba(11, 23, 38, 0.78);
--public-surface-soft: rgba(15, 27, 45, 0.62);
--public-border: rgba(148, 163, 184, 0.16);
--public-text: #f8fafc;
--public-muted: #9db0c8;
--public-accent: #60a5fa;
--public-accent-strong: #2563eb;
--public-success: #3b82f6;
--public-warm: #f59e0b;
position: relative;
min-height: 100vh;
color: var(--public-text);
background:
radial-gradient(circle at top left, rgba(59, 130, 246, 0.16), transparent 28%),
radial-gradient(circle at top right, rgba(96, 165, 250, 0.18), transparent 22%),
radial-gradient(circle at 50% 100%, rgba(245, 158, 11, 0.08), transparent 30%),
linear-gradient(180deg, var(--public-bg) 0%, var(--public-bg-strong) 48%, var(--public-bg) 100%);
overflow: hidden;
}
.public-site__backdrop,
.public-site__grid {
pointer-events: none;
position: absolute;
inset: 0;
}
.public-site__backdrop {
background:
radial-gradient(circle at 18% 18%, rgba(59, 130, 246, 0.12), transparent 20%),
radial-gradient(circle at 82% 22%, rgba(96, 165, 250, 0.14), transparent 18%);
}
.public-site__grid {
background-image:
linear-gradient(rgba(148, 163, 184, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 184, 0.04) 1px, transparent 1px);
background-size: 52px 52px;
mask-image: linear-gradient(180deg, transparent, black 18%, black 82%, transparent);
}
.public-site__nav {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
padding: 1.2rem 5vw;
background: rgba(7, 19, 31, 0.84);
backdrop-filter: blur(18px);
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
}
.public-site__brand {
display: inline-flex;
align-items: center;
gap: 0.85rem;
background: none;
border: 0;
color: var(--public-text);
font: inherit;
cursor: pointer;
padding: 0;
}
.public-site__brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.8rem;
height: 2.8rem;
border-radius: 1rem;
background: linear-gradient(135deg, rgba(96, 165, 250, 0.96), rgba(37, 99, 235, 0.92));
color: #eff6ff;
box-shadow: 0 16px 40px rgba(14, 165, 233, 0.18);
}
.public-site__brand-copy {
display: flex;
flex-direction: column;
align-items: flex-start;
line-height: 1.1;
}
.public-site__brand-title {
font-size: 1.05rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.public-site__brand-subtitle {
font-size: 0.8rem;
color: var(--public-muted);
}
.public-site__nav-links,
.public-site__footer-links {
display: flex;
align-items: center;
gap: 0.85rem;
flex-wrap: wrap;
}
.public-site__nav-link,
.public-site__footer-link,
.public-site__text-action {
background: none;
border: 0;
color: var(--public-muted);
cursor: pointer;
font: inherit;
font-size: 0.95rem;
font-weight: 500;
padding: 0.2rem 0;
transition: color 0.2s ease;
}
.public-site__nav-link:hover,
.public-site__footer-link:hover,
.public-site__text-action:hover,
.public-site__nav-link.is-active,
.public-site__footer-link.is-active {
color: var(--public-text);
}
.public-site__nav-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.public-site__theme-toggle {
display: inline-flex;
}
.public-site__cta,
.public-site__button,
.public-site__button--secondary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.55rem;
border-radius: 0.95rem;
padding: 0.95rem 1.4rem;
font-size: 0.95rem;
font-weight: 700;
text-decoration: none;
transition: transform 0.22s ease, box-shadow 0.22s ease, background 0.22s ease, border-color 0.22s ease;
cursor: pointer;
}
.public-site__cta,
.public-site__button {
color: #eff6ff;
background: linear-gradient(135deg, var(--public-accent), var(--public-accent-strong));
border: 0;
box-shadow: 0 14px 34px rgba(37, 99, 235, 0.22);
}
.public-site__cta:hover,
.public-site__button:hover {
transform: translateY(-2px);
box-shadow: 0 18px 38px rgba(37, 99, 235, 0.28);
}
.public-site__cta--nav {
min-height: 42px;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
gap: 0.5rem;
line-height: 1.25rem;
box-shadow: none;
}
.public-site__cta--nav:hover {
box-shadow: none;
}
.public-site__button--secondary {
color: var(--public-text);
background: rgba(15, 27, 45, 0.55);
border: 1px solid var(--public-border);
}
.public-site__button--secondary:hover {
transform: translateY(-2px);
border-color: rgba(56, 189, 248, 0.32);
background: rgba(15, 27, 45, 0.82);
}
.public-site__main {
position: relative;
z-index: 1;
padding: 0 5vw 4rem;
}
.public-hero,
.public-section,
.public-page {
max-width: 1280px;
margin: 0 auto;
}
.public-hero {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr);
gap: 3rem;
align-items: center;
padding: 4.8rem 0 4.5rem;
}
.public-hero__badge,
.public-section__tag {
display: inline-flex;
align-items: center;
gap: 0.55rem;
padding: 0.5rem 0.95rem;
border-radius: 999px;
border: 1px solid rgba(96, 165, 250, 0.22);
background: rgba(96, 165, 250, 0.1);
color: #dbeafe;
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.public-hero__title {
margin: 1.35rem 0 1.1rem;
font-size: clamp(2.85rem, 6vw, 5rem);
line-height: 0.98;
letter-spacing: -0.04em;
}
.public-hero__title-accent {
display: block;
color: transparent;
background: linear-gradient(135deg, #dbeafe 0%, #60a5fa 45%, #2563eb 100%);
background-clip: text;
-webkit-background-clip: text;
}
.public-hero__copy {
margin: 0;
max-width: 40rem;
color: var(--public-muted);
font-size: 1.1rem;
line-height: 1.8;
}
.public-hero__actions {
display: flex;
flex-wrap: wrap;
gap: 0.95rem;
margin-top: 2rem;
}
.public-hero__meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.65rem;
}
.public-chip {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border-radius: 999px;
border: 1px solid var(--public-border);
background: rgba(15, 27, 45, 0.62);
padding: 0.7rem 1rem;
color: var(--public-muted);
font-size: 0.9rem;
}
.public-hero__panel,
.public-card,
.public-page__card,
.public-stats__card,
.public-preview__panel {
border: 1px solid var(--public-border);
background: var(--public-surface);
backdrop-filter: blur(18px);
box-shadow: 0 28px 60px rgba(0, 0, 0, 0.24);
}
.public-preview__panel {
padding: 1.6rem;
border-radius: 1.8rem;
transform: perspective(1000px) rotateY(-6deg) rotateX(4deg);
transition: transform 0.3s ease;
}
.public-preview__panel:hover {
transform: perspective(1000px) rotateY(-2deg) rotateX(1deg);
}
.public-preview__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
}
.public-preview__status {
color: #bfdbfe;
font-size: 0.85rem;
font-weight: 600;
}
.public-preview__status::before {
content: '';
display: inline-block;
width: 0.55rem;
height: 0.55rem;
margin-right: 0.45rem;
border-radius: 999px;
background: #60a5fa;
box-shadow: 0 0 0 6px rgba(96, 165, 250, 0.16);
}
.public-preview__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
margin-top: 1.2rem;
}
.public-preview__card {
border-radius: 1.25rem;
padding: 1.15rem;
background: rgba(7, 19, 31, 0.56);
border: 1px solid rgba(148, 163, 184, 0.12);
}
.public-preview__label {
color: var(--public-muted);
font-size: 0.8rem;
}
.public-preview__value {
margin-top: 0.55rem;
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.03em;
}
.public-preview__value--accent {
color: #93c5fd;
}
.public-preview__value--success {
color: #60a5fa;
}
.public-progress {
height: 0.5rem;
margin-top: 0.85rem;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
}
.public-progress > span {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--public-accent), var(--public-accent-strong));
}
.public-preview__meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
margin-top: 1rem;
}
.public-preview__meta-card {
padding: 1rem;
border-radius: 1rem;
background: rgba(3, 9, 18, 0.42);
border: 1px solid rgba(148, 163, 184, 0.08);
}
.public-section {
margin-bottom: 4.5rem;
}
.public-section__header {
max-width: 48rem;
margin: 0 auto 2.5rem;
text-align: center;
}
.public-section__title {
margin: 1rem 0 0.75rem;
font-size: clamp(2rem, 4vw, 3.25rem);
line-height: 1.08;
letter-spacing: -0.03em;
}
.public-section__copy {
margin: 0;
color: var(--public-muted);
font-size: 1.05rem;
line-height: 1.75;
}
.public-features,
.public-steps,
.public-page__grid {
display: grid;
gap: 1.2rem;
}
.public-features {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.public-card {
border-radius: 1.5rem;
padding: 1.5rem;
}
.public-card__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 1rem;
background: linear-gradient(135deg, rgba(96, 165, 250, 0.2), rgba(37, 99, 235, 0.16));
color: #dbeafe;
margin-bottom: 1rem;
}
.public-card__title {
margin: 0 0 0.75rem;
font-size: 1.2rem;
}
.public-card__copy {
margin: 0;
color: var(--public-muted);
line-height: 1.75;
}
.public-steps {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.public-step {
position: relative;
padding: 1.6rem;
border-radius: 1.5rem;
}
.public-step__number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.25rem;
height: 3.25rem;
border-radius: 999px;
background: rgba(96, 165, 250, 0.12);
border: 1px solid rgba(96, 165, 250, 0.32);
color: #dbeafe;
font-weight: 700;
margin-bottom: 1rem;
}
.public-step__title {
margin: 0 0 0.6rem;
font-size: 1.15rem;
}
.public-step__copy {
margin: 0;
color: var(--public-muted);
line-height: 1.7;
}
.public-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1rem;
}
.public-stats__card {
border-radius: 1.5rem;
padding: 1.5rem;
text-align: center;
}
.public-stats__value {
font-size: clamp(2rem, 4vw, 3rem);
font-weight: 800;
letter-spacing: -0.04em;
}
.public-stats__label {
margin-top: 0.5rem;
color: var(--public-muted);
}
.public-cta-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
padding: 2rem;
border-radius: 1.9rem;
background: linear-gradient(135deg, rgba(6, 18, 31, 0.94), rgba(11, 23, 38, 0.78));
border: 1px solid var(--public-border);
box-shadow: 0 28px 60px rgba(0, 0, 0, 0.24);
}
.public-cta-panel__title {
margin: 0.75rem 0 0.55rem;
font-size: clamp(1.8rem, 4vw, 2.6rem);
line-height: 1.1;
}
.public-cta-panel__copy {
margin: 0;
color: var(--public-muted);
line-height: 1.75;
}
.public-cta-panel__actions {
display: flex;
flex-wrap: wrap;
gap: 0.9rem;
}
.public-page {
padding: 3.25rem 0 4rem;
}
.public-page__hero {
max-width: 54rem;
margin-bottom: 2rem;
}
.public-page__title {
margin: 1rem 0 0.8rem;
font-size: clamp(2.25rem, 5vw, 4rem);
line-height: 1.02;
letter-spacing: -0.04em;
}
.public-page__copy {
margin: 0;
color: var(--public-muted);
font-size: 1.03rem;
line-height: 1.8;
}
.public-page__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.public-page__card {
border-radius: 1.5rem;
padding: 1.5rem;
}
.public-page__card--wide {
grid-column: 1 / -1;
}
.public-page__card-title {
margin: 0 0 0.8rem;
font-size: 1.15rem;
}
.public-page__card-copy,
.public-page__list {
margin: 0;
color: var(--public-muted);
line-height: 1.75;
}
.public-page__list {
padding-left: 1.1rem;
}
.public-page__list li + li {
margin-top: 0.45rem;
}
.public-page__notice {
margin-top: 1rem;
padding: 1rem 1.1rem;
border-radius: 1rem;
border: 1px solid rgba(245, 158, 11, 0.18);
background: rgba(245, 158, 11, 0.08);
color: #fcd34d;
line-height: 1.7;
}
.public-site__footer {
position: relative;
z-index: 1;
max-width: 1280px;
margin: 0 auto;
padding: 0 5vw 2.8rem;
}
.public-site__footer-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.2rem;
padding-top: 1.5rem;
border-top: 1px solid rgba(148, 163, 184, 0.12);
}
.public-site__footer-meta {
color: var(--public-muted);
font-size: 0.92rem;
}
html:not(.dark) .public-site {
--public-bg: #eef4ff;
--public-bg-strong: #f8fbff;
--public-surface: rgba(255, 255, 255, 0.88);
--public-surface-soft: rgba(255, 255, 255, 0.74);
--public-border: rgba(37, 99, 235, 0.14);
--public-text: #0f172a;
--public-muted: #475569;
background:
radial-gradient(circle at top left, rgba(96, 165, 250, 0.2), transparent 28%),
radial-gradient(circle at top right, rgba(191, 219, 254, 0.44), transparent 24%),
radial-gradient(circle at 50% 100%, rgba(245, 158, 11, 0.12), transparent 30%),
linear-gradient(180deg, var(--public-bg) 0%, var(--public-bg-strong) 48%, #edf5ff 100%);
}
html:not(.dark) .public-site__backdrop {
background:
radial-gradient(circle at 18% 18%, rgba(96, 165, 250, 0.14), transparent 22%),
radial-gradient(circle at 82% 22%, rgba(59, 130, 246, 0.12), transparent 18%);
}
html:not(.dark) .public-site__grid {
background-image:
linear-gradient(rgba(37, 99, 235, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(37, 99, 235, 0.05) 1px, transparent 1px);
}
html:not(.dark) .public-site__nav {
background: rgba(255, 255, 255, 0.84);
border-bottom: 1px solid rgba(37, 99, 235, 0.12);
}
html:not(.dark) .public-site__brand-mark {
box-shadow: 0 16px 40px rgba(37, 99, 235, 0.16);
}
html:not(.dark) .public-site__button--secondary {
background: rgba(255, 255, 255, 0.84);
border-color: rgba(37, 99, 235, 0.16);
}
html:not(.dark) .public-site__button--secondary:hover {
border-color: rgba(37, 99, 235, 0.28);
background: rgba(255, 255, 255, 0.96);
}
html:not(.dark) .public-hero__badge,
html:not(.dark) .public-section__tag {
color: #1d4ed8;
background: rgba(96, 165, 250, 0.12);
}
html:not(.dark) .public-chip {
background: rgba(255, 255, 255, 0.78);
}
html:not(.dark) .public-preview__panel,
html:not(.dark) .public-card,
html:not(.dark) .public-page__card,
html:not(.dark) .public-stats__card {
box-shadow: 0 24px 52px rgba(37, 99, 235, 0.12);
}
html:not(.dark) .public-preview__card {
background: rgba(255, 255, 255, 0.84);
border-color: rgba(37, 99, 235, 0.12);
}
html:not(.dark) .public-progress {
background: rgba(37, 99, 235, 0.1);
}
html:not(.dark) .public-preview__meta-card {
background: rgba(244, 247, 255, 0.9);
border-color: rgba(37, 99, 235, 0.12);
}
html:not(.dark) .public-cta-panel {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(239, 246, 255, 0.92));
box-shadow: 0 24px 52px rgba(37, 99, 235, 0.12);
}
html:not(.dark) .public-site__footer-inner {
border-top: 1px solid rgba(37, 99, 235, 0.12);
}
@media (max-width: 1100px) {
.public-hero {
grid-template-columns: 1fr;
}
.public-preview__panel {
transform: none;
}
.public-features,
.public-steps,
.public-stats,
.public-page__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.public-cta-panel {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 780px) {
.public-site__nav {
align-items: flex-start;
flex-direction: column;
}
.public-site__nav-links,
.public-site__nav-actions,
.public-site__footer-inner {
width: 100%;
}
.public-site__footer-inner {
flex-direction: column;
align-items: flex-start;
}
.public-preview__grid,
.public-preview__meta,
.public-features,
.public-steps,
.public-stats,
.public-page__grid {
grid-template-columns: 1fr;
}
.public-hero__title {
font-size: clamp(2.4rem, 12vw, 4rem);
}
}

71
src/data/patTypes.js Normal file
View File

@@ -0,0 +1,71 @@
export const patTypes = {
'PAT Start': [
{ name: '1) Stoß-Geschwindigkeit', values: ['', '', ''], soll: 3, durchschnitt: 0, faktor: 33.33, points: 0 },
{ name: '2) Stoß-Gradlinigkeit', values: ['', '', '', ''], soll: 2, durchschnitt: 0, faktor: 50.0, points: 0 },
{ name: '3) Winkelbälle', values: ['', '', ''], soll: 3, durchschnitt: 0, faktor: 33.33, points: 0 },
{ name: '4) Nachlauf Wirkung', values: ['', '', ''], soll: 9, durchschnitt: 0, faktor: 11.11, points: 0 },
{ name: '5) Rücklauf-Wirkung', values: ['', '', ''], soll: 7, durchschnitt: 0, faktor: 14.29, points: 0 },
{ name: '6) Kl. Positions-Spiel', subA: ['', '', ''], subB: ['', '', ''], soll: 4, durchschnitt: 0, faktor: 25, points: 0 },
{ name: '7) Gr. Pos. Bereich', subA: ['', '', ''], subB: ['', '', ''], soll: 5, durchschnitt: 0, faktor: 20, points: 0 },
{ name: '8) Press Bande', values: ['', '', ''], soll: 3, durchschnitt: 0, faktor: 33.33, points: 0 },
{ name: '9) Standardbälle', values: ['', '', '', '', ''], soll: 2, durchschnitt: 0, faktor: 50.0, points: 0 },
{ name: '10) 8-Ball Situation', values: ['', '', ''], soll: 3, durchschnitt: 0, faktor: 33.33, points: 0 }
],
'PAT 1': [
{ name: '1) Geschwindigkeit', values: ['', '', ''], soll: 3.33, durchschnitt: 0, faktor: 30.0, points: 0, minValue: 0, maxValue: 4 },
{ name: '2) Geradlinigkeit (Speed 2)', values: ['', '', '', ''], soll: 2.5, durchschnitt: 0, faktor: 40.0, points: 0, minValue: 0, maxValue: 3 },
{ name: '3) Nachlauf', values: ['', '', ''], soll: 11.0, durchschnitt: 0, faktor: 9.1, points: 0, minValue: 0, maxValue: 12 },
{ name: '4) Rücklauf', values: ['', '', ''], soll: 10.0, durchschnitt: 0, faktor: 10.0, points: 0, minValue: 0, maxValue: 12 },
{
name: '5) Kl. Pos.-Bereich',
subA: ['', '', ''],
subB: ['', '', ''],
subLabels: ['1', '2'],
soll: 3.33,
durchschnitt: 0,
faktor: 30.0,
points: 0,
minValue: 0,
maxValue: 5
},
{
name: '6) Gr. Pos.-Bereich',
subA: ['', '', ''],
subB: ['', '', ''],
subLabels: ['1', '2'],
soll: 3.33,
durchschnitt: 0,
faktor: 30.0,
points: 0,
minValue: 0,
maxValue: 6
},
{
name: '7) Press Bande',
subA: ['', '', ''],
subB: ['', '', ''],
subLabels: ['1', '2'],
soll: 4.0,
durchschnitt: 0,
faktor: 25.0,
points: 0,
subValueRanges: {
a: { minValue: 0, maxValue: 6 },
b: { minValue: 0, maxValue: 10 }
}
},
{ name: '8) Endlos-Übung (Max: 9)', values: ['', '', ''], soll: 4.5, durchschnitt: 0, faktor: 22.22, points: 0, minValue: 0, maxValue: 9 },
{ name: '9) Standardbälle', values: ['', '', '', '', ''], soll: 2.0, durchschnitt: 0, faktor: 50.0, points: 0, minValue: 0, maxValue: 3 },
{ name: '10) 9-Ball Situation 5', values: ['', '', ''], soll: 4.5, durchschnitt: 0, faktor: 22.22, points: 0, minValue: 0, maxValue: 9 }
],
'PAT 2': [
{ name: '1) Übung PAT 2-1', values: ['', '', ''], soll: 3, durchschnitt: 0, faktor: 33.33, points: 0 },
{ name: '2) Übung PAT 2-2', values: ['', '', ''], soll: 3, durchschnitt: 0, faktor: 33.33, points: 0 },
{ name: '3) Übung PAT 2-3', values: ['', '', ''], soll: 3, durchschnitt: 0, faktor: 33.33, points: 0 }
],
'PAT 3': [
{ name: '1) Übung PAT 3-1', values: ['', '', ''], soll: 3, durchschnitt: 0, faktor: 33.33, points: 0 },
{ name: '2) Übung PAT 3-2', values: ['', '', ''], soll: 3, durchschnitt: 0, faktor: 33.33, points: 0 },
{ name: '3) Übung PAT 3-3', values: ['', '', ''], soll: 3, durchschnitt: 0, faktor: 33.33, points: 0 }
]
};

238
src/hooks/useAssessments.js Normal file
View File

@@ -0,0 +1,238 @@
import { useEffect, useMemo, useState } from 'react';
import { calculateExercise, calculateTotal } from '../utils/patCalculations';
import { supabase } from '../lib/supabaseClient';
import { normalizeExerciseValue } from '../utils/exerciseInputRules';
import {
cloneAssessment,
getFinalizeDisabledReason,
isAssessmentComplete
} from '../utils/assessmentState';
const generateId = () => (typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Date.now().toString());
const mapFromDb = (row) => ({
id: row.id,
patType: row.pat_type,
datum: row.datum,
name: row.name,
exercises: cloneAssessment(row.exercises || []),
isFinalized: Boolean(row.is_finalized),
finalizedAt: row.finalized_at || null,
finalizationReminderSentAt: row.finalization_reminder_sent_at || null
});
const mapToDb = (assessment, userId) => ({
id: assessment.id,
user_id: userId,
pat_type: assessment.patType,
datum: assessment.datum,
name: assessment.name,
exercises: cloneAssessment(assessment.exercises),
is_finalized: Boolean(assessment.isFinalized),
finalized_at: assessment.finalizedAt || null,
finalization_reminder_sent_at: assessment.finalizationReminderSentAt || null
});
export const useAssessments = (patTypes, user, options = {}) => {
const { onAfterSave } = options;
const [assessments, setAssessments] = useState([]);
const [currentAssessment, setCurrentAssessment] = useState(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const canUseSupabase = useMemo(() => !!supabase && !!user?.id, [user]);
useEffect(() => {
if (!canUseSupabase) return;
loadAssessments();
}, [canUseSupabase]);
const loadAssessments = async () => {
if (!canUseSupabase) return;
try {
const { data, error } = await supabase
.from('assessments')
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false });
if (error) throw error;
setAssessments((data || []).map(mapFromDb));
} catch (error) {
console.error('Fehler beim Laden:', error);
alert('Fehler beim Laden der Daten aus Supabase: ' + error.message);
}
};
const createNewAssessment = (patType) => {
const newAssessment = {
id: generateId(),
patType: patType,
datum: new Date().toISOString().split('T')[0],
name: '',
exercises: cloneAssessment(patTypes[patType]),
isFinalized: false,
finalizedAt: null,
finalizationReminderSentAt: null
};
setCurrentAssessment(newAssessment);
setHasUnsavedChanges(false);
};
const openAssessment = (assessment) => {
setCurrentAssessment(cloneAssessment(assessment));
setHasUnsavedChanges(false);
};
const persistAssessment = async ({ finalize = false } = {}) => {
if (!currentAssessment?.name || !currentAssessment?.datum) {
alert('Bitte Name und Datum ausfüllen');
return { ok: false };
}
if (currentAssessment?.isFinalized && !finalize) {
alert('Final abgeschlossene Bewertungen können nicht mehr geändert werden.');
return { ok: false };
}
if (finalize) {
const finalizeDisabledReason = getFinalizeDisabledReason(currentAssessment);
if (finalizeDisabledReason || !isAssessmentComplete(currentAssessment)) {
alert(finalizeDisabledReason || 'Bitte alle Felder ausfüllen, bevor du final abschließt.');
return { ok: false };
}
}
if (!canUseSupabase) {
alert('Supabase ist nicht konfiguriert. Bitte .env prüfen.');
return { ok: false };
}
const exists = assessments.find((a) => a.id === currentAssessment.id);
try {
const nextAssessment = cloneAssessment(currentAssessment);
nextAssessment.name = nextAssessment.name.trim();
if (finalize) {
nextAssessment.isFinalized = true;
nextAssessment.finalizedAt = new Date().toISOString();
nextAssessment.finalizationReminderSentAt = null;
}
const payload = mapToDb(nextAssessment, user.id);
let saved = null;
let nextAssessments = [];
if (!exists) {
const { data, error } = await supabase
.from('assessments')
.insert(payload)
.select()
.single();
if (error) throw error;
saved = mapFromDb(data);
nextAssessments = [saved, ...assessments.filter((item) => item.id !== saved.id)];
setAssessments(nextAssessments);
setCurrentAssessment(saved);
} else {
const { data, error } = await supabase
.from('assessments')
.update(payload)
.eq('id', currentAssessment.id)
.eq('user_id', user.id)
.select()
.single();
if (error) throw error;
saved = mapFromDb(data);
nextAssessments = assessments.map((item) => (item.id === saved.id ? saved : item));
setAssessments(nextAssessments);
setCurrentAssessment(saved);
}
if (typeof onAfterSave === 'function') {
try {
await onAfterSave({
savedAssessment: saved,
assessments: nextAssessments
});
} catch (postSaveError) {
console.warn('Post-save hook failed:', postSaveError);
}
}
setHasUnsavedChanges(false);
return {
ok: true,
assessment: saved,
assessments: nextAssessments
};
} catch (error) {
alert('Fehler beim Speichern in Supabase: ' + error.message);
return {
ok: false,
error
};
}
};
const handleSave = async () => persistAssessment();
const finalizeAssessment = async () => persistAssessment({ finalize: true });
const updateExerciseValue = (exerciseIndex, valueIndex, newValue, subType = null) => {
if (!currentAssessment || currentAssessment.isFinalized) return;
const updated = cloneAssessment(currentAssessment);
const normalizedValue = normalizeExerciseValue(updated.exercises[exerciseIndex], newValue, subType);
if (subType === 'a') {
updated.exercises[exerciseIndex].subA[valueIndex] = normalizedValue;
} else if (subType === 'b') {
updated.exercises[exerciseIndex].subB[valueIndex] = normalizedValue;
} else {
updated.exercises[exerciseIndex].values[valueIndex] = normalizedValue;
}
const { durchschnitt, points } = calculateExercise(updated.exercises[exerciseIndex]);
updated.exercises[exerciseIndex].durchschnitt = durchschnitt;
updated.exercises[exerciseIndex].points = points;
setCurrentAssessment(updated);
setHasUnsavedChanges(true);
};
const deleteAssessment = async (id) => {
if (!confirm('Möchten Sie diese Bewertung wirklich löschen?')) return;
if (!canUseSupabase) {
alert('Supabase ist nicht konfiguriert. Bitte .env prüfen.');
return;
}
const { error } = await supabase.from('assessments').delete().eq('id', id).eq('user_id', user.id);
if (error) {
alert('Fehler beim Löschen: ' + error.message);
return;
}
setAssessments((prev) => prev.filter((a) => a.id !== id));
};
return {
assessments,
currentAssessment,
setCurrentAssessment,
hasUnsavedChanges,
setHasUnsavedChanges,
createNewAssessment,
openAssessment,
handleSave,
finalizeAssessment,
deleteAssessment,
updateExerciseValue,
calculateTotal: () => calculateTotal(currentAssessment?.exercises || [])
};
};

View File

@@ -0,0 +1,275 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { supabase } from '../lib/supabaseClient';
import { saveTrainingPlanWithSessions } from '../lib/trainingPlanService';
import { generateTrainingPlan } from '../utils/trainingPlanGenerator';
const mapPlanRow = (row) => ({
id: row.id,
patType: row.pat_type,
status: row.status,
analysisSnapshot: row.analysis_snapshot || {},
durationWeeks: row.duration_weeks,
sessionsPerWeek: row.sessions_per_week,
generatedAt: row.generated_at,
archivedAt: row.archived_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
sessions: []
});
const mapSessionRow = (row) => ({
id: row.id,
planId: row.plan_id,
weekNo: row.week_no,
sessionNo: row.session_no,
mainExercise: row.main_exercise,
secondaryExercises: row.secondary_exercises || [],
tasks: row.tasks || [],
state: row.state || 'open',
notes: row.notes || '',
completedAt: row.completed_at,
createdAt: row.created_at,
updatedAt: row.updated_at
});
const updateSessionInPlan = (plan, sessionId, patch) => {
if (!plan) return plan;
const hasSession = plan.sessions.some((session) => session.id === sessionId);
if (!hasSession) return plan;
return {
...plan,
sessions: plan.sessions.map((session) =>
session.id === sessionId
? {
...session,
...patch
}
: session
)
};
};
export const useTrainingPlans = ({ user, patType }) => {
const [activePlan, setActivePlan] = useState(null);
const [historyPlans, setHistoryPlans] = useState([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const canUseSupabase = useMemo(() => Boolean(supabase && user?.id), [user]);
const loadPlans = useCallback(async () => {
if (!canUseSupabase || !patType) {
setActivePlan(null);
setHistoryPlans([]);
return;
}
setLoading(true);
setError('');
try {
const { data: plansData, error: plansError } = await supabase
.from('training_plans')
.select('*')
.eq('user_id', user.id)
.eq('pat_type', patType)
.order('generated_at', { ascending: false });
if (plansError) throw plansError;
const mappedPlans = (plansData || []).map(mapPlanRow);
const planIds = mappedPlans.map((plan) => plan.id);
let sessionsByPlan = new Map();
if (planIds.length) {
const { data: sessionsData, error: sessionsError } = await supabase
.from('training_plan_sessions')
.select('*')
.in('plan_id', planIds)
.order('week_no', { ascending: true })
.order('session_no', { ascending: true });
if (sessionsError) throw sessionsError;
sessionsByPlan = (sessionsData || []).reduce((map, row) => {
const session = mapSessionRow(row);
const list = map.get(session.planId) || [];
list.push(session);
map.set(session.planId, list);
return map;
}, new Map());
}
const plansWithSessions = mappedPlans.map((plan) => ({
...plan,
sessions: sessionsByPlan.get(plan.id) || []
}));
const active = plansWithSessions.find((plan) => plan.status === 'active') || plansWithSessions[0] || null;
const history = plansWithSessions.filter((plan) => !active || plan.id !== active.id);
setActivePlan(active);
setHistoryPlans(history);
} catch (loadError) {
setError(loadError.message || 'Fehler beim Laden der Trainingspläne.');
} finally {
setLoading(false);
}
}, [canUseSupabase, patType, user?.id]);
useEffect(() => {
loadPlans();
}, [loadPlans]);
const saveGeneratedPlan = useCallback(
async ({ analysis, durationWeeks = 4, sessionsPerWeek = 3, sessions = null }) => {
if (!canUseSupabase || !patType) {
return {
ok: false,
error: new Error('Supabase oder PAT-Typ nicht verfügbar.')
};
}
setSaving(true);
setError('');
try {
const preparedSessions = sessions
? sessions
: generateTrainingPlan({
topWeaknesses: analysis?.topWeaknesses || [],
topStrengths: analysis?.topStrengths || [],
durationWeeks,
sessionsPerWeek
}).sessions;
const result = await saveTrainingPlanWithSessions({
patType,
analysisSnapshot: analysis,
durationWeeks,
sessionsPerWeek,
sessions: preparedSessions
});
if (!result.ok) throw result.error;
await loadPlans();
return {
ok: true,
planId: result.planId
};
} catch (saveError) {
setError(saveError.message || 'Fehler beim Speichern des Trainingsplans.');
return {
ok: false,
error: saveError
};
} finally {
setSaving(false);
}
},
[canUseSupabase, loadPlans, patType]
);
const updateSessionState = useCallback(async (sessionId, nextState) => {
if (!canUseSupabase) return { ok: false };
const nextCompletedAt = nextState === 'done' ? new Date().toISOString() : null;
const prevActive = activePlan;
const prevHistory = historyPlans;
setActivePlan((current) => updateSessionInPlan(current, sessionId, {
state: nextState,
completedAt: nextCompletedAt
}));
setHistoryPlans((current) =>
current.map((plan) =>
updateSessionInPlan(plan, sessionId, {
state: nextState,
completedAt: nextCompletedAt
})
)
);
const { error: updateError } = await supabase
.from('training_plan_sessions')
.update({
state: nextState,
completed_at: nextCompletedAt
})
.eq('id', sessionId);
if (updateError) {
setActivePlan(prevActive);
setHistoryPlans(prevHistory);
setError(updateError.message || 'Session-Status konnte nicht gespeichert werden.');
return { ok: false, error: updateError };
}
return { ok: true };
}, [activePlan, canUseSupabase, historyPlans]);
const updateSessionPartial = useCallback(async (sessionId, patch) => {
if (!canUseSupabase) return { ok: false };
const normalizedPatch = {
...patch
};
const prevActive = activePlan;
const prevHistory = historyPlans;
setActivePlan((current) => updateSessionInPlan(current, sessionId, normalizedPatch));
setHistoryPlans((current) => current.map((plan) => updateSessionInPlan(plan, sessionId, normalizedPatch)));
const dbPatch = {};
if (Object.prototype.hasOwnProperty.call(normalizedPatch, 'mainExercise')) {
dbPatch.main_exercise = normalizedPatch.mainExercise;
}
if (Object.prototype.hasOwnProperty.call(normalizedPatch, 'secondaryExercises')) {
dbPatch.secondary_exercises = normalizedPatch.secondaryExercises;
}
if (Object.prototype.hasOwnProperty.call(normalizedPatch, 'tasks')) {
dbPatch.tasks = normalizedPatch.tasks;
}
if (Object.prototype.hasOwnProperty.call(normalizedPatch, 'notes')) {
dbPatch.notes = normalizedPatch.notes;
}
const { error: updateError } = await supabase
.from('training_plan_sessions')
.update(dbPatch)
.eq('id', sessionId);
if (updateError) {
setActivePlan(prevActive);
setHistoryPlans(prevHistory);
setError(updateError.message || 'Session-Änderung konnte nicht gespeichert werden.');
return { ok: false, error: updateError };
}
return { ok: true };
}, [activePlan, canUseSupabase, historyPlans]);
return {
activePlan,
historyPlans,
loading,
saving,
error,
loadPlans,
saveGeneratedPlan,
updateSessionState,
updateSessionPartial
};
};
export default useTrainingPlans;

16
src/index.css Normal file
View File

@@ -0,0 +1,16 @@
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: 'Space Grotesk', 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}

View File

@@ -0,0 +1,127 @@
import { calculateExercise } from '../utils/patCalculations'
import { supabase } from './supabaseClient'
const hydrateExercise = (exercise = {}) => {
const normalized = {
name: exercise.name || '',
soll: exercise.soll ?? 0,
faktor: exercise.faktor ?? 0
}
if (exercise.subA && exercise.subB) {
normalized.subA = exercise.subA
normalized.subB = exercise.subB
if (exercise.subLabels) {
normalized.subLabels = exercise.subLabels
}
} else {
normalized.values = exercise.values || []
}
const { durchschnitt, points } = calculateExercise({
...normalized,
values: normalized.values || []
})
return {
...normalized,
durchschnitt,
points
}
}
const mapSharedAssessment = (row) => ({
id: row.id,
patType: row.pat_type,
datum: row.datum,
name: row.name,
exercises: (row.exercises || []).map(hydrateExercise)
})
const getSingleRow = (data) => (Array.isArray(data) ? data[0] || null : data || null)
const buildShareUrl = (shareToken) => {
if (typeof window === 'undefined') {
return `?share=${shareToken}`
}
const url = new URL(window.location.origin + window.location.pathname)
url.searchParams.set('share', shareToken)
return url.toString()
}
export const createAssessmentShareLink = async (assessment) => {
if (!assessment) {
throw new Error('Kein Test zum Teilen gefunden.')
}
if (!supabase) {
throw new Error('Supabase ist nicht konfiguriert.')
}
const { data, error } = await supabase.rpc('create_or_get_assessment_share', {
p_assessment_id: assessment.id
})
if (error) throw error
const row = getSingleRow(data)
if (!row?.share_token) {
throw new Error('Kein Freigabetoken erhalten.')
}
return {
token: row.share_token,
url: buildShareUrl(row.share_token)
}
}
export const listAssessmentShares = async (assessmentIds = []) => {
if (!supabase) {
throw new Error('Supabase ist nicht konfiguriert.')
}
if (!assessmentIds.length) {
return {}
}
const { data, error } = await supabase
.from('assessment_shares')
.select('assessment_id, share_token')
.in('assessment_id', assessmentIds)
if (error) throw error
return (data || []).reduce((acc, row) => {
acc[row.assessment_id] = row.share_token
return acc
}, {})
}
export const revokeAssessmentShare = async (assessmentId) => {
if (!supabase) {
throw new Error('Supabase ist nicht konfiguriert.')
}
const { error } = await supabase
.from('assessment_shares')
.delete()
.eq('assessment_id', assessmentId)
if (error) throw error
}
export const getSharedAssessment = async (shareToken) => {
if (!supabase) {
throw new Error('Supabase ist nicht konfiguriert.')
}
const { data, error } = await supabase.rpc('get_shared_assessment', {
p_share_token: shareToken
})
if (error) throw error
const row = getSingleRow(data)
return row ? mapSharedAssessment(row) : null
}

12
src/lib/supabaseClient.js Normal file
View File

@@ -0,0 +1,12 @@
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
if (!supabaseUrl || !supabaseAnonKey) {
console.warn('Supabase env vars missing. Auth is disabled until VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY are set.')
}
export const supabase = supabaseUrl && supabaseAnonKey
? createClient(supabaseUrl, supabaseAnonKey)
: null

View File

@@ -0,0 +1,66 @@
import { supabase } from './supabaseClient';
const normalizeTasks = (tasks) => {
if (!Array.isArray(tasks)) return [];
return tasks.map((task) => ({
type: task?.type || 'task',
title: task?.title || '',
durationMin: Number(task?.durationMin || 0),
repetitions: task?.repetitions ?? null,
intensity: task?.intensity || null,
instructions: task?.instructions || ''
}));
};
export const mapSessionToRpc = (session) => ({
weekNo: Number(session?.weekNo || session?.week_no || 1),
sessionNo: Number(session?.sessionNo || session?.session_no || 1),
mainExercise: session?.mainExercise || session?.main_exercise || 'Basistechnik',
secondaryExercises: Array.isArray(session?.secondaryExercises)
? session.secondaryExercises
: Array.isArray(session?.secondary_exercises)
? session.secondary_exercises
: [],
tasks: normalizeTasks(session?.tasks),
state: session?.state || 'open',
notes: session?.notes || ''
});
export const saveTrainingPlanWithSessions = async ({
patType,
analysisSnapshot,
durationWeeks,
sessionsPerWeek,
sessions
}) => {
if (!supabase) {
return {
ok: false,
error: new Error('Supabase ist nicht konfiguriert.')
};
}
try {
const payload = {
p_pat_type: patType,
p_analysis_snapshot: analysisSnapshot,
p_duration_weeks: Number(durationWeeks),
p_sessions_per_week: Number(sessionsPerWeek),
p_sessions: (sessions || []).map(mapSessionToRpc)
};
const { data, error } = await supabase.rpc('create_training_plan_with_sessions', payload);
if (error) throw error;
return {
ok: true,
planId: data
};
} catch (error) {
return {
ok: false,
error
};
}
};

10
src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

187
src/utils/analysisEngine.js Normal file
View File

@@ -0,0 +1,187 @@
import { calculateTotal } from './patCalculations';
const toNumber = (value, fallback = 0) => {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : fallback;
};
const round = (value, precision = 4) => {
const factor = 10 ** precision;
return Math.round(value * factor) / factor;
};
const mean = (values) => {
if (!values.length) return 0;
return values.reduce((sum, value) => sum + value, 0) / values.length;
};
const stddev = (values) => {
if (values.length <= 1) return 0;
const avg = mean(values);
const variance = mean(values.map((value) => (value - avg) ** 2));
return Math.sqrt(variance);
};
const sortByDateDesc = (items = []) =>
[...items].sort((left, right) => {
const leftDate = new Date(left?.datum || 0).getTime();
const rightDate = new Date(right?.datum || 0).getTime();
if (leftDate === rightDate) {
const leftCreated = new Date(left?.created_at || 0).getTime();
const rightCreated = new Date(right?.created_at || 0).getTime();
return rightCreated - leftCreated;
}
return rightDate - leftDate;
});
const collectSampleValues = (exercise) => {
if (!exercise || typeof exercise !== 'object') return [];
if (Array.isArray(exercise.subA) || Array.isArray(exercise.subB)) {
return [...(exercise.subA || []), ...(exercise.subB || [])]
.map((value) => Number(value))
.filter((value) => Number.isFinite(value));
}
return (exercise.values || [])
.map((value) => Number(value))
.filter((value) => Number.isFinite(value));
};
const getExerciseActual = (exercise, missingStrategy = 'zero') => {
if (!exercise) return missingStrategy === 'zero' ? 0 : null;
const avg = Number(exercise.durchschnitt);
if (Number.isFinite(avg)) return avg;
const samples = collectSampleValues(exercise);
if (!samples.length) return missingStrategy === 'zero' ? 0 : null;
return mean(samples);
};
const formatDateLabel = (value) => {
if (!value) return '—';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
}).format(parsed);
};
export const buildAnalysis = (assessments = [], patType, options = {}) => {
const windowSize = toNumber(options.windowSize, 5);
const missingStrategy = options.missingStrategy || 'zero';
const matchingAssessments = sortByDateDesc(
assessments.filter((assessment) => assessment?.patType === patType)
);
const windowedAssessments = matchingAssessments.slice(0, windowSize);
if (!windowedAssessments.length) {
return {
windowedAssessments: [],
exerciseMetrics: [],
topWeaknesses: [],
topStrengths: [],
trendSeries: [],
radarSeries: [],
isLimitedData: false,
patType,
windowSize
};
}
const latestAssessment = windowedAssessments[0];
const baseExercises = latestAssessment?.exercises || [];
const maxFactor = Math.max(...baseExercises.map((exercise) => toNumber(exercise?.faktor, 0)), 1);
const exerciseMetrics = baseExercises.map((baseExercise, index) => {
const actualSeries = windowedAssessments.map((assessment) => {
const exercise = assessment?.exercises?.[index];
const actual = getExerciseActual(exercise, missingStrategy);
return actual === null ? 0 : actual;
});
const actualMean = mean(actualSeries);
const soll = toNumber(baseExercise?.soll, 0);
const factor = toNumber(baseExercise?.faktor, 0);
const normalizedSoll = soll > 0 ? soll : 1;
const gapScore = Math.max(0, (soll - actualMean) / normalizedSoll);
const factorScore = maxFactor > 0 ? factor / maxFactor : 0;
const consistencyScore = Math.min(1, stddev(actualSeries) / normalizedSoll);
const priorityScore = gapScore * factorScore * (1 + consistencyScore);
const overNorm = Math.max(0, (actualMean - soll) / normalizedSoll);
const stability = 1 - consistencyScore;
const strengthScore = overNorm * factorScore * Math.max(stability, 0.1);
const latestActual = actualSeries[0] || 0;
const oldestActual = actualSeries[actualSeries.length - 1] || 0;
const trendDelta = latestActual - oldestActual;
return {
index,
name: baseExercise?.name || `Übung ${index + 1}`,
soll,
factor,
actualMean: round(actualMean, 4),
latestActual: round(latestActual, 4),
trendDelta: round(trendDelta, 4),
gapScore: round(gapScore, 4),
consistencyScore: round(consistencyScore, 4),
priorityScore: round(priorityScore, 4),
strengthScore: round(strengthScore, 4),
actualSeries: actualSeries.map((value) => round(value, 4))
};
});
const topWeaknesses = [...exerciseMetrics]
.sort((left, right) => right.priorityScore - left.priorityScore)
.slice(0, 3);
const topStrengths = [...exerciseMetrics]
.sort((left, right) => right.strengthScore - left.strengthScore)
.slice(0, 3);
const trendSeries = [...windowedAssessments]
.reverse()
.map((assessment) => ({
assessmentId: assessment.id,
datum: assessment.datum,
label: formatDateLabel(assessment.datum),
totalPoints: Math.round(calculateTotal(assessment.exercises || []))
}));
const radarSeries = [...exerciseMetrics]
.sort((left, right) => right.factor - left.factor)
.slice(0, 6)
.map((metric) => ({
name: metric.name,
soll: metric.soll,
actual: metric.actualMean,
ratio: metric.soll > 0 ? round(Math.min(1.2, metric.actualMean / metric.soll), 4) : 0,
priorityScore: metric.priorityScore
}));
return {
windowedAssessments,
exerciseMetrics,
topWeaknesses,
topStrengths,
trendSeries,
radarSeries,
isLimitedData: windowedAssessments.length < windowSize,
patType,
windowSize
};
};
export default buildAnalysis;

View File

@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest';
import { buildAnalysis } from './analysisEngine';
const createExercise = (name, soll, faktor, durchschnitt) => ({
name,
soll,
faktor,
durchschnitt,
points: durchschnitt * faktor
});
const createAssessment = (id, datum, exercises) => ({
id,
patType: 'PAT 1',
datum,
name: `Spieler ${id}`,
exercises
});
describe('buildAnalysis', () => {
it('uses the latest 5 assessments for windowing', () => {
const baseExercises = [
createExercise('1) Faktor hoch konstant', 10, 40, 5),
createExercise('2) Faktor mittel konstant', 10, 20, 5),
createExercise('3) Faktor hoch schwankend', 10, 40, 5),
createExercise('4) Stärke', 5, 10, 7)
];
const assessments = [
createAssessment('a1', '2026-02-01', baseExercises),
createAssessment('a2', '2026-02-02', baseExercises),
createAssessment('a3', '2026-02-03', baseExercises),
createAssessment('a4', '2026-02-04', baseExercises),
createAssessment('a5', '2026-02-05', baseExercises),
createAssessment('a6', '2026-02-06', baseExercises)
];
const result = buildAnalysis(assessments, 'PAT 1', { windowSize: 5, missingStrategy: 'zero' });
expect(result.windowedAssessments).toHaveLength(5);
expect(result.windowedAssessments[0].id).toBe('a6');
expect(result.windowedAssessments[4].id).toBe('a2');
expect(result.isLimitedData).toBe(false);
});
it('fills missing exercise values with zero and marks limited data when less than window', () => {
const assessmentA = createAssessment('a1', '2026-02-03', [
createExercise('1) Technik', 10, 30, 6)
]);
const assessmentB = createAssessment('a2', '2026-02-04', []);
const assessmentC = createAssessment('a3', '2026-02-05', [
createExercise('1) Technik', 10, 30, 4)
]);
const result = buildAnalysis([assessmentA, assessmentB, assessmentC], 'PAT 1', {
windowSize: 5,
missingStrategy: 'zero'
});
expect(result.isLimitedData).toBe(true);
expect(result.exerciseMetrics[0].actualSeries).toContain(0);
});
it('prioritizes weaknesses by gap x factor x consistency and detects strengths', () => {
const rows = [
createAssessment('a1', '2026-02-01', [
createExercise('1) Faktor hoch konstant', 10, 40, 5),
createExercise('2) Faktor mittel konstant', 10, 20, 5),
createExercise('3) Faktor hoch schwankend', 10, 40, 0),
createExercise('4) Stärke', 5, 10, 7)
]),
createAssessment('a2', '2026-02-02', [
createExercise('1) Faktor hoch konstant', 10, 40, 5),
createExercise('2) Faktor mittel konstant', 10, 20, 5),
createExercise('3) Faktor hoch schwankend', 10, 40, 10),
createExercise('4) Stärke', 5, 10, 7)
]),
createAssessment('a3', '2026-02-03', [
createExercise('1) Faktor hoch konstant', 10, 40, 5),
createExercise('2) Faktor mittel konstant', 10, 20, 5),
createExercise('3) Faktor hoch schwankend', 10, 40, 0),
createExercise('4) Stärke', 5, 10, 7)
]),
createAssessment('a4', '2026-02-04', [
createExercise('1) Faktor hoch konstant', 10, 40, 5),
createExercise('2) Faktor mittel konstant', 10, 20, 5),
createExercise('3) Faktor hoch schwankend', 10, 40, 10),
createExercise('4) Stärke', 5, 10, 7)
]),
createAssessment('a5', '2026-02-05', [
createExercise('1) Faktor hoch konstant', 10, 40, 5),
createExercise('2) Faktor mittel konstant', 10, 20, 5),
createExercise('3) Faktor hoch schwankend', 10, 40, 5),
createExercise('4) Stärke', 5, 10, 7)
])
];
const result = buildAnalysis(rows, 'PAT 1', { windowSize: 5, missingStrategy: 'zero' });
expect(result.topWeaknesses[0].name).toBe('3) Faktor hoch schwankend');
expect(result.topWeaknesses[1].name).toBe('1) Faktor hoch konstant');
expect(result.topWeaknesses[2].name).toBe('2) Faktor mittel konstant');
expect(result.topStrengths[0].name).toBe('4) Stärke');
expect(result.topStrengths[0].strengthScore).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,38 @@
export const cloneAssessment = (assessment) =>
assessment ? JSON.parse(JSON.stringify(assessment)) : null;
const hasValue = (value) => {
if (value === null || typeof value === 'undefined') return false;
if (typeof value === 'string') return value.trim() !== '';
return true;
};
export const isExerciseComplete = (exercise = {}) => {
if (exercise.subA && exercise.subB) {
return [...(exercise.subA || []), ...(exercise.subB || [])].every(hasValue);
}
return (exercise.values || []).every(hasValue);
};
export const isAssessmentComplete = (assessment) => {
if (!assessment) return false;
if (!hasValue(assessment.name) || !hasValue(assessment.datum)) return false;
const exercises = assessment.exercises || [];
if (!exercises.length) return false;
return exercises.every(isExerciseComplete);
};
export const getFinalizeDisabledReason = (assessment) => {
if (!assessment) return 'Kein Test geöffnet';
if (assessment.isFinalized) return 'Dieser Test wurde bereits final abgeschlossen';
if (!hasValue(assessment.name) || !hasValue(assessment.datum)) {
return 'Bitte zuerst Name und Datum ausfüllen';
}
if (!isAssessmentComplete(assessment)) {
return 'Bitte alle Übungsfelder ausfüllen, bevor du final abschließt';
}
return '';
};

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import {
cloneAssessment,
getFinalizeDisabledReason,
isAssessmentComplete,
isExerciseComplete
} from './assessmentState';
describe('assessmentState', () => {
it('treats regular exercises as complete only when all values are filled', () => {
expect(isExerciseComplete({ values: ['1', '2', '3'] })).toBe(true);
expect(isExerciseComplete({ values: ['1', '', '3'] })).toBe(false);
});
it('treats split exercises as complete only when both rows are filled', () => {
expect(isExerciseComplete({ subA: ['1', '2'], subB: ['3', '4'] })).toBe(true);
expect(isExerciseComplete({ subA: ['1', '2'], subB: ['3', ''] })).toBe(false);
});
it('marks assessments incomplete until name, date and all exercise values are present', () => {
const baseAssessment = {
name: 'Stefan',
datum: '2026-03-21',
exercises: [{ values: ['1', '2', '3'] }]
};
expect(isAssessmentComplete(baseAssessment)).toBe(true);
expect(getFinalizeDisabledReason(baseAssessment)).toBe('');
expect(isAssessmentComplete({ ...baseAssessment, name: '' })).toBe(false);
expect(isAssessmentComplete({ ...baseAssessment, exercises: [{ values: ['1', '', '3'] }] })).toBe(false);
});
it('deep clones assessments before editing', () => {
const source = {
name: 'Stefan',
exercises: [{ values: ['1', '2'] }]
};
const clone = cloneAssessment(source);
clone.exercises[0].values[0] = '9';
expect(source.exercises[0].values[0]).toBe('1');
});
});

View File

@@ -0,0 +1,31 @@
export const sanitizeNumericInput = (value) => String(value || '').replace(/\D/g, '');
const getExerciseRange = (exercise, subType = null) => {
if (subType && exercise?.subValueRanges?.[subType]) {
return exercise.subValueRanges[subType];
}
return {
minValue: exercise?.minValue,
maxValue: exercise?.maxValue
};
};
export const normalizeExerciseValue = (exercise, value, subType = null) => {
const sanitizedValue = sanitizeNumericInput(value);
if (!sanitizedValue) {
return '';
}
const parsedValue = Number.parseInt(sanitizedValue, 10);
const range = getExerciseRange(exercise, subType);
const minValue = Number.isFinite(range?.minValue) ? range.minValue : 0;
const maxValue = Number.isFinite(range?.maxValue) ? range.maxValue : null;
const clampedValue = maxValue === null
? Math.max(parsedValue, minValue)
: Math.min(Math.max(parsedValue, minValue), maxValue);
return String(clampedValue);
};

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { normalizeExerciseValue } from './exerciseInputRules';
describe('normalizeExerciseValue', () => {
it('keeps only digits', () => {
expect(normalizeExerciseValue({}, '1a-2')).toBe('12');
});
it('clamps values to the configured range', () => {
expect(normalizeExerciseValue({ minValue: 0, maxValue: 4 }, '8')).toBe('4');
expect(normalizeExerciseValue({ minValue: 0, maxValue: 3 }, '9')).toBe('3');
});
it('supports separate ranges for split exercise rows', () => {
const exercise = {
subValueRanges: {
a: { minValue: 0, maxValue: 6 },
b: { minValue: 0, maxValue: 10 }
}
};
expect(normalizeExerciseValue(exercise, '8', 'a')).toBe('6');
expect(normalizeExerciseValue(exercise, '12', 'b')).toBe('10');
});
it('keeps empty values empty', () => {
expect(normalizeExerciseValue({ minValue: 0, maxValue: 4 }, '')).toBe('');
});
});

View File

@@ -0,0 +1,73 @@
export const calculateExercise = (exercise) => {
let sum = 0;
let count = 0;
if (exercise.subA && exercise.subB) {
[...exercise.subA, ...exercise.subB].forEach((val) => {
const num = parseFloat(val);
if (!isNaN(num)) {
sum += num;
count++;
}
});
} else {
exercise.values.forEach((val) => {
const num = parseFloat(val);
if (!isNaN(num)) {
sum += num;
count++;
}
});
}
const durchschnitt = count > 0 ? sum / count : 0;
const points = durchschnitt * exercise.faktor;
return { durchschnitt, points };
};
export const calculateTotal = (exercises = []) =>
exercises.reduce((sum, ex) => sum + (ex.points || 0), 0);
export const getPatTypeColor = (patType) => {
const colors = {
'PAT Start': 'bg-green-100 text-green-800',
'PAT 1': 'bg-blue-100 text-blue-800',
'PAT 2': 'bg-purple-100 text-purple-800',
'PAT 3': 'bg-orange-100 text-orange-800'
};
return colors[patType] || 'bg-gray-100 text-gray-800';
};
export const getAchievement = (patType, points) => {
const achievements = {
'PAT Start': [
{ min: 1000, color: 'bg-yellow-400', text: 'text-black', name: '2. Schwarz-Weiss Sport WPA', subtitle: 'Freizeit-Spieler' },
{ min: 800, color: 'bg-white', text: 'text-black', name: '1. Weiss', subtitle: '' },
{ min: 600, color: 'bg-gray-200', text: 'text-gray-800', name: 'PAT Start Teilnahme', subtitle: '' },
{ min: 0, color: 'bg-gray-100', text: 'text-gray-500', name: 'Nicht bestanden', subtitle: '' }
],
'PAT 1': [
{ min: 1000, color: 'bg-green-700', text: 'text-white', name: '4. Dunkelgrün', subtitle: 'ca. Bezirksliga' },
{ min: 800, color: 'bg-green-300', text: 'text-black', name: '3. Hellgrün', subtitle: 'ca. Kreisliga' },
{ min: 600, color: 'bg-gray-200', text: 'text-gray-800', name: 'PAT 1 Teilnahme', subtitle: '' },
{ min: 0, color: 'bg-gray-100', text: 'text-gray-500', name: 'Nicht bestanden', subtitle: '' }
],
'PAT 2': [
{ min: 1000, color: 'bg-blue-700', text: 'text-white', name: '6. Dunkelblau', subtitle: 'ca. Verbandsliga' },
{ min: 800, color: 'bg-blue-300', text: 'text-black', name: '5. Hellblau', subtitle: 'ca. Landesliga' },
{ min: 600, color: 'bg-gray-200', text: 'text-gray-800', name: 'PAT 2 Teilnahme', subtitle: '' },
{ min: 0, color: 'bg-gray-100', text: 'text-gray-500', name: 'Nicht bestanden', subtitle: '' }
],
'PAT 3': [
{ min: 1000, color: 'bg-pink-400', text: 'text-white', name: '10. Pink', subtitle: 'Ein Weltstar!' },
{ min: 850, color: 'bg-yellow-400', text: 'text-black', name: '9. Gold', subtitle: 'internationaler Topsportler' },
{ min: 700, color: 'bg-red-600', text: 'text-white', name: '8. Rot', subtitle: 'ca. 1. Bundesliga' },
{ min: 550, color: 'bg-black', text: 'text-white', name: '7. Schwarz', subtitle: 'ca. 2. Bundesliga' },
{ min: 0, color: 'bg-gray-100', text: 'text-gray-500', name: 'Nicht bestanden', subtitle: '' }
]
};
const patAchievements = achievements[patType] || achievements['PAT Start'];
return patAchievements.find((a) => points >= a.min) || patAchievements[patAchievements.length - 1];
};

308
src/utils/pdfExport.js Normal file
View File

@@ -0,0 +1,308 @@
const PAGE_MARGIN = 14
const PAGE_TOP = 12
const PAGE_BOTTOM = 22
const CONTENT_WIDTH = 210 - PAGE_MARGIN * 2
const CONTINUATION_PAGE_TOP = PAGE_MARGIN + 10
const TABLE_FONT_SIZE = 10.5
const TABLE_CELL_PADDING = 3
const formatDate = (value) => {
if (!value) return '—'
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return value
return new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
}).format(parsed)
}
const sanitizeFileName = (value) =>
(value || 'pat-test')
.toLowerCase()
.replace(/[^a-z0-9-_]+/gi, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
const formatValue = (value) => {
if (value === '' || value === null || typeof value === 'undefined') return '—'
return String(value)
}
const truncateText = (value, maxLength = 26) => {
const text = String(value || '—')
return text.length > maxLength ? `${text.slice(0, maxLength - 1)}` : text
}
const VALUE_TABLE_LABEL = 'Versuch'
const estimateExerciseSectionHeight = (exercise) => {
const hasSplitRows = Boolean(exercise.subA && exercise.subB)
const valueTableHeight = hasSplitRows ? 30 : 21
const metricsTableHeight = 21
return 18 + valueTableHeight + metricsTableHeight
}
const buildValueTable = (exercise) => {
if (exercise.subA && exercise.subB) {
const valuesCount = Math.max(exercise.subA.length, exercise.subB.length)
const headers = [VALUE_TABLE_LABEL, ...Array.from({ length: valuesCount }, (_, index) => `${index + 1}`)]
const firstColumnWidth = 32
const valueColumnWidth = (CONTENT_WIDTH - firstColumnWidth) / valuesCount
return {
head: [headers],
body: [
['a.)', ...Array.from({ length: valuesCount }, (_, index) => formatValue(exercise.subA[index]))],
['b.)', ...Array.from({ length: valuesCount }, (_, index) => formatValue(exercise.subB[index]))]
],
columnStyles: {
0: { cellWidth: firstColumnWidth, halign: 'center' },
...Object.fromEntries(
Array.from({ length: valuesCount }, (_, index) => [
index + 1,
{ cellWidth: valueColumnWidth, halign: 'center' }
])
)
}
}
}
const valuesCount = exercise.values?.length || 0
const headers = [VALUE_TABLE_LABEL, ...Array.from({ length: valuesCount }, (_, index) => `${index + 1}`)]
const firstColumnWidth = 32
const valueColumnWidth = valuesCount > 0 ? (CONTENT_WIDTH - firstColumnWidth) / valuesCount : CONTENT_WIDTH - firstColumnWidth
return {
head: [headers],
body: [['Ist', ...(exercise.values || []).map(formatValue)]],
columnStyles: {
0: { cellWidth: firstColumnWidth, halign: 'center' },
...Object.fromEntries(
Array.from({ length: valuesCount }, (_, index) => [
index + 1,
{ cellWidth: valueColumnWidth, halign: 'center' }
])
)
}
}
}
const buildMetricsTable = (exercise) => ({
head: [['Soll', 'Durchschnitt ist', 'Faktor', 'Punkte']],
body: [[
formatValue(exercise.soll),
(exercise.durchschnitt || 0).toFixed(2),
formatValue(exercise.faktor),
(exercise.points || 0).toFixed(0)
]],
columnStyles: {
0: { cellWidth: CONTENT_WIDTH / 4, halign: 'center' },
1: { cellWidth: CONTENT_WIDTH / 4, halign: 'center' },
2: { cellWidth: CONTENT_WIDTH / 4, halign: 'center' },
3: { cellWidth: CONTENT_WIDTH / 4, halign: 'center' }
}
})
const buildSummaryTable = ({ assessment, totalPoints, achievement }) => ({
head: [['Name', 'PAT Typ', 'Datum', 'Gesamtpunkte', 'Bewertung']],
body: [[
assessment.name || '—',
assessment.patType || '—',
formatDate(assessment.datum),
String(Math.round(totalPoints || 0)),
achievement?.name || '—'
]],
columnStyles: {
0: { cellWidth: 42 },
1: { cellWidth: 28 },
2: { cellWidth: 34 },
3: { cellWidth: 34, halign: 'center' },
4: { cellWidth: 44 }
}
})
const buildFooterItems = ({ assessment, achievement, pageNumber, pageCount }) => ([
`Name: ${truncateText(assessment?.name, 20)}`,
`PAT Typ: ${truncateText(assessment?.patType, 14)}`,
`Datum: ${formatDate(assessment?.datum)}`,
`Bewertung: ${truncateText(achievement?.name, 18)}`,
`Seite ${pageNumber}/${pageCount}`
])
const ensureSectionSpace = (doc, startY, minHeight) => {
const pageHeight = doc.internal.pageSize.getHeight()
if (startY + minHeight <= pageHeight - PAGE_BOTTOM) {
return startY
}
doc.addPage()
return CONTINUATION_PAGE_TOP
}
const drawFooter = (doc, { assessment, achievement }) => {
const pageCount = doc.getNumberOfPages()
const pageHeight = doc.internal.pageSize.getHeight()
const pageWidth = doc.internal.pageSize.getWidth()
const footerY = pageHeight - 8
const footerXs = [
PAGE_MARGIN,
PAGE_MARGIN + 47,
PAGE_MARGIN + 88,
PAGE_MARGIN + 130,
pageWidth - PAGE_MARGIN
]
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) {
const [nameText, patTypeText, dateText, achievementText, pageText] = buildFooterItems({
assessment,
achievement,
pageNumber,
pageCount
})
doc.setPage(pageNumber)
doc.setDrawColor(226, 232, 240)
doc.setLineWidth(0.3)
doc.line(PAGE_MARGIN, footerY - 4, pageWidth - PAGE_MARGIN, footerY - 4)
doc.setFont('helvetica', 'normal')
doc.setFontSize(8.5)
doc.setTextColor(100, 116, 139)
doc.text(nameText, footerXs[0], footerY)
doc.text(patTypeText, footerXs[1], footerY)
doc.text(dateText, footerXs[2], footerY)
doc.text(achievementText, footerXs[3], footerY)
doc.text(pageText, footerXs[4], footerY, { align: 'right' })
}
}
export const downloadAssessmentPdf = async ({ assessment, totalPoints, achievement }) => {
if (!assessment) return
const [{ jsPDF }, { default: autoTable }] = await Promise.all([
import('jspdf'),
import('jspdf-autotable')
])
const doc = new jsPDF({
unit: 'mm',
format: 'a4'
})
doc.setFillColor(16, 185, 129)
doc.roundedRect(PAGE_MARGIN, PAGE_TOP, CONTENT_WIDTH, 18, 4, 4, 'F')
doc.setFont('helvetica', 'bold')
doc.setFontSize(20)
doc.setTextColor(255, 255, 255)
doc.text('PAT Test Details', 20, 24)
autoTable(doc, {
startY: 38,
margin: { left: PAGE_MARGIN, right: PAGE_MARGIN, bottom: PAGE_BOTTOM },
theme: 'grid',
styles: {
font: 'helvetica',
fontSize: TABLE_FONT_SIZE,
cellPadding: 4,
textColor: [31, 41, 55],
lineColor: [226, 232, 240],
lineWidth: 0.3
},
headStyles: {
fillColor: [241, 245, 249],
textColor: [71, 85, 105],
fontStyle: 'bold'
},
bodyStyles: {
fillColor: [255, 255, 255]
},
tableWidth: CONTENT_WIDTH,
...buildSummaryTable({ assessment, totalPoints, achievement })
})
;(assessment.exercises || []).forEach((exercise, index) => {
const headingY = ensureSectionSpace(
doc,
(doc.lastAutoTable?.finalY || 38) + 8,
estimateExerciseSectionHeight(exercise)
)
doc.setFont('helvetica', 'bold')
doc.setFontSize(14)
doc.setTextColor(17, 24, 39)
doc.text(exercise.name || `Übung ${index + 1}`, PAGE_MARGIN, headingY)
autoTable(doc, {
startY: headingY + 4,
margin: { left: PAGE_MARGIN, right: PAGE_MARGIN, bottom: PAGE_BOTTOM },
theme: 'grid',
styles: {
font: 'helvetica',
fontSize: TABLE_FONT_SIZE,
cellPadding: TABLE_CELL_PADDING,
halign: 'center',
textColor: [31, 41, 55],
lineColor: [226, 232, 240],
lineWidth: 0.3
},
headStyles: {
fillColor: [239, 246, 255],
textColor: [30, 64, 175],
fontStyle: 'bold',
halign: 'center'
},
bodyStyles: {
fillColor: [255, 255, 255],
halign: 'center'
},
tableWidth: CONTENT_WIDTH,
pageBreak: 'avoid',
rowPageBreak: 'avoid',
...buildValueTable(exercise)
})
autoTable(doc, {
startY: (doc.lastAutoTable?.finalY || headingY + 4) + 3,
margin: { left: PAGE_MARGIN, right: PAGE_MARGIN, bottom: PAGE_BOTTOM },
theme: 'grid',
styles: {
font: 'helvetica',
fontSize: TABLE_FONT_SIZE,
cellPadding: TABLE_CELL_PADDING,
textColor: [31, 41, 55],
lineColor: [226, 232, 240],
lineWidth: 0.3
},
headStyles: {
fillColor: [236, 253, 245],
textColor: [6, 95, 70],
fontStyle: 'bold'
},
bodyStyles: {
fillColor: [255, 255, 255],
halign: 'center'
},
didParseCell: ({ section, column, cell }) => {
if (section === 'body' && column.index === 3) {
cell.styles.fillColor = [241, 245, 249]
}
},
tableWidth: CONTENT_WIDTH,
pageBreak: 'avoid',
rowPageBreak: 'avoid',
...buildMetricsTable(exercise)
})
})
const fileName = [
sanitizeFileName(assessment.name),
sanitizeFileName(assessment.patType),
sanitizeFileName(assessment.datum)
]
.filter(Boolean)
.join('_')
drawFooter(doc, { assessment, achievement })
doc.save(`${fileName || 'pat-test'}.pdf`)
}

View File

@@ -0,0 +1,127 @@
const DEFAULT_MAIN_PATTERN = [0, 1, 0, 2, 0, 1, 0, 1, 2, 0];
const clampToAllowed = (value, allowed, fallback) =>
allowed.includes(value) ? value : fallback;
const getIntensity = (gapScore) => {
if (gapScore >= 0.7) return 'hoch';
if (gapScore >= 0.4) return 'mittel';
return 'leicht';
};
const buildWarmupTask = () => ({
type: 'warmup',
title: 'Warm-up',
durationMin: 10,
instructions: 'Lockere Stoßserie, Fokus auf saubere Ansprechposition und Rhythmus.'
});
const buildMainTask = (exerciseName, intensity) => {
const repetitionsByIntensity = {
hoch: 24,
mittel: 18,
leicht: 12
};
return {
type: 'main',
title: `Hauptblock: ${exerciseName}`,
durationMin: intensity === 'hoch' ? 35 : 28,
repetitions: repetitionsByIntensity[intensity],
intensity,
instructions: 'Dokumentiere Trefferquote pro Serie und halte Pausen kurz (30-45 Sek.).'
};
};
const buildSecondaryTask = (exerciseName, order) => ({
type: 'secondary',
title: `Nebenblock ${order}: ${exerciseName}`,
durationMin: 18,
repetitions: 10,
intensity: 'mittel',
instructions: 'Konstanter Ablauf, Fokus auf Präzision statt Tempo.'
});
const buildCooldownTask = () => ({
type: 'cooldown',
title: 'Cool-down',
durationMin: 8,
instructions: 'Kurze Reflexion: 2 Dinge die gut liefen, 1 Punkt für den nächsten Tag.'
});
const pickMainExercise = (index, topWeaknesses) => {
if (!topWeaknesses.length) {
return { name: 'Generelle Basistechnik', gapScore: 0.3, priorityScore: 0.3 };
}
const patternIndex = DEFAULT_MAIN_PATTERN[index % DEFAULT_MAIN_PATTERN.length];
const safeIndex = patternIndex % topWeaknesses.length;
return topWeaknesses[safeIndex];
};
const pickSecondaryExercises = (mainExerciseName, topWeaknesses, topStrengths) => {
const pool = [
...topWeaknesses.map((item) => item?.name),
...topStrengths.map((item) => item?.name)
].filter(Boolean);
const uniquePool = [...new Set(pool)].filter((name) => name !== mainExerciseName);
if (uniquePool.length >= 2) return uniquePool.slice(0, 2);
if (uniquePool.length === 1) return [uniquePool[0], 'Positionsspiel Basis'];
return ['Positionsspiel Basis', 'Stoßroutine Stabilisierung'];
};
export const generateTrainingPlan = ({
topWeaknesses = [],
topStrengths = [],
durationWeeks = 4,
sessionsPerWeek = 3,
sessionMix = '1-main-2-secondary'
} = {}) => {
const safeDurationWeeks = clampToAllowed(Number(durationWeeks), [2, 4, 6], 4);
const safeSessionsPerWeek = clampToAllowed(Number(sessionsPerWeek), [2, 3, 4], 3);
const totalSessions = safeDurationWeeks * safeSessionsPerWeek;
const sessions = Array.from({ length: totalSessions }, (_, index) => {
const weekNo = Math.floor(index / safeSessionsPerWeek) + 1;
const sessionNo = (index % safeSessionsPerWeek) + 1;
const mainExercise = pickMainExercise(index, topWeaknesses);
const intensity = getIntensity(Number(mainExercise?.gapScore || 0));
const secondaryExercises = pickSecondaryExercises(mainExercise?.name, topWeaknesses, topStrengths);
const tasks = [
buildWarmupTask(),
buildMainTask(mainExercise?.name || 'Basistechnik', intensity),
buildSecondaryTask(secondaryExercises[0], 1),
buildSecondaryTask(secondaryExercises[1], 2),
buildCooldownTask()
];
return {
weekNo,
sessionNo,
mainExercise: mainExercise?.name || 'Basistechnik',
secondaryExercises,
sessionMix,
intensity,
state: 'open',
notes: '',
tasks,
meta: {
gapScore: Number(mainExercise?.gapScore || 0),
priorityScore: Number(mainExercise?.priorityScore || 0)
}
};
});
return {
durationWeeks: safeDurationWeeks,
sessionsPerWeek: safeSessionsPerWeek,
sessions
};
};
export default generateTrainingPlan;

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { generateTrainingPlan } from './trainingPlanGenerator';
const topWeaknesses = [
{ name: 'A', gapScore: 0.8, priorityScore: 0.9 },
{ name: 'B', gapScore: 0.6, priorityScore: 0.7 },
{ name: 'C', gapScore: 0.4, priorityScore: 0.5 }
];
const topStrengths = [
{ name: 'S1' },
{ name: 'S2' }
];
describe('generateTrainingPlan', () => {
it('creates expected number of sessions for duration and cadence', () => {
const plan = generateTrainingPlan({
topWeaknesses,
topStrengths,
durationWeeks: 2,
sessionsPerWeek: 3
});
expect(plan.sessions).toHaveLength(6);
expect(plan.durationWeeks).toBe(2);
expect(plan.sessionsPerWeek).toBe(3);
});
it('ensures each session has one main and two secondary exercises', () => {
const plan = generateTrainingPlan({
topWeaknesses,
topStrengths,
durationWeeks: 4,
sessionsPerWeek: 2
});
for (const session of plan.sessions) {
expect(session.mainExercise).toBeTruthy();
expect(session.secondaryExercises).toHaveLength(2);
const mainTasks = session.tasks.filter((task) => task.type === 'main');
const secondaryTasks = session.tasks.filter((task) => task.type === 'secondary');
expect(mainTasks).toHaveLength(1);
expect(secondaryTasks).toHaveLength(2);
}
});
it('rotates weighted main focus with 50/30/20 pattern', () => {
const plan = generateTrainingPlan({
topWeaknesses,
topStrengths,
durationWeeks: 6,
sessionsPerWeek: 4
});
expect(plan.sessions).toHaveLength(24);
const counts = plan.sessions.reduce(
(accumulator, session) => {
accumulator[session.mainExercise] = (accumulator[session.mainExercise] || 0) + 1;
return accumulator;
},
{ A: 0, B: 0, C: 0 }
);
expect(counts.A).toBe(12);
expect(counts.B).toBe(7);
expect(counts.C).toBe(5);
});
});

View File

@@ -0,0 +1,49 @@
-- Assessments table for PAT Stats
create table if not exists public.assessments (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
pat_type text not null,
datum date not null,
name text not null,
exercises jsonb not null default '[]'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- Keep updated_at current
create or replace function public.set_updated_at()
returns trigger as $$
begin
new.updated_at = now();
return new;
end;
$$ language plpgsql;
drop trigger if exists trg_assessments_updated_at on public.assessments;
create trigger trg_assessments_updated_at
before update on public.assessments
for each row execute function public.set_updated_at();
-- Row Level Security
alter table public.assessments enable row level security;
-- Policies: authenticated users manage only their own rows
create policy if not exists "Allow users to insert own assessments"
on public.assessments
for insert with check (auth.uid() = user_id);
create policy if not exists "Allow users to select own assessments"
on public.assessments
for select using (auth.uid() = user_id);
create policy if not exists "Allow users to update own assessments"
on public.assessments
for update using (auth.uid() = user_id);
create policy if not exists "Allow users to delete own assessments"
on public.assessments
for delete using (auth.uid() = user_id);
-- Helpful index
create index if not exists idx_assessments_user_id_created_at
on public.assessments (user_id, created_at desc);

View File

@@ -0,0 +1,233 @@
-- Training plans and sessions for analysis-driven coaching
create table if not exists public.training_plans (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
pat_type text not null,
status text not null default 'active' check (status in ('active', 'archived')),
analysis_snapshot jsonb not null default '{}'::jsonb,
duration_weeks integer not null check (duration_weeks in (2, 4, 6)),
sessions_per_week integer not null check (sessions_per_week in (2, 3, 4)),
generated_at timestamptz not null default now(),
archived_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists public.training_plan_sessions (
id uuid primary key default gen_random_uuid(),
plan_id uuid not null references public.training_plans(id) on delete cascade,
week_no integer not null,
session_no integer not null,
main_exercise text not null,
secondary_exercises jsonb not null default '[]'::jsonb,
tasks jsonb not null default '[]'::jsonb,
state text not null default 'open' check (state in ('open', 'done', 'skipped')),
notes text,
completed_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (plan_id, week_no, session_no)
);
-- updated_at trigger reuse
create or replace function public.set_updated_at()
returns trigger as $$
begin
new.updated_at = now();
return new;
end;
$$ language plpgsql;
drop trigger if exists trg_training_plans_updated_at on public.training_plans;
create trigger trg_training_plans_updated_at
before update on public.training_plans
for each row execute function public.set_updated_at();
drop trigger if exists trg_training_plan_sessions_updated_at on public.training_plan_sessions;
create trigger trg_training_plan_sessions_updated_at
before update on public.training_plan_sessions
for each row execute function public.set_updated_at();
-- RLS
alter table public.training_plans enable row level security;
alter table public.training_plan_sessions enable row level security;
drop policy if exists "Allow users to insert own training plans" on public.training_plans;
create policy "Allow users to insert own training plans"
on public.training_plans
for insert
with check (auth.uid() = user_id);
drop policy if exists "Allow users to select own training plans" on public.training_plans;
create policy "Allow users to select own training plans"
on public.training_plans
for select
using (auth.uid() = user_id);
drop policy if exists "Allow users to update own training plans" on public.training_plans;
create policy "Allow users to update own training plans"
on public.training_plans
for update
using (auth.uid() = user_id);
drop policy if exists "Allow users to delete own training plans" on public.training_plans;
create policy "Allow users to delete own training plans"
on public.training_plans
for delete
using (auth.uid() = user_id);
drop policy if exists "Allow users to insert own training plan sessions" on public.training_plan_sessions;
create policy "Allow users to insert own training plan sessions"
on public.training_plan_sessions
for insert
with check (
exists (
select 1
from public.training_plans tp
where tp.id = training_plan_sessions.plan_id
and tp.user_id = auth.uid()
)
);
drop policy if exists "Allow users to select own training plan sessions" on public.training_plan_sessions;
create policy "Allow users to select own training plan sessions"
on public.training_plan_sessions
for select
using (
exists (
select 1
from public.training_plans tp
where tp.id = training_plan_sessions.plan_id
and tp.user_id = auth.uid()
)
);
drop policy if exists "Allow users to update own training plan sessions" on public.training_plan_sessions;
create policy "Allow users to update own training plan sessions"
on public.training_plan_sessions
for update
using (
exists (
select 1
from public.training_plans tp
where tp.id = training_plan_sessions.plan_id
and tp.user_id = auth.uid()
)
);
drop policy if exists "Allow users to delete own training plan sessions" on public.training_plan_sessions;
create policy "Allow users to delete own training plan sessions"
on public.training_plan_sessions
for delete
using (
exists (
select 1
from public.training_plans tp
where tp.id = training_plan_sessions.plan_id
and tp.user_id = auth.uid()
)
);
create index if not exists idx_training_plans_user_pat_generated
on public.training_plans(user_id, pat_type, generated_at desc);
create unique index if not exists idx_training_plans_user_pat_active_unique
on public.training_plans(user_id, pat_type)
where status = 'active';
create index if not exists idx_training_plan_sessions_plan_week_session
on public.training_plan_sessions(plan_id, week_no, session_no);
create or replace function public.create_training_plan_with_sessions(
p_pat_type text,
p_analysis_snapshot jsonb,
p_duration_weeks integer,
p_sessions_per_week integer,
p_sessions jsonb
)
returns uuid
language plpgsql
security invoker
as $$
declare
v_user_id uuid;
v_plan_id uuid;
v_session jsonb;
v_week_no integer;
v_session_no integer;
begin
v_user_id := auth.uid();
if v_user_id is null then
raise exception 'Not authenticated';
end if;
if p_duration_weeks not in (2, 4, 6) then
raise exception 'Invalid duration_weeks: %', p_duration_weeks;
end if;
if p_sessions_per_week not in (2, 3, 4) then
raise exception 'Invalid sessions_per_week: %', p_sessions_per_week;
end if;
update public.training_plans
set status = 'archived', archived_at = now()
where user_id = v_user_id
and pat_type = p_pat_type
and status = 'active';
insert into public.training_plans (
user_id,
pat_type,
status,
analysis_snapshot,
duration_weeks,
sessions_per_week,
generated_at
)
values (
v_user_id,
p_pat_type,
'active',
coalesce(p_analysis_snapshot, '{}'::jsonb),
p_duration_weeks,
p_sessions_per_week,
now()
)
returning id into v_plan_id;
for v_session in
select value
from jsonb_array_elements(coalesce(p_sessions, '[]'::jsonb))
loop
v_week_no := coalesce((v_session->>'weekNo')::integer, (v_session->>'week_no')::integer, 1);
v_session_no := coalesce((v_session->>'sessionNo')::integer, (v_session->>'session_no')::integer, 1);
insert into public.training_plan_sessions (
plan_id,
week_no,
session_no,
main_exercise,
secondary_exercises,
tasks,
state,
notes
)
values (
v_plan_id,
v_week_no,
v_session_no,
coalesce(v_session->>'mainExercise', v_session->>'main_exercise', 'Basistechnik'),
coalesce(v_session->'secondaryExercises', v_session->'secondary_exercises', '[]'::jsonb),
coalesce(v_session->'tasks', '[]'::jsonb),
coalesce(v_session->>'state', 'open'),
nullif(v_session->>'notes', '')
);
end loop;
return v_plan_id;
end;
$$;
grant execute on function public.create_training_plan_with_sessions(text, jsonb, integer, integer, jsonb)
to authenticated;

View File

@@ -0,0 +1,136 @@
-- Token-based sharing for individual assessments
create table if not exists public.assessment_shares (
assessment_id uuid primary key references public.assessments(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
share_token text not null unique,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
drop trigger if exists trg_assessment_shares_updated_at on public.assessment_shares;
create trigger trg_assessment_shares_updated_at
before update on public.assessment_shares
for each row execute function public.set_updated_at();
alter table public.assessment_shares enable row level security;
drop policy if exists "Allow users to insert own assessment shares" on public.assessment_shares;
create policy "Allow users to insert own assessment shares"
on public.assessment_shares
for insert
with check (
auth.uid() = user_id
and exists (
select 1
from public.assessments a
where a.id = assessment_shares.assessment_id
and a.user_id = auth.uid()
)
);
drop policy if exists "Allow users to select own assessment shares" on public.assessment_shares;
create policy "Allow users to select own assessment shares"
on public.assessment_shares
for select
using (auth.uid() = user_id);
drop policy if exists "Allow users to update own assessment shares" on public.assessment_shares;
create policy "Allow users to update own assessment shares"
on public.assessment_shares
for update
using (auth.uid() = user_id)
with check (
auth.uid() = user_id
and exists (
select 1
from public.assessments a
where a.id = assessment_shares.assessment_id
and a.user_id = auth.uid()
)
);
drop policy if exists "Allow users to delete own assessment shares" on public.assessment_shares;
create policy "Allow users to delete own assessment shares"
on public.assessment_shares
for delete
using (auth.uid() = user_id);
create index if not exists idx_assessment_shares_user_id
on public.assessment_shares(user_id);
create or replace function public.generate_share_token()
returns text
language sql
as $$
select replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', '');
$$;
create or replace function public.create_or_get_assessment_share(p_assessment_id uuid)
returns table (
assessment_id uuid,
share_token text
)
language plpgsql
security definer
set search_path = public
as $$
declare
v_user_id uuid;
begin
v_user_id := auth.uid();
if v_user_id is null then
raise exception 'Not authenticated';
end if;
if not exists (
select 1
from public.assessments a
where a.id = p_assessment_id
and a.user_id = v_user_id
) then
raise exception 'Assessment not found';
end if;
insert into public.assessment_shares (assessment_id, user_id, share_token)
values (p_assessment_id, v_user_id, public.generate_share_token())
on conflict on constraint assessment_shares_pkey do nothing;
return query
select s.assessment_id, s.share_token
from public.assessment_shares s
where s.assessment_id = p_assessment_id
and s.user_id = v_user_id
limit 1;
end;
$$;
create or replace function public.get_shared_assessment(p_share_token text)
returns table (
id uuid,
pat_type text,
datum date,
name text,
exercises jsonb
)
language sql
security definer
set search_path = public
as $$
select
a.id,
a.pat_type,
a.datum,
a.name,
a.exercises
from public.assessment_shares s
join public.assessments a on a.id = s.assessment_id
where s.share_token = p_share_token
limit 1;
$$;
revoke all on function public.create_or_get_assessment_share(uuid) from public;
grant execute on function public.create_or_get_assessment_share(uuid) to authenticated;
revoke all on function public.get_shared_assessment(text) from public;
grant execute on function public.get_shared_assessment(text) to anon, authenticated;

View File

@@ -0,0 +1,45 @@
alter table public.assessments
add column if not exists is_finalized boolean not null default false,
add column if not exists finalized_at timestamptz,
add column if not exists finalization_reminder_sent_at timestamptz;
create index if not exists idx_assessments_user_id_finalization
on public.assessments (user_id, is_finalized, created_at desc);
create index if not exists idx_assessments_open_finalization_reminders
on public.assessments (created_at)
where is_finalized = false and finalization_reminder_sent_at is null;
create or replace function public.guard_assessment_finalization()
returns trigger as $$
begin
if tg_op = 'UPDATE' and old.is_finalized then
if new.pat_type is distinct from old.pat_type
or new.datum is distinct from old.datum
or new.name is distinct from old.name
or new.exercises is distinct from old.exercises
or new.is_finalized is distinct from old.is_finalized
or new.finalized_at is distinct from old.finalized_at then
raise exception 'Finalisierte Bewertungen koennen nicht mehr geaendert werden.';
end if;
end if;
if new.is_finalized then
if tg_op = 'INSERT' then
new.finalized_at := coalesce(new.finalized_at, now());
else
new.finalized_at := coalesce(new.finalized_at, old.finalized_at, now());
end if;
new.finalization_reminder_sent_at := null;
else
new.finalized_at := null;
end if;
return new;
end;
$$ language plpgsql;
drop trigger if exists trg_assessments_finalization_guard on public.assessments;
create trigger trg_assessments_finalization_guard
before insert or update on public.assessments
for each row execute function public.guard_assessment_finalization();

12
tailwind.config.js Normal file
View File

@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -0,0 +1,7 @@
{
"folders": [
{
"path": "."
}
]
}

BIN
unusedstuf/Screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 KiB

BIN
unusedstuf/Screenshot_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 KiB

BIN
unusedstuf/Screenshot_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 691 KiB

BIN
unusedstuf/Screenshot_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 725 KiB

BIN
unusedstuf/Screenshot_4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Binary file not shown.

BIN
unusedstuf/page1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
unusedstuf/row1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

BIN
unusedstuf/row2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

BIN
unusedstuf/row3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

BIN
unusedstuf/row4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

BIN
unusedstuf/row5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

BIN
unusedstuf/top_band.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

10
vite.config.js Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'node',
include: ['src/**/*.test.{js,jsx}']
}
})