From 526da02512fdb6e0ed780226a3996ec080a3ba4f Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 12:52:13 +0100 Subject: [PATCH 01/14] fix: change trial banner button to mailto contact link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace subscription selection link with email contact for now. Future: Central subscription system on jinkendo.de for all apps. Button text: - "Abo wählen" → "Abo anfragen" - "Jetzt upgraden" → "Kontakt aufnehmen" Opens mailto:mitai@jinkendo.de with pre-filled subject and body. Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/TrialBanner.jsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/TrialBanner.jsx b/frontend/src/components/TrialBanner.jsx index 6208700..e2c787b 100644 --- a/frontend/src/components/TrialBanner.jsx +++ b/frontend/src/components/TrialBanner.jsx @@ -64,8 +64,8 @@ export default function TrialBanner({ profile }) { - - {isUrgent ? 'Jetzt upgraden' : 'Abo wählen'} - + {isUrgent ? 'Kontakt aufnehmen' : 'Abo anfragen'} + ) } From 0aca5fda5d7ecebfc29feb180f2031a171dfe29e Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 12:56:18 +0100 Subject: [PATCH 02/14] docs: update CLAUDE.md for v9c completion and new bug fixes - Mark v9c as deployed to production (21.03.2026) - Add BUG-005 to BUG-008 (login/verify navigation fixes) - Document TrialBanner mailto change (on develop) - Mark v9d as in progress Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 300a352..ee3e196 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ frontend/src/ └── technical/ # MEMBERSHIP_SYSTEM.md ``` -## Aktuelle Version: v9c (komplett) +## Aktuelle Version: v9c (komplett) 🚀 Production seit 21.03.2026 ### Implementiert ✅ - Login (E-Mail + bcrypt), Auth-Middleware alle Endpoints, Rate Limiting @@ -81,15 +81,27 @@ frontend/src/ - ✅ **BUG-002:** Ernährungs-Daten Tab fehlte – importierte Einträge nicht sichtbar - ✅ **BUG-003:** Korrelations-Chart Extrapolation (gestrichelte Linien für fehlende Werte) - ✅ **BUG-004:** Import-Historie Refresh (Force remount via key prop) +- ✅ **BUG-005:** Login → leere Seite (window.location.href='/' nach login) +- ✅ **BUG-006:** Email-Verifizierung → leere Seite (window.location.href='/' statt navigate) +- ✅ **BUG-007:** Doppelklick Verifizierungslink → generischer JSON-Fehler (Error-Parsing + bessere Backend-Meldung) +- ✅ **BUG-008:** Dashboard infinite loading bei API-Fehlern (.catch() handler in load()) -### v9c Finalisierung ✅ +### v9c Finalisierung ✅ (Deployed to Production 21.03.2026) - ✅ **Selbst-Registrierung:** POST /api/auth/register, E-Mail-Verifizierung, Auto-Login - ✅ **Trial-System UI:** Countdown-Banner im Dashboard (3 Urgency-Level) - ✅ **Migrations-System:** Automatische Schema-Migrationen beim Start (db_init.py) +- ✅ **Navigation-Fixes:** Alle Login/Verify-Flows funktionieren korrekt +- ✅ **Error-Handling:** JSON-Fehler sauber formatiert, Dashboard robust bei API-Fehlern -### Offen v9d 🔲 -- Schlaf-Modul -- Trainingstypen + Herzfrequenz +### Auf develop (nicht deployed) 📦 +- ✅ **TrialBanner mailto:** "Abo wählen" → mailto:mitai@jinkendo.de (Vorbereitung für zentrales Abo-System) +- 📚 Dokumentation: `.claude/docs/technical/CENTRAL_SUBSCRIPTION_SYSTEM.md` + +### v9d – In Arbeit 🔄 +- 🔲 Schlaf-Modul (Erfassung, Import, Auswertung, Korrelationen) +- 🔲 Trainingstypen (Cardio, Kraft, HIIT, Mobility, Erholung) +- 🔲 Ruhetage erfassen +- 🔲 Ruhepuls + HF-Zonen + VO2Max-Schätzung 📚 Details: `.claude/docs/technical/MEMBERSHIP_SYSTEM.md` · `.claude/docs/architecture/FEATURE_ENFORCEMENT.md` From 410b2ce308a773af9334361adcafbf353f726e78 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 13:05:33 +0100 Subject: [PATCH 03/14] feat(v9d): add training types system + logout button Phase 1: Training Types Basis ============================= Backend: - Migration 004: training_types table + seed data (24 types) - New router: /api/training-types (grouped, flat, categories) - Extend activity_log: training_type_id, training_category, training_subcategory - Extend ActivityEntry model: support training type fields Frontend: - TrainingTypeSelect component (two-level dropdown) - TrainingTypeDistribution component (pie chart) - API functions: listTrainingTypes, listTrainingTypesFlat, getTrainingCategories Quick Win: Logout Button ======================== - Add LogOut icon button in app header - Confirm dialog before logout - Redirect to / after logout - Hover effect: red color on hover Not yet integrated: - TrainingTypeSelect not yet in ActivityPage form - TrainingTypeDistribution not yet in Dashboard (will be added in next commit) Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 5 +- backend/migrations/004_training_types.sql | 86 ++++++++++++ backend/models.py | 3 + backend/routers/training_types.py | 123 ++++++++++++++++++ frontend/src/App.jsx | 43 ++++-- .../components/TrainingTypeDistribution.jsx | 120 +++++++++++++++++ .../src/components/TrainingTypeSelect.jsx | 114 ++++++++++++++++ frontend/src/utils/api.js | 5 + 8 files changed, 490 insertions(+), 9 deletions(-) create mode 100644 backend/migrations/004_training_types.sql create mode 100644 backend/routers/training_types.py create mode 100644 frontend/src/components/TrainingTypeDistribution.jsx create mode 100644 frontend/src/components/TrainingTypeSelect.jsx diff --git a/backend/main.py b/backend/main.py index f4bf4f8..192033b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -19,7 +19,7 @@ from routers import auth, profiles, weight, circumference, caliper from routers import activity, nutrition, photos, insights, prompts from routers import admin, stats, exportdata, importdata from routers import subscription, coupons, features, tiers_mgmt, tier_limits -from routers import user_restrictions, access_grants +from routers import user_restrictions, access_grants, training_types # ── App Configuration ───────────────────────────────────────────────────────── DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) @@ -85,6 +85,9 @@ app.include_router(tier_limits.router) # /api/tier-limits (admin) app.include_router(user_restrictions.router) # /api/user-restrictions (admin) app.include_router(access_grants.router) # /api/access-grants (admin) +# v9d Training Types +app.include_router(training_types.router) # /api/training-types/* + # ── Health Check ────────────────────────────────────────────────────────────── @app.get("/") def root(): diff --git a/backend/migrations/004_training_types.sql b/backend/migrations/004_training_types.sql new file mode 100644 index 0000000..59b254c --- /dev/null +++ b/backend/migrations/004_training_types.sql @@ -0,0 +1,86 @@ +-- Migration 004: Training Types & Categories +-- Part of v9d: Schlaf + Sport-Vertiefung +-- Created: 2026-03-21 + +-- ======================================== +-- 1. Create training_types table +-- ======================================== +CREATE TABLE IF NOT EXISTS training_types ( + id SERIAL PRIMARY KEY, + category VARCHAR(50) NOT NULL, -- Main category: 'cardio', 'strength', 'hiit', etc. + subcategory VARCHAR(50), -- Optional: 'running', 'hypertrophy', etc. + name_de VARCHAR(100) NOT NULL, -- German display name + name_en VARCHAR(100) NOT NULL, -- English display name + icon VARCHAR(10), -- Emoji icon + sort_order INTEGER DEFAULT 0, -- For UI ordering + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- ======================================== +-- 2. Add training type columns to activity_log +-- ======================================== +ALTER TABLE activity_log + ADD COLUMN IF NOT EXISTS training_type_id INTEGER REFERENCES training_types(id), + ADD COLUMN IF NOT EXISTS training_category VARCHAR(50), -- Denormalized for fast queries + ADD COLUMN IF NOT EXISTS training_subcategory VARCHAR(50); -- Denormalized + +-- ======================================== +-- 3. Create indexes +-- ======================================== +CREATE INDEX IF NOT EXISTS idx_activity_training_type ON activity_log(training_type_id); +CREATE INDEX IF NOT EXISTS idx_activity_training_category ON activity_log(training_category); +CREATE INDEX IF NOT EXISTS idx_training_types_category ON training_types(category); + +-- ======================================== +-- 4. Seed training types data +-- ======================================== + +-- Cardio (Ausdauer) +INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES + ('cardio', 'running', 'Laufen', 'Running', '🏃', 100), + ('cardio', 'cycling', 'Radfahren', 'Cycling', '🚴', 101), + ('cardio', 'swimming', 'Schwimmen', 'Swimming', '🏊', 102), + ('cardio', 'rowing', 'Rudern', 'Rowing', '🚣', 103), + ('cardio', 'other', 'Sonstiges Cardio', 'Other Cardio', '❤️', 104); + +-- Kraft +INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES + ('strength', 'hypertrophy', 'Hypertrophie', 'Hypertrophy', '💪', 200), + ('strength', 'maxstrength', 'Maximalkraft', 'Max Strength', '🏋️', 201), + ('strength', 'endurance', 'Kraftausdauer', 'Strength Endurance', '🔁', 202), + ('strength', 'functional', 'Funktionell', 'Functional', '⚡', 203); + +-- Schnellkraft / HIIT +INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES + ('hiit', 'hiit', 'HIIT', 'HIIT', '🔥', 300), + ('hiit', 'explosive', 'Explosiv', 'Explosive', '💥', 301), + ('hiit', 'circuit', 'Circuit Training', 'Circuit Training', '🔄', 302); + +-- Kampfsport / Technikkraft +INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES + ('martial_arts', 'technique', 'Techniktraining', 'Technique Training', '🥋', 400), + ('martial_arts', 'sparring', 'Sparring / Wettkampf', 'Sparring / Competition', '🥊', 401), + ('martial_arts', 'strength', 'Kraft für Kampfsport', 'Martial Arts Strength', '⚔️', 402); + +-- Mobility & Dehnung +INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES + ('mobility', 'static', 'Statisches Dehnen', 'Static Stretching', '🧘', 500), + ('mobility', 'dynamic', 'Dynamisches Dehnen', 'Dynamic Stretching', '🤸', 501), + ('mobility', 'yoga', 'Yoga', 'Yoga', '🕉️', 502), + ('mobility', 'fascia', 'Faszienarbeit', 'Fascia Work', '🎯', 503); + +-- Erholung (aktiv) +INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES + ('recovery', 'walk', 'Spaziergang', 'Walk', '🚶', 600), + ('recovery', 'swim_light', 'Leichtes Schwimmen', 'Light Swimming', '🏊', 601), + ('recovery', 'regeneration', 'Regenerationseinheit', 'Regeneration', '💆', 602); + +-- General / Uncategorized +INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES + ('other', NULL, 'Sonstiges', 'Other', '📝', 900); + +-- ======================================== +-- 5. Add comment +-- ======================================== +COMMENT ON TABLE training_types IS 'v9d: Training type categories and subcategories'; +COMMENT ON TABLE activity_log IS 'Extended in v9d with training_type_id for categorization'; diff --git a/backend/models.py b/backend/models.py index 380e300..86bdb8c 100644 --- a/backend/models.py +++ b/backend/models.py @@ -84,6 +84,9 @@ class ActivityEntry(BaseModel): rpe: Optional[int] = None source: Optional[str] = 'manual' notes: Optional[str] = None + training_type_id: Optional[int] = None # v9d: Training type categorization + training_category: Optional[str] = None # v9d: Denormalized category + training_subcategory: Optional[str] = None # v9d: Denormalized subcategory class NutritionDay(BaseModel): diff --git a/backend/routers/training_types.py b/backend/routers/training_types.py new file mode 100644 index 0000000..ab24785 --- /dev/null +++ b/backend/routers/training_types.py @@ -0,0 +1,123 @@ +""" +Training Types API - v9d + +Provides hierarchical list of training categories and subcategories +for activity classification. +""" +from fastapi import APIRouter, Depends +from db import get_db, get_cursor +from auth import require_auth + +router = APIRouter(prefix="/api/training-types", tags=["training-types"]) + + +@router.get("") +def list_training_types(session: dict = Depends(require_auth)): + """ + Get all training types, grouped by category. + + Returns hierarchical structure: + { + "cardio": [ + {"id": 1, "subcategory": "running", "name_de": "Laufen", ...}, + ... + ], + "strength": [...], + ... + } + """ + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(""" + SELECT id, category, subcategory, name_de, name_en, icon, sort_order + FROM training_types + ORDER BY sort_order, category, subcategory + """) + rows = cur.fetchall() + + # Group by category + grouped = {} + for row in rows: + cat = row['category'] + if cat not in grouped: + grouped[cat] = [] + grouped[cat].append({ + 'id': row['id'], + 'category': row['category'], + 'subcategory': row['subcategory'], + 'name_de': row['name_de'], + 'name_en': row['name_en'], + 'icon': row['icon'], + 'sort_order': row['sort_order'] + }) + + return grouped + + +@router.get("/flat") +def list_training_types_flat(session: dict = Depends(require_auth)): + """ + Get all training types as flat list (for simple dropdown). + """ + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(""" + SELECT id, category, subcategory, name_de, name_en, icon + FROM training_types + ORDER BY sort_order + """) + rows = cur.fetchall() + + return [dict(row) for row in rows] + + +@router.get("/categories") +def list_categories(session: dict = Depends(require_auth)): + """ + Get list of unique categories with metadata. + """ + categories = { + 'cardio': { + 'name_de': 'Cardio (Ausdauer)', + 'name_en': 'Cardio (Endurance)', + 'icon': '❤️', + 'color': '#EF4444' + }, + 'strength': { + 'name_de': 'Kraft', + 'name_en': 'Strength', + 'icon': '💪', + 'color': '#3B82F6' + }, + 'hiit': { + 'name_de': 'Schnellkraft / HIIT', + 'name_en': 'Power / HIIT', + 'icon': '🔥', + 'color': '#F59E0B' + }, + 'martial_arts': { + 'name_de': 'Kampfsport', + 'name_en': 'Martial Arts', + 'icon': '🥋', + 'color': '#8B5CF6' + }, + 'mobility': { + 'name_de': 'Mobility & Dehnung', + 'name_en': 'Mobility & Stretching', + 'icon': '🧘', + 'color': '#10B981' + }, + 'recovery': { + 'name_de': 'Erholung (aktiv)', + 'name_en': 'Recovery (active)', + 'icon': '💆', + 'color': '#6B7280' + }, + 'other': { + 'name_de': 'Sonstiges', + 'name_en': 'Other', + 'icon': '📝', + 'color': '#9CA3AF' + } + } + return categories diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 75e3a4b..defb903 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,6 @@ import { useEffect } from 'react' import { BrowserRouter, Routes, Route, NavLink, useNavigate } from 'react-router-dom' -import { LayoutDashboard, PlusSquare, TrendingUp, BarChart2, Settings } from 'lucide-react' +import { LayoutDashboard, PlusSquare, TrendingUp, BarChart2, Settings, LogOut } from 'lucide-react' import { ProfileProvider, useProfile } from './context/ProfileContext' import { AuthProvider, useAuth } from './context/AuthContext' import { setProfileId } from './utils/api' @@ -50,10 +50,17 @@ function Nav() { } function AppShell() { - const { session, loading: authLoading, needsSetup } = useAuth() + const { session, loading: authLoading, needsSetup, logout } = useAuth() const { activeProfile, loading: profileLoading } = useProfile() const nav = useNavigate() + const handleLogout = () => { + if (confirm('Wirklich abmelden?')) { + logout() + window.location.href = '/' + } + } + useEffect(()=>{ if (session?.profile_id) { setProfileId(session.profile_id) @@ -119,12 +126,32 @@ function AppShell() {
Mitai Jinkendo - - {activeProfile - ? - :
- } - +
+ + + {activeProfile + ? + :
+ } + +
diff --git a/frontend/src/components/TrainingTypeDistribution.jsx b/frontend/src/components/TrainingTypeDistribution.jsx new file mode 100644 index 0000000..39889f8 --- /dev/null +++ b/frontend/src/components/TrainingTypeDistribution.jsx @@ -0,0 +1,120 @@ +import { useState, useEffect } from 'react' +import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from 'recharts' +import { api } from '../utils/api' + +/** + * TrainingTypeDistribution - Pie chart showing activity distribution by type + * + * @param {number} days - Number of days to analyze (default: 28) + */ +export default function TrainingTypeDistribution({ days = 28 }) { + const [data, setData] = useState([]) + const [categories, setCategories] = useState({}) + const [loading, setLoading] = useState(true) + + useEffect(() => { + Promise.all([ + api.listActivity(days), + api.getTrainingCategories() + ]).then(([activities, cats]) => { + setCategories(cats) + + // Group by training_category + const grouped = {} + activities.forEach(act => { + const cat = act.training_category || 'other' + if (!grouped[cat]) grouped[cat] = 0 + grouped[cat]++ + }) + + // Convert to chart data + const chartData = Object.entries(grouped).map(([cat, count]) => ({ + name: cats[cat]?.name_de || 'Sonstiges', + value: count, + color: cats[cat]?.color || '#9CA3AF', + icon: cats[cat]?.icon || '📝' + })) + + setData(chartData.sort((a, b) => b.value - a.value)) + setLoading(false) + }).catch(err => { + console.error('Failed to load training type distribution:', err) + setLoading(false) + }) + }, [days]) + + if (loading) { + return ( +
+
+
+ ) + } + + if (data.length === 0) { + return ( +
+ Keine Aktivitäten in den letzten {days} Tagen +
+ ) + } + + const total = data.reduce((sum, d) => sum + d.value, 0) + + return ( +
+ + + + {data.map((entry, index) => ( + + ))} + + `${value} Einheiten (${Math.round(value/total*100)}%)`} + contentStyle={{ + background:'var(--surface)', + border:'1px solid var(--border)', + borderRadius:8, + fontSize:12 + }} + /> + + + + {/* Legend */} +
+ {data.map((entry, i) => ( +
+
+ + {entry.icon} {entry.name} + + + {entry.value} + +
+ ))} +
+ +
+ Gesamt: {total} Einheiten in {days} Tagen +
+
+ ) +} diff --git a/frontend/src/components/TrainingTypeSelect.jsx b/frontend/src/components/TrainingTypeSelect.jsx new file mode 100644 index 0000000..f2b92ee --- /dev/null +++ b/frontend/src/components/TrainingTypeSelect.jsx @@ -0,0 +1,114 @@ +import { useState, useEffect } from 'react' +import { api } from '../utils/api' + +/** + * TrainingTypeSelect - Two-level dropdown for training type selection + * + * @param {number|null} value - Selected training_type_id + * @param {function} onChange - Callback (training_type_id, category, subcategory) => void + * @param {boolean} required - Is selection required? + */ +export default function TrainingTypeSelect({ value, onChange, required = false }) { + const [types, setTypes] = useState({}) // Grouped by category + const [categories, setCategories] = useState({}) // Category metadata + const [selectedCategory, setSelectedCategory] = useState('') + const [loading, setLoading] = useState(true) + + useEffect(() => { + Promise.all([ + api.listTrainingTypes(), + api.getTrainingCategories() + ]).then(([typesData, catsData]) => { + setTypes(typesData) + setCategories(catsData) + + // If value is set, find and select the category + if (value) { + for (const cat in typesData) { + const found = typesData[cat].find(t => t.id === value) + if (found) { + setSelectedCategory(cat) + break + } + } + } + + setLoading(false) + }).catch(err => { + console.error('Failed to load training types:', err) + setLoading(false) + }) + }, [value]) + + const handleCategoryChange = (cat) => { + setSelectedCategory(cat) + // Auto-select first subcategory if available + if (types[cat] && types[cat].length > 0) { + const firstType = types[cat][0] + onChange(firstType.id, firstType.category, firstType.subcategory) + } else { + onChange(null, null, null) + } + } + + const handleTypeChange = (typeId) => { + const type = Object.values(types) + .flat() + .find(t => t.id === parseInt(typeId)) + + if (type) { + onChange(type.id, type.category, type.subcategory) + } + } + + if (loading) { + return
Lade Trainingstypen...
+ } + + const availableCategories = Object.keys(categories) + const availableTypes = selectedCategory ? (types[selectedCategory] || []) : [] + + return ( +
+ {/* Category dropdown */} +
+ + +
+ + {/* Subcategory dropdown (conditional) */} + {selectedCategory && availableTypes.length > 0 && ( +
+ + +
+ )} +
+ ) +} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 5cdba29..22004ef 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -189,4 +189,9 @@ export const api = { createAccessGrant: (d) => req('/access-grants',json(d)), updateAccessGrant: (id,d) => req(`/access-grants/${id}`,jput(d)), revokeAccessGrant: (id) => req(`/access-grants/${id}`,{method:'DELETE'}), + + // v9d: Training Types + listTrainingTypes: () => req('/training-types'), // Grouped by category + listTrainingTypesFlat:() => req('/training-types/flat'), // Flat list + getTrainingCategories:() => req('/training-types/categories'), // Category metadata } From df01ee3de3e09645da81d0ab81ebba7c23cff886 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 13:46:55 +0100 Subject: [PATCH 04/14] docs: mark v9d Phase 1 as deployed and tested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Training types system: ✅ deployed to dev - Logout button: ✅ tested and working - Migration 004: ✅ applied successfully (23 types) - API endpoints: ✅ functional Next: Phase 1b (UI integration) Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ee3e196..cc01896 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,11 +97,21 @@ frontend/src/ - ✅ **TrialBanner mailto:** "Abo wählen" → mailto:mitai@jinkendo.de (Vorbereitung für zentrales Abo-System) - 📚 Dokumentation: `.claude/docs/technical/CENTRAL_SUBSCRIPTION_SYSTEM.md` -### v9d – In Arbeit 🔄 -- 🔲 Schlaf-Modul (Erfassung, Import, Auswertung, Korrelationen) -- 🔲 Trainingstypen (Cardio, Kraft, HIIT, Mobility, Erholung) -- 🔲 Ruhetage erfassen -- 🔲 Ruhepuls + HF-Zonen + VO2Max-Schätzung +### v9d – Phase 1 ✅ (Deployed 21.03.2026) +- ✅ **Trainingstypen Basis:** DB-Schema, 23 Typen, API-Endpoints +- ✅ **Logout-Button:** Im Header neben Avatar, mit Bestätigung +- ✅ **Components:** TrainingTypeSelect, TrainingTypeDistribution (noch nicht eingebunden) + +### v9d – Phase 1b 🔲 (Integration) +- 🔲 ActivityPage: TrainingTypeSelect einbinden +- 🔲 Dashboard: TrainingTypeDistribution Chart +- 🔲 History: Typ-Badge bei Aktivitäten + +### v9d – Phase 2+ 🔲 (Später) +- 🔲 Ruhetage erfassen (rest_days Tabelle) +- 🔲 Ruhepuls erfassen (vitals_log Tabelle) +- 🔲 HF-Zonen + Erholungsstatus +- 🔲 Schlaf-Modul 📚 Details: `.claude/docs/technical/MEMBERSHIP_SYSTEM.md` · `.claude/docs/architecture/FEATURE_ENFORCEMENT.md` From 08cead49fede8f13bdbbdec56c1ae750b7c36154 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 14:56:11 +0100 Subject: [PATCH 05/14] feat(v9d): integrate training type UI components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1b - UI Integration: =========================== ActivityPage: - Replace old activity type dropdown with TrainingTypeSelect - Add training_type_id, training_category, training_subcategory to form - Two-level selection (category → subcategory) Dashboard: - Add TrainingTypeDistribution card (pie chart) - Shows last 28 days activity distribution by type - Conditional rendering (only if activities exist) Still TODO: - History: Add type badge display (next commit) Co-Authored-By: Claude Opus 4.6 --- frontend/src/pages/ActivityPage.jsx | 24 ++++++++++++++++++------ frontend/src/pages/Dashboard.jsx | 15 +++++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx index 3257180..5d721aa 100644 --- a/frontend/src/pages/ActivityPage.jsx +++ b/frontend/src/pages/ActivityPage.jsx @@ -3,6 +3,7 @@ import { Upload, Pencil, Trash2, Check, X, CheckCircle } from 'lucide-react' import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts' import { api } from '../utils/api' import UsageBadge from '../components/UsageBadge' +import TrainingTypeSelect from '../components/TrainingTypeSelect' import dayjs from 'dayjs' import 'dayjs/locale/de' dayjs.locale('de') @@ -18,7 +19,10 @@ function empty() { date: dayjs().format('YYYY-MM-DD'), activity_type: 'Traditionelles Krafttraining', duration_min: '', kcal_active: '', - hr_avg: '', hr_max: '', rpe: '', notes: '' + hr_avg: '', hr_max: '', rpe: '', notes: '', + training_type_id: null, + training_category: null, + training_subcategory: null } } @@ -89,11 +93,19 @@ function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', sav set('date',e.target.value)}/>
-
- - +
+ { + setForm(f => ({ + ...f, + training_type_id: typeId, + training_category: category, + training_subcategory: subcategory + })) + }} + required={false} + />
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 586805c..1649544 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -10,6 +10,7 @@ import { useProfile } from '../context/ProfileContext' import { getBfCategory } from '../utils/calc' import TrialBanner from '../components/TrialBanner' import EmailVerificationBanner from '../components/EmailVerificationBanner' +import TrainingTypeDistribution from '../components/TrainingTypeDistribution' import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret' import Markdown from '../utils/Markdown' import dayjs from 'dayjs' @@ -470,6 +471,20 @@ export default function Dashboard() { )}
+ {/* Training Type Distribution */} + {activities.length > 0 && ( +
+
+
🏋️ Trainingstyp-Verteilung
+ +
+ +
+ )} + {/* Latest AI insight */}
From 96b0acacd2aa49388e22f0f874bca9d8ebe50038 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 15:08:18 +0100 Subject: [PATCH 06/14] feat: automatic training type mapping for Apple Health import and bulk categorization - Add get_training_type_for_apple_health() mapping function (23 workout types) - CSV import now automatically assigns training_type_id/category/subcategory - New endpoint: GET /activity/uncategorized (grouped by activity_type) - New endpoint: POST /activity/bulk-categorize (bulk update training types) - New component: BulkCategorize with two-level dropdown selection - ActivityPage: new "Kategorisieren" tab for existing activities - Update CLAUDE.md: v9d Phase 1b progress Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 16 +- backend/routers/activity.py | 116 ++++++++++++- frontend/src/components/BulkCategorize.jsx | 191 +++++++++++++++++++++ frontend/src/pages/ActivityPage.jsx | 9 + frontend/src/utils/api.js | 2 + 5 files changed, 325 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/BulkCategorize.jsx diff --git a/CLAUDE.md b/CLAUDE.md index cc01896..2ab20cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,17 +95,23 @@ frontend/src/ ### Auf develop (nicht deployed) 📦 - ✅ **TrialBanner mailto:** "Abo wählen" → mailto:mitai@jinkendo.de (Vorbereitung für zentrales Abo-System) +- ✅ **Apple Health Mapping:** Automatische Trainingstyp-Zuordnung beim CSV-Import (23 Workout-Typen) +- ✅ **Bulk-Kategorisierung:** Nachträgliche Typ-Zuweisung für bestehende Aktivitäten +- ✅ **ActivityPage Integration:** TrainingTypeSelect + "Kategorisieren"-Tab +- ✅ **Dashboard Integration:** TrainingTypeDistribution Chart (28 Tage) - 📚 Dokumentation: `.claude/docs/technical/CENTRAL_SUBSCRIPTION_SYSTEM.md` ### v9d – Phase 1 ✅ (Deployed 21.03.2026) - ✅ **Trainingstypen Basis:** DB-Schema, 23 Typen, API-Endpoints - ✅ **Logout-Button:** Im Header neben Avatar, mit Bestätigung -- ✅ **Components:** TrainingTypeSelect, TrainingTypeDistribution (noch nicht eingebunden) +- ✅ **Components:** TrainingTypeSelect, TrainingTypeDistribution -### v9d – Phase 1b 🔲 (Integration) -- 🔲 ActivityPage: TrainingTypeSelect einbinden -- 🔲 Dashboard: TrainingTypeDistribution Chart -- 🔲 History: Typ-Badge bei Aktivitäten +### v9d – Phase 1b ⏳ (In Progress) +- ✅ ActivityPage: TrainingTypeSelect eingebunden +- ✅ Dashboard: TrainingTypeDistribution Chart eingebunden +- ✅ Apple Health Import: Automatisches Mapping +- ✅ Bulk-Kategorisierung: UI + Endpoints +- 🔲 History: Typ-Badge bei Aktivitäten (ausstehend) ### v9d – Phase 2+ 🔲 (Später) - 🔲 Ruhetage erfassen (rest_days Tabelle) diff --git a/backend/routers/activity.py b/backend/routers/activity.py index 0d12000..5abcf0d 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -113,9 +113,113 @@ def activity_stats(x_profile_id: Optional[str]=Header(default=None), session: di return {"count":len(rows),"total_kcal":round(total_kcal),"total_min":round(total_min),"by_type":by_type} +def get_training_type_for_apple_health(workout_type: str): + """ + Map Apple Health workout type to training_type_id + category + subcategory. + Returns: (training_type_id, category, subcategory) or (None, None, None) + """ + with get_db() as conn: + cur = get_cursor(conn) + + # Mapping: Apple Health Workout Type → training_type subcategory + mapping = { + 'running': 'running', + 'walking': 'walk', + 'hiking': 'walk', + 'cycling': 'cycling', + 'swimming': 'swimming', + 'traditional strength training': 'hypertrophy', + 'functional strength training': 'functional', + 'high intensity interval training': 'hiit', + 'yoga': 'yoga', + 'martial arts': 'technique', + 'boxing': 'sparring', + 'rowing': 'rowing', + 'dance': 'other', + 'core training': 'functional', + 'flexibility': 'static', + 'cooldown': 'regeneration', + } + + subcategory = mapping.get(workout_type.lower()) + if not subcategory: + return (None, None, None) + + # Find training_type_id by subcategory + cur.execute(""" + SELECT id, category, subcategory + FROM training_types + WHERE LOWER(subcategory) = %s + LIMIT 1 + """, (subcategory,)) + row = cur.fetchone() + + if row: + return (row['id'], row['category'], row['subcategory']) + + return (None, None, None) + + +@router.get("/uncategorized") +def list_uncategorized_activities(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): + """Get activities without assigned training type, grouped by activity_type.""" + pid = get_pid(x_profile_id) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(""" + SELECT activity_type, COUNT(*) as count, + MIN(date) as first_date, MAX(date) as last_date + FROM activity_log + WHERE profile_id=%s AND training_type_id IS NULL + GROUP BY activity_type + ORDER BY count DESC + """, (pid,)) + return [r2d(r) for r in cur.fetchall()] + + +@router.post("/bulk-categorize") +def bulk_categorize_activities( + data: dict, + x_profile_id: Optional[str]=Header(default=None), + session: dict=Depends(require_auth) +): + """ + Bulk update training type for activities. + Body: { + "activity_type": "Running", + "training_type_id": 1, + "training_category": "cardio", + "training_subcategory": "running" + } + """ + pid = get_pid(x_profile_id) + activity_type = data.get('activity_type') + training_type_id = data.get('training_type_id') + training_category = data.get('training_category') + training_subcategory = data.get('training_subcategory') + + if not activity_type or not training_type_id: + raise HTTPException(400, "activity_type and training_type_id required") + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(""" + UPDATE activity_log + SET training_type_id = %s, + training_category = %s, + training_subcategory = %s + WHERE profile_id = %s + AND activity_type = %s + AND training_type_id IS NULL + """, (training_type_id, training_category, training_subcategory, pid, activity_type)) + updated_count = cur.rowcount + + return {"updated": updated_count, "activity_type": activity_type} + + @router.post("/import-csv") async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): - """Import Apple Health workout CSV.""" + """Import Apple Health workout CSV with automatic training type mapping.""" pid = get_pid(x_profile_id) raw = await file.read() try: text = raw.decode('utf-8') @@ -145,16 +249,20 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional def tf(v): try: return round(float(v),1) if v else None except: return None + # Map Apple Health workout type to training_type_id + training_type_id, training_category, training_subcategory = get_training_type_for_apple_health(wtype) + try: cur.execute("""INSERT INTO activity_log (id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting, - hr_avg,hr_max,distance_km,source,created) - VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'apple_health',CURRENT_TIMESTAMP)""", + hr_avg,hr_max,distance_km,source,training_type_id,training_category,training_subcategory,created) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'apple_health',%s,%s,%s,CURRENT_TIMESTAMP)""", (str(uuid.uuid4()),pid,date,start,row.get('End',''),wtype,duration_min, kj(row.get('Aktive Energie (kJ)','')),kj(row.get('Ruheeinträge (kJ)','')), tf(row.get('Durchschn. Herzfrequenz (count/min)','')), tf(row.get('Max. Herzfrequenz (count/min)','')), - tf(row.get('Distanz (km)','')))) + tf(row.get('Distanz (km)','')), + training_type_id,training_category,training_subcategory)) inserted+=1 except: skipped+=1 return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"} diff --git a/frontend/src/components/BulkCategorize.jsx b/frontend/src/components/BulkCategorize.jsx new file mode 100644 index 0000000..f4fa36d --- /dev/null +++ b/frontend/src/components/BulkCategorize.jsx @@ -0,0 +1,191 @@ +import { useState, useEffect } from 'react' +import { api } from '../utils/api' +import TrainingTypeSelect from './TrainingTypeSelect' + +/** + * BulkCategorize - UI for categorizing existing activities without training type + * + * Shows uncategorized activities grouped by activity_type, + * allows bulk assignment of training type to all activities of same type. + */ +export default function BulkCategorize({ onComplete }) { + const [uncategorized, setUncategorized] = useState([]) + const [loading, setLoading] = useState(true) + const [assignments, setAssignments] = useState({}) + const [saving, setSaving] = useState(null) + + useEffect(() => { + loadUncategorized() + }, []) + + const loadUncategorized = () => { + setLoading(true) + api.listUncategorizedActivities() + .then(data => { + setUncategorized(data) + setLoading(false) + }) + .catch(err => { + console.error('Failed to load uncategorized activities:', err) + setLoading(false) + }) + } + + const handleAssignment = (activityType, typeId, category, subcategory) => { + setAssignments(prev => ({ + ...prev, + [activityType]: { + training_type_id: typeId, + training_category: category, + training_subcategory: subcategory + } + })) + } + + const handleSave = async (activityType) => { + const assignment = assignments[activityType] + if (!assignment || !assignment.training_type_id) { + alert('Bitte wähle einen Trainingstyp aus') + return + } + + setSaving(activityType) + try { + const result = await api.bulkCategorizeActivities({ + activity_type: activityType, + ...assignment + }) + + // Remove from list + setUncategorized(prev => prev.filter(u => u.activity_type !== activityType)) + setAssignments(prev => { + const newAssignments = { ...prev } + delete newAssignments[activityType] + return newAssignments + }) + + // Show success message + console.log(`✓ ${result.updated} activities categorized`) + + } catch (err) { + console.error('Failed to categorize:', err) + alert('Kategorisierung fehlgeschlagen: ' + err.message) + } finally { + setSaving(null) + } + } + + if (loading) { + return ( +
+
+
+ ) + } + + if (uncategorized.length === 0) { + return ( +
+
+
Alle Aktivitäten sind kategorisiert
+ {onComplete && ( + + )} +
+ ) + } + + return ( +
+
+ + {uncategorized.reduce((sum, u) => sum + u.count, 0)} Aktivitäten + ohne Trainingstyp gefunden. Weise jedem Aktivitätstyp einen Trainingstyp zu. +
+ +
+ {uncategorized.map(item => ( +
+
+
+
+ {item.activity_type} +
+
+ {item.count} Einheiten + {item.first_date && item.last_date && ( + <> · {item.first_date} bis {item.last_date} + )} +
+
+
+ + + handleAssignment(item.activity_type, typeId, category, subcategory) + } + required={false} + /> + + +
+ ))} +
+ + {onComplete && ( + + )} +
+ ) +} diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx index 5d721aa..05730cc 100644 --- a/frontend/src/pages/ActivityPage.jsx +++ b/frontend/src/pages/ActivityPage.jsx @@ -4,6 +4,7 @@ import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGri import { api } from '../utils/api' import UsageBadge from '../components/UsageBadge' import TrainingTypeSelect from '../components/TrainingTypeSelect' +import BulkCategorize from '../components/BulkCategorize' import dayjs from 'dayjs' import 'dayjs/locale/de' dayjs.locale('de') @@ -258,6 +259,7 @@ export default function ActivityPage() { +
@@ -279,6 +281,13 @@ export default function ActivityPage() { {tab==='import' && } + {tab==='categorize' && ( +
+
🏷️ Aktivitäten kategorisieren
+ { load(); setTab('list'); }} /> +
+ )} + {tab==='add' && (
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 22004ef..c4a24b1 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -56,6 +56,8 @@ export const api = { updateActivity: (id,d) => req(`/activity/${id}`,jput(d)), deleteActivity: (id) => req(`/activity/${id}`,{method:'DELETE'}), activityStats: () => req('/activity/stats'), + listUncategorizedActivities: () => req('/activity/uncategorized'), + bulkCategorizeActivities: (d) => req('/activity/bulk-categorize', json(d)), importActivityCsv: async(file)=>{ const fd=new FormData();fd.append('file',file) const r=await fetch(`${BASE}/activity/import-csv`,{method:'POST',body:fd,headers:hdrs()}) From d164ab932dcf28cf2d80e87377de3c8c0e74c0d5 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 15:16:07 +0100 Subject: [PATCH 07/14] feat: add extended training types (cardio walk/dance, mind & meditation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration 005: Add cardio subcategories (Gehen, Tanzen) - Migration 005: Add new category "Geist & Meditation" with 4 subcategories (Meditation, Atemarbeit, Achtsamkeit, Visualisierung) - Update categories endpoint with mind category metadata - Update Apple Health mapping: dance → dance, add meditation/mindfulness - 6 new training types total Co-Authored-By: Claude Opus 4.6 --- .../005_training_types_extended.sql | 24 +++++++++++++++++++ backend/routers/activity.py | 4 +++- backend/routers/training_types.py | 6 +++++ 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/005_training_types_extended.sql diff --git a/backend/migrations/005_training_types_extended.sql b/backend/migrations/005_training_types_extended.sql new file mode 100644 index 0000000..35700e4 --- /dev/null +++ b/backend/migrations/005_training_types_extended.sql @@ -0,0 +1,24 @@ +-- Migration 005: Extended Training Types +-- Add: Cardio (Gehen, Tanzen), Mind & Meditation category +-- Created: 2026-03-21 + +-- ======================================== +-- Add new cardio subcategories +-- ======================================== +INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES + ('cardio', 'walk', 'Gehen', 'Walking', '🚶', 105), + ('cardio', 'dance', 'Tanzen', 'Dance', '💃', 106); + +-- ======================================== +-- Add new category: Geist & Meditation +-- ======================================== +INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES + ('mind', 'meditation', 'Meditation', 'Meditation', '🧘‍♂️', 700), + ('mind', 'breathwork', 'Atemarbeit', 'Breathwork', '🫁', 701), + ('mind', 'mindfulness', 'Achtsamkeit', 'Mindfulness', '☮️', 702), + ('mind', 'visualization', 'Visualisierung', 'Visualization', '🎨', 703); + +-- ======================================== +-- Add comment +-- ======================================== +COMMENT ON TABLE training_types IS 'v9d Phase 1b: Extended with cardio walk/dance and mind category'; diff --git a/backend/routers/activity.py b/backend/routers/activity.py index 5abcf0d..956dc0c 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -135,10 +135,12 @@ def get_training_type_for_apple_health(workout_type: str): 'martial arts': 'technique', 'boxing': 'sparring', 'rowing': 'rowing', - 'dance': 'other', + 'dance': 'dance', 'core training': 'functional', 'flexibility': 'static', 'cooldown': 'regeneration', + 'meditation': 'meditation', + 'mindfulness': 'mindfulness', } subcategory = mapping.get(workout_type.lower()) diff --git a/backend/routers/training_types.py b/backend/routers/training_types.py index ab24785..e8c6fe7 100644 --- a/backend/routers/training_types.py +++ b/backend/routers/training_types.py @@ -113,6 +113,12 @@ def list_categories(session: dict = Depends(require_auth)): 'icon': '💆', 'color': '#6B7280' }, + 'mind': { + 'name_de': 'Geist & Meditation', + 'name_en': 'Mind & Meditation', + 'icon': '🧘‍♂️', + 'color': '#A78BFA' + }, 'other': { 'name_de': 'Sonstiges', 'name_en': 'Other', From eecc00e82464f4db220411c3dc364b9eceb811ea Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 15:32:32 +0100 Subject: [PATCH 08/14] feat: admin CRUD for training types + distribution chart in ActivityPage Backend (v9d Phase 1b): - Migration 006: Add abilities JSONB column + descriptions - admin_training_types.py: Full CRUD endpoints for training types - List, Get, Create, Update, Delete - Abilities taxonomy endpoint (5 dimensions: koordinativ, konditionell, kognitiv, psychisch, taktisch) - Validation: Cannot delete types in use - Register admin_training_types router in main.py Frontend: - AdminTrainingTypesPage: Full CRUD UI - Create/edit form with all fields (category, subcategory, names, icon, descriptions, sort_order) - List grouped by category with color coding - Delete with usage check - Note about abilities mapping coming in v9f - Add TrainingTypeDistribution to ActivityPage stats tab - Add admin link in AdminPanel (v9d section) - Update api.js with admin training types methods Notes: - Abilities mapping UI deferred to v9f (flexible prompt system) - Placeholders (abilities column) in place for future AI analysis Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 5 +- .../006_training_types_abilities.sql | 29 ++ backend/routers/admin_training_types.py | 281 +++++++++++++ frontend/src/App.jsx | 2 + frontend/src/pages/ActivityPage.jsx | 6 + frontend/src/pages/AdminPanel.jsx | 17 + frontend/src/pages/AdminTrainingTypesPage.jsx | 384 ++++++++++++++++++ frontend/src/utils/api.js | 8 + 8 files changed, 730 insertions(+), 2 deletions(-) create mode 100644 backend/migrations/006_training_types_abilities.sql create mode 100644 backend/routers/admin_training_types.py create mode 100644 frontend/src/pages/AdminTrainingTypesPage.jsx diff --git a/backend/main.py b/backend/main.py index 192033b..70bbcd2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -19,7 +19,7 @@ from routers import auth, profiles, weight, circumference, caliper from routers import activity, nutrition, photos, insights, prompts from routers import admin, stats, exportdata, importdata from routers import subscription, coupons, features, tiers_mgmt, tier_limits -from routers import user_restrictions, access_grants, training_types +from routers import user_restrictions, access_grants, training_types, admin_training_types # ── App Configuration ───────────────────────────────────────────────────────── DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) @@ -86,7 +86,8 @@ app.include_router(user_restrictions.router) # /api/user-restrictions (admin) app.include_router(access_grants.router) # /api/access-grants (admin) # v9d Training Types -app.include_router(training_types.router) # /api/training-types/* +app.include_router(training_types.router) # /api/training-types/* +app.include_router(admin_training_types.router) # /api/admin/training-types/* # ── Health Check ────────────────────────────────────────────────────────────── @app.get("/") diff --git a/backend/migrations/006_training_types_abilities.sql b/backend/migrations/006_training_types_abilities.sql new file mode 100644 index 0000000..4327e79 --- /dev/null +++ b/backend/migrations/006_training_types_abilities.sql @@ -0,0 +1,29 @@ +-- Migration 006: Training Types - Abilities Mapping +-- Add abilities JSONB column for future AI analysis +-- Maps to: koordinativ, konditionell, kognitiv, psychisch, taktisch +-- Created: 2026-03-21 + +-- ======================================== +-- Add abilities column +-- ======================================== +ALTER TABLE training_types + ADD COLUMN IF NOT EXISTS abilities JSONB DEFAULT '{}'; + +-- ======================================== +-- Add description columns for better documentation +-- ======================================== +ALTER TABLE training_types + ADD COLUMN IF NOT EXISTS description_de TEXT, + ADD COLUMN IF NOT EXISTS description_en TEXT; + +-- ======================================== +-- Add index for abilities queries +-- ======================================== +CREATE INDEX IF NOT EXISTS idx_training_types_abilities ON training_types USING GIN (abilities); + +-- ======================================== +-- Comment +-- ======================================== +COMMENT ON COLUMN training_types.abilities IS 'JSONB: Maps to athletic abilities for AI analysis (koordinativ, konditionell, kognitiv, psychisch, taktisch)'; +COMMENT ON COLUMN training_types.description_de IS 'German description for admin UI and AI context'; +COMMENT ON COLUMN training_types.description_en IS 'English description for admin UI and AI context'; diff --git a/backend/routers/admin_training_types.py b/backend/routers/admin_training_types.py new file mode 100644 index 0000000..f26db55 --- /dev/null +++ b/backend/routers/admin_training_types.py @@ -0,0 +1,281 @@ +""" +Admin Training Types Management - v9d Phase 1b + +CRUD operations for training types with abilities mapping preparation. +""" +import logging +from typing import Optional +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel + +from db import get_db, get_cursor, r2d +from auth import require_auth, require_admin + +router = APIRouter(prefix="/api/admin/training-types", tags=["admin", "training-types"]) +logger = logging.getLogger(__name__) + + +class TrainingTypeCreate(BaseModel): + category: str + subcategory: Optional[str] = None + name_de: str + name_en: str + icon: Optional[str] = None + description_de: Optional[str] = None + description_en: Optional[str] = None + sort_order: int = 0 + abilities: Optional[dict] = None + + +class TrainingTypeUpdate(BaseModel): + category: Optional[str] = None + subcategory: Optional[str] = None + name_de: Optional[str] = None + name_en: Optional[str] = None + icon: Optional[str] = None + description_de: Optional[str] = None + description_en: Optional[str] = None + sort_order: Optional[int] = None + abilities: Optional[dict] = None + + +@router.get("") +def list_training_types_admin(session: dict = Depends(require_admin)): + """ + Get all training types for admin management. + Returns full details including abilities. + """ + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(""" + SELECT id, category, subcategory, name_de, name_en, icon, + description_de, description_en, sort_order, abilities, + created_at + FROM training_types + ORDER BY sort_order, category, subcategory + """) + rows = cur.fetchall() + + return [r2d(r) for r in rows] + + +@router.get("/{type_id}") +def get_training_type(type_id: int, session: dict = Depends(require_admin)): + """Get single training type by ID.""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(""" + SELECT id, category, subcategory, name_de, name_en, icon, + description_de, description_en, sort_order, abilities, + created_at + FROM training_types + WHERE id = %s + """, (type_id,)) + row = cur.fetchone() + + if not row: + raise HTTPException(404, "Training type not found") + + return r2d(row) + + +@router.post("") +def create_training_type(data: TrainingTypeCreate, session: dict = Depends(require_admin)): + """Create new training type.""" + with get_db() as conn: + cur = get_cursor(conn) + + # Convert abilities dict to JSONB + abilities_json = data.abilities if data.abilities else {} + + cur.execute(""" + INSERT INTO training_types + (category, subcategory, name_de, name_en, icon, + description_de, description_en, sort_order, abilities) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + data.category, + data.subcategory, + data.name_de, + data.name_en, + data.icon, + data.description_de, + data.description_en, + data.sort_order, + abilities_json + )) + + new_id = cur.fetchone()['id'] + + logger.info(f"[ADMIN] Training type created: {new_id} - {data.name_de} ({data.category}/{data.subcategory})") + + return {"id": new_id, "message": "Training type created"} + + +@router.put("/{type_id}") +def update_training_type( + type_id: int, + data: TrainingTypeUpdate, + session: dict = Depends(require_admin) +): + """Update existing training type.""" + with get_db() as conn: + cur = get_cursor(conn) + + # Build update query dynamically + updates = [] + values = [] + + if data.category is not None: + updates.append("category = %s") + values.append(data.category) + if data.subcategory is not None: + updates.append("subcategory = %s") + values.append(data.subcategory) + if data.name_de is not None: + updates.append("name_de = %s") + values.append(data.name_de) + if data.name_en is not None: + updates.append("name_en = %s") + values.append(data.name_en) + if data.icon is not None: + updates.append("icon = %s") + values.append(data.icon) + if data.description_de is not None: + updates.append("description_de = %s") + values.append(data.description_de) + if data.description_en is not None: + updates.append("description_en = %s") + values.append(data.description_en) + if data.sort_order is not None: + updates.append("sort_order = %s") + values.append(data.sort_order) + if data.abilities is not None: + updates.append("abilities = %s") + values.append(data.abilities) + + if not updates: + raise HTTPException(400, "No fields to update") + + values.append(type_id) + + cur.execute(f""" + UPDATE training_types + SET {', '.join(updates)} + WHERE id = %s + """, values) + + if cur.rowcount == 0: + raise HTTPException(404, "Training type not found") + + logger.info(f"[ADMIN] Training type updated: {type_id}") + + return {"id": type_id, "message": "Training type updated"} + + +@router.delete("/{type_id}") +def delete_training_type(type_id: int, session: dict = Depends(require_admin)): + """ + Delete training type. + + WARNING: This will fail if any activities reference this type. + Consider adding a soft-delete or archive mechanism if needed. + """ + with get_db() as conn: + cur = get_cursor(conn) + + # Check if any activities use this type + cur.execute(""" + SELECT COUNT(*) as count + FROM activity_log + WHERE training_type_id = %s + """, (type_id,)) + + count = cur.fetchone()['count'] + if count > 0: + raise HTTPException( + 400, + f"Cannot delete: {count} activities are using this training type. " + "Please reassign or delete those activities first." + ) + + cur.execute("DELETE FROM training_types WHERE id = %s", (type_id,)) + + if cur.rowcount == 0: + raise HTTPException(404, "Training type not found") + + logger.info(f"[ADMIN] Training type deleted: {type_id}") + + return {"message": "Training type deleted"} + + +@router.get("/taxonomy/abilities") +def get_abilities_taxonomy(session: dict = Depends(require_auth)): + """ + Get abilities taxonomy for UI and AI analysis. + + This defines the 5 dimensions of athletic development. + """ + taxonomy = { + "koordinativ": { + "name_de": "Koordinative Fähigkeiten", + "name_en": "Coordination Abilities", + "icon": "🎯", + "abilities": [ + {"key": "orientierung", "name_de": "Orientierung", "name_en": "Orientation"}, + {"key": "differenzierung", "name_de": "Differenzierung", "name_en": "Differentiation"}, + {"key": "kopplung", "name_de": "Kopplung", "name_en": "Coupling"}, + {"key": "gleichgewicht", "name_de": "Gleichgewicht", "name_en": "Balance"}, + {"key": "rhythmus", "name_de": "Rhythmisierung", "name_en": "Rhythm"}, + {"key": "reaktion", "name_de": "Reaktion", "name_en": "Reaction"}, + {"key": "umstellung", "name_de": "Umstellung", "name_en": "Adaptation"} + ] + }, + "konditionell": { + "name_de": "Konditionelle Fähigkeiten", + "name_en": "Conditional Abilities", + "icon": "💪", + "abilities": [ + {"key": "kraft", "name_de": "Kraft", "name_en": "Strength"}, + {"key": "ausdauer", "name_de": "Ausdauer", "name_en": "Endurance"}, + {"key": "schnelligkeit", "name_de": "Schnelligkeit", "name_en": "Speed"}, + {"key": "flexibilitaet", "name_de": "Flexibilität", "name_en": "Flexibility"} + ] + }, + "kognitiv": { + "name_de": "Kognitive Fähigkeiten", + "name_en": "Cognitive Abilities", + "icon": "🧠", + "abilities": [ + {"key": "konzentration", "name_de": "Konzentration", "name_en": "Concentration"}, + {"key": "aufmerksamkeit", "name_de": "Aufmerksamkeit", "name_en": "Attention"}, + {"key": "wahrnehmung", "name_de": "Wahrnehmung", "name_en": "Perception"}, + {"key": "entscheidung", "name_de": "Entscheidungsfindung", "name_en": "Decision Making"} + ] + }, + "psychisch": { + "name_de": "Psychische Fähigkeiten", + "name_en": "Psychological Abilities", + "icon": "🎭", + "abilities": [ + {"key": "motivation", "name_de": "Motivation", "name_en": "Motivation"}, + {"key": "willenskraft", "name_de": "Willenskraft", "name_en": "Willpower"}, + {"key": "stressresistenz", "name_de": "Stressresistenz", "name_en": "Stress Resistance"}, + {"key": "selbstvertrauen", "name_de": "Selbstvertrauen", "name_en": "Self-Confidence"} + ] + }, + "taktisch": { + "name_de": "Taktische Fähigkeiten", + "name_en": "Tactical Abilities", + "icon": "♟️", + "abilities": [ + {"key": "timing", "name_de": "Timing", "name_en": "Timing"}, + {"key": "strategie", "name_de": "Strategie", "name_en": "Strategy"}, + {"key": "antizipation", "name_de": "Antizipation", "name_en": "Anticipation"}, + {"key": "situationsanalyse", "name_de": "Situationsanalyse", "name_en": "Situation Analysis"} + ] + } + } + + return taxonomy diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index defb903..8afed6f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -27,6 +27,7 @@ import AdminFeaturesPage from './pages/AdminFeaturesPage' import AdminTiersPage from './pages/AdminTiersPage' import AdminCouponsPage from './pages/AdminCouponsPage' import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage' +import AdminTrainingTypesPage from './pages/AdminTrainingTypesPage' import SubscriptionPage from './pages/SubscriptionPage' import './app.css' @@ -172,6 +173,7 @@ function AppShell() { }/> }/> }/> + }/> }/>
diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx index 05730cc..f68b757 100644 --- a/frontend/src/pages/ActivityPage.jsx +++ b/frontend/src/pages/ActivityPage.jsx @@ -5,6 +5,7 @@ import { api } from '../utils/api' import UsageBadge from '../components/UsageBadge' import TrainingTypeSelect from '../components/TrainingTypeSelect' import BulkCategorize from '../components/BulkCategorize' +import TrainingTypeDistribution from '../components/TrainingTypeDistribution' import dayjs from 'dayjs' import 'dayjs/locale/de' dayjs.locale('de') @@ -302,6 +303,11 @@ export default function ActivityPage() { {tab==='stats' && stats && (
+
+
🏋️ Trainingstyp-Verteilung (30 Tage)
+ +
+ {chartData.length>=2 && (
Aktive Kalorien pro Tag
diff --git a/frontend/src/pages/AdminPanel.jsx b/frontend/src/pages/AdminPanel.jsx index 8abf7ca..1dd49bc 100644 --- a/frontend/src/pages/AdminPanel.jsx +++ b/frontend/src/pages/AdminPanel.jsx @@ -424,6 +424,23 @@ export default function AdminPanel() {
+ + {/* v9d Training Types Management */} +
+
+ Trainingstypen (v9d) +
+
+ Verwalte Trainingstypen, Kategorien und Fähigkeiten-Mapping. +
+
+ + + +
+
) } diff --git a/frontend/src/pages/AdminTrainingTypesPage.jsx b/frontend/src/pages/AdminTrainingTypesPage.jsx new file mode 100644 index 0000000..442a1c4 --- /dev/null +++ b/frontend/src/pages/AdminTrainingTypesPage.jsx @@ -0,0 +1,384 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { Pencil, Trash2, Plus, Save, X, ArrowLeft } from 'lucide-react' +import { api } from '../utils/api' + +/** + * AdminTrainingTypesPage - CRUD for training types + * v9d Phase 1b - Basic CRUD without abilities mapping + */ +export default function AdminTrainingTypesPage() { + const nav = useNavigate() + const [types, setTypes] = useState([]) + const [categories, setCategories] = useState({}) + const [loading, setLoading] = useState(true) + const [editingId, setEditingId] = useState(null) + const [formData, setFormData] = useState(null) + const [error, setError] = useState(null) + const [saving, setSaving] = useState(false) + + useEffect(() => { + load() + }, []) + + const load = () => { + setLoading(true) + Promise.all([ + api.adminListTrainingTypes(), + api.getTrainingCategories() + ]).then(([typesData, catsData]) => { + setTypes(typesData) + setCategories(catsData) + setLoading(false) + }).catch(err => { + console.error('Failed to load training types:', err) + setError(err.message) + setLoading(false) + }) + } + + const startCreate = () => { + setFormData({ + category: 'cardio', + subcategory: '', + name_de: '', + name_en: '', + icon: '', + description_de: '', + description_en: '', + sort_order: 0 + }) + setEditingId('new') + } + + const startEdit = (type) => { + setFormData({ + category: type.category, + subcategory: type.subcategory || '', + name_de: type.name_de, + name_en: type.name_en, + icon: type.icon || '', + description_de: type.description_de || '', + description_en: type.description_en || '', + sort_order: type.sort_order + }) + setEditingId(type.id) + } + + const cancelEdit = () => { + setEditingId(null) + setFormData(null) + setError(null) + } + + const handleSave = async () => { + if (!formData.name_de || !formData.name_en) { + setError('Name (DE) und Name (EN) sind Pflichtfelder') + return + } + + setSaving(true) + setError(null) + + try { + if (editingId === 'new') { + await api.adminCreateTrainingType(formData) + } else { + await api.adminUpdateTrainingType(editingId, formData) + } + await load() + cancelEdit() + } catch (err) { + console.error('Save failed:', err) + setError(err.message) + } finally { + setSaving(false) + } + } + + const handleDelete = async (id, name) => { + if (!confirm(`Trainingstyp "${name}" wirklich löschen?\n\nHinweis: Löschen ist nur möglich wenn keine Aktivitäten diesen Typ verwenden.`)) { + return + } + + try { + await api.adminDeleteTrainingType(id) + await load() + } catch (err) { + alert('Löschen fehlgeschlagen: ' + err.message) + } + } + + // Group by category + const grouped = {} + types.forEach(type => { + if (!grouped[type.category]) { + grouped[type.category] = [] + } + grouped[type.category].push(type) + }) + + if (loading) { + return ( +
+
+
+ ) + } + + return ( +
+
+ +

Trainingstypen verwalten

+
+ + {error && ( +
+ {error} +
+ )} + + {/* Create new button */} + {!editingId && ( + + )} + + {/* Edit form */} + {editingId && formData && ( +
+
+ {editingId === 'new' ? '➕ Neuer Trainingstyp' : '✏️ Trainingstyp bearbeiten'} +
+ +
+
+ + +
+ +
+ + setFormData({ ...formData, subcategory: e.target.value })} + placeholder="z.B. running, hypertrophy, meditation" + /> +
+ Kleingeschrieben, ohne Leerzeichen, eindeutig +
+
+ +
+
+ + setFormData({ ...formData, name_de: e.target.value })} + placeholder="z.B. Laufen" + /> +
+ +
+ + setFormData({ ...formData, name_en: e.target.value })} + placeholder="e.g. Running" + /> +
+
+ +
+ + setFormData({ ...formData, icon: e.target.value })} + placeholder="🏃" + maxLength={10} + /> +
+ +
+ + setFormData({ ...formData, sort_order: parseInt(e.target.value) })} + /> +
+ Niedrigere Zahlen werden zuerst angezeigt +
+
+ +
+ +