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 +
+
+ +
+ +