From 829edecbdc8db961334bc979976ce0f7467d345f Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 19:31:58 +0100 Subject: [PATCH] feat: learnable activity type mapping system (DB-based, auto-learning) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces hardcoded mappings with database-driven, self-learning system. Backend: - Migration 007: activity_type_mappings table - Supports global and user-specific mappings - Seeded with 40+ default mappings (German + English) - Unique constraint: (activity_type, profile_id) - Refactored: get_training_type_for_activity() queries DB - Priority: user-specific → global → NULL - Bulk categorization now saves mapping automatically - Source: 'bulk' for learned mappings - admin_activity_mappings.py: Full CRUD endpoints - List, Get, Create, Update, Delete - Coverage stats endpoint - CSV import uses DB mappings (no hardcoded logic) Frontend: - AdminActivityMappingsPage: Full mapping management UI - Coverage stats (% mapped, unmapped count) - Filter: All / Global - Create/Edit/Delete mappings - Tip: System learns from bulk categorization - Added route + admin link - API methods: adminList/Get/Create/Update/DeleteActivityMapping Benefits: - No code changes needed for new activity types - System learns from user bulk categorizations - User-specific mappings override global defaults - Admin can manage all mappings via UI - Migration pre-populates 40+ common German/English types Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 2 + .../migrations/007_activity_type_mappings.sql | 121 ++++++ backend/routers/activity.py | 114 +++--- backend/routers/admin_activity_mappings.py | 219 +++++++++++ frontend/src/App.jsx | 2 + .../src/pages/AdminActivityMappingsPage.jsx | 366 ++++++++++++++++++ frontend/src/pages/AdminPanel.jsx | 7 +- frontend/src/utils/api.js | 8 + 8 files changed, 772 insertions(+), 67 deletions(-) create mode 100644 backend/migrations/007_activity_type_mappings.sql create mode 100644 backend/routers/admin_activity_mappings.py create mode 100644 frontend/src/pages/AdminActivityMappingsPage.jsx diff --git a/backend/main.py b/backend/main.py index 70bbcd2..e67e3b9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -20,6 +20,7 @@ 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, admin_training_types +from routers import admin_activity_mappings # ── App Configuration ───────────────────────────────────────────────────────── DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) @@ -88,6 +89,7 @@ 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(admin_training_types.router) # /api/admin/training-types/* +app.include_router(admin_activity_mappings.router) # /api/admin/activity-mappings/* # ── Health Check ────────────────────────────────────────────────────────────── @app.get("/") diff --git a/backend/migrations/007_activity_type_mappings.sql b/backend/migrations/007_activity_type_mappings.sql new file mode 100644 index 0000000..78bfbbe --- /dev/null +++ b/backend/migrations/007_activity_type_mappings.sql @@ -0,0 +1,121 @@ +-- Migration 007: Activity Type Mappings (Learnable System) +-- Replaces hardcoded mappings with DB-based configurable system +-- Created: 2026-03-21 + +-- ======================================== +-- 1. Create activity_type_mappings table +-- ======================================== +CREATE TABLE IF NOT EXISTS activity_type_mappings ( + id SERIAL PRIMARY KEY, + activity_type VARCHAR(100) NOT NULL, + training_type_id INTEGER NOT NULL REFERENCES training_types(id) ON DELETE CASCADE, + profile_id VARCHAR(36), -- NULL = global mapping, otherwise user-specific + source VARCHAR(20) DEFAULT 'manual', -- 'manual', 'bulk', 'admin', 'default' + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_activity_type_per_profile UNIQUE(activity_type, profile_id) +); + +-- ======================================== +-- 2. Create indexes +-- ======================================== +CREATE INDEX IF NOT EXISTS idx_activity_type_mappings_type ON activity_type_mappings(activity_type); +CREATE INDEX IF NOT EXISTS idx_activity_type_mappings_profile ON activity_type_mappings(profile_id); + +-- ======================================== +-- 3. Seed default mappings (global) +-- ======================================== +-- Note: These are the German Apple Health workout types +-- training_type_id references are based on existing training_types data + +-- Helper function to get training_type_id by subcategory +DO $$ +DECLARE + v_running_id INTEGER; + v_walk_id INTEGER; + v_cycling_id INTEGER; + v_swimming_id INTEGER; + v_hypertrophy_id INTEGER; + v_functional_id INTEGER; + v_hiit_id INTEGER; + v_yoga_id INTEGER; + v_technique_id INTEGER; + v_sparring_id INTEGER; + v_rowing_id INTEGER; + v_dance_id INTEGER; + v_static_id INTEGER; + v_regeneration_id INTEGER; + v_meditation_id INTEGER; + v_mindfulness_id INTEGER; +BEGIN + -- Get training_type IDs + SELECT id INTO v_running_id FROM training_types WHERE subcategory = 'running' LIMIT 1; + SELECT id INTO v_walk_id FROM training_types WHERE subcategory = 'walk' LIMIT 1; + SELECT id INTO v_cycling_id FROM training_types WHERE subcategory = 'cycling' LIMIT 1; + SELECT id INTO v_swimming_id FROM training_types WHERE subcategory = 'swimming' LIMIT 1; + SELECT id INTO v_hypertrophy_id FROM training_types WHERE subcategory = 'hypertrophy' LIMIT 1; + SELECT id INTO v_functional_id FROM training_types WHERE subcategory = 'functional' LIMIT 1; + SELECT id INTO v_hiit_id FROM training_types WHERE subcategory = 'hiit' LIMIT 1; + SELECT id INTO v_yoga_id FROM training_types WHERE subcategory = 'yoga' LIMIT 1; + SELECT id INTO v_technique_id FROM training_types WHERE subcategory = 'technique' LIMIT 1; + SELECT id INTO v_sparring_id FROM training_types WHERE subcategory = 'sparring' LIMIT 1; + SELECT id INTO v_rowing_id FROM training_types WHERE subcategory = 'rowing' LIMIT 1; + SELECT id INTO v_dance_id FROM training_types WHERE subcategory = 'dance' LIMIT 1; + SELECT id INTO v_static_id FROM training_types WHERE subcategory = 'static' LIMIT 1; + SELECT id INTO v_regeneration_id FROM training_types WHERE subcategory = 'regeneration' LIMIT 1; + SELECT id INTO v_meditation_id FROM training_types WHERE subcategory = 'meditation' LIMIT 1; + SELECT id INTO v_mindfulness_id FROM training_types WHERE subcategory = 'mindfulness' LIMIT 1; + + -- Insert default mappings (German Apple Health names) + INSERT INTO activity_type_mappings (activity_type, training_type_id, profile_id, source) VALUES + -- German workout types + ('Laufen', v_running_id, NULL, 'default'), + ('Gehen', v_walk_id, NULL, 'default'), + ('Wandern', v_walk_id, NULL, 'default'), + ('Outdoor Spaziergang', v_walk_id, NULL, 'default'), + ('Innenräume Spaziergang', v_walk_id, NULL, 'default'), + ('Spaziergang', v_walk_id, NULL, 'default'), + ('Radfahren', v_cycling_id, NULL, 'default'), + ('Schwimmen', v_swimming_id, NULL, 'default'), + ('Traditionelles Krafttraining', v_hypertrophy_id, NULL, 'default'), + ('Funktionelles Krafttraining', v_functional_id, NULL, 'default'), + ('Hochintensives Intervalltraining', v_hiit_id, NULL, 'default'), + ('Yoga', v_yoga_id, NULL, 'default'), + ('Kampfsport', v_technique_id, NULL, 'default'), + ('Matrial Arts', v_technique_id, NULL, 'default'), -- Common typo + ('Boxen', v_sparring_id, NULL, 'default'), + ('Rudern', v_rowing_id, NULL, 'default'), + ('Tanzen', v_dance_id, NULL, 'default'), + ('Cardio Dance', v_dance_id, NULL, 'default'), + ('Flexibilität', v_static_id, NULL, 'default'), + ('Abwärmen', v_regeneration_id, NULL, 'default'), + ('Cooldown', v_regeneration_id, NULL, 'default'), + ('Meditation', v_meditation_id, NULL, 'default'), + ('Achtsamkeit', v_mindfulness_id, NULL, 'default'), + ('Geist & Körper', v_yoga_id, NULL, 'default') + ON CONFLICT (activity_type, profile_id) DO NOTHING; + + -- English workout types (for compatibility) + INSERT INTO activity_type_mappings (activity_type, training_type_id, profile_id, source) VALUES + ('Running', v_running_id, NULL, 'default'), + ('Walking', v_walk_id, NULL, 'default'), + ('Hiking', v_walk_id, NULL, 'default'), + ('Cycling', v_cycling_id, NULL, 'default'), + ('Swimming', v_swimming_id, NULL, 'default'), + ('Traditional Strength Training', v_hypertrophy_id, NULL, 'default'), + ('Functional Strength Training', v_functional_id, NULL, 'default'), + ('High Intensity Interval Training', v_hiit_id, NULL, 'default'), + ('Martial Arts', v_technique_id, NULL, 'default'), + ('Boxing', v_sparring_id, NULL, 'default'), + ('Rowing', v_rowing_id, NULL, 'default'), + ('Dance', v_dance_id, NULL, 'default'), + ('Core Training', v_functional_id, NULL, 'default'), + ('Flexibility', v_static_id, NULL, 'default'), + ('Mindfulness', v_mindfulness_id, NULL, 'default') + ON CONFLICT (activity_type, profile_id) DO NOTHING; +END $$; + +-- ======================================== +-- 4. Add comment +-- ======================================== +COMMENT ON TABLE activity_type_mappings IS 'v9d Phase 1b: Learnable activity type to training type mappings. Replaces hardcoded mappings.'; diff --git a/backend/routers/activity.py b/backend/routers/activity.py index 8b9da64..a37b0bb 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -113,80 +113,44 @@ 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): +def get_training_type_for_activity(activity_type: str, profile_id: str = None): """ - Map Apple Health workout type to training_type_id + category + subcategory. + Map activity_type to training_type_id using database mappings. + + Priority: + 1. User-specific mapping (profile_id) + 2. Global mapping (profile_id = NULL) + 3. No mapping found → returns (None, None, None) + 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 - # Supports English and German workout names - mapping = { - # English - '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': 'dance', - 'core training': 'functional', - 'flexibility': 'static', - 'cooldown': 'regeneration', - 'meditation': 'meditation', - 'mindfulness': 'mindfulness', + # Try user-specific mapping first + if profile_id: + cur.execute(""" + SELECT m.training_type_id, t.category, t.subcategory + FROM activity_type_mappings m + JOIN training_types t ON m.training_type_id = t.id + WHERE m.activity_type = %s AND m.profile_id = %s + LIMIT 1 + """, (activity_type, profile_id)) + row = cur.fetchone() + if row: + return (row['training_type_id'], row['category'], row['subcategory']) - # German (Deutsch) - 'laufen': 'running', - 'gehen': 'walk', - 'wandern': 'walk', - 'outdoor spaziergang': 'walk', - 'innenräume spaziergang': 'walk', - 'spaziergang': 'walk', - 'radfahren': 'cycling', - 'schwimmen': 'swimming', - 'traditionelles krafttraining': 'hypertrophy', - 'funktionelles krafttraining': 'functional', - 'hochintensives intervalltraining': 'hiit', - 'yoga': 'yoga', - 'kampfsport': 'technique', - 'matrial arts': 'technique', # Common typo in Apple Health - 'boxen': 'sparring', - 'rudern': 'rowing', - 'tanzen': 'dance', - 'cardio dance': 'dance', - 'core training': 'functional', - 'flexibilität': 'static', - 'abwärmen': 'regeneration', - 'cooldown': 'regeneration', - 'meditation': 'meditation', - 'achtsamkeit': 'mindfulness', - 'geist & körper': 'yoga', # Mind & Body → Yoga category - } - - subcategory = mapping.get(workout_type.lower()) - if not subcategory: - return (None, None, None) - - # Find training_type_id by subcategory + # Try global mapping cur.execute(""" - SELECT id, category, subcategory - FROM training_types - WHERE LOWER(subcategory) = %s + SELECT m.training_type_id, t.category, t.subcategory + FROM activity_type_mappings m + JOIN training_types t ON m.training_type_id = t.id + WHERE m.activity_type = %s AND m.profile_id IS NULL LIMIT 1 - """, (subcategory,)) + """, (activity_type,)) row = cur.fetchone() - if row: - return (row['id'], row['category'], row['subcategory']) + return (row['training_type_id'], row['category'], row['subcategory']) return (None, None, None) @@ -216,6 +180,9 @@ def bulk_categorize_activities( ): """ Bulk update training type for activities. + + Also saves the mapping to activity_type_mappings for future imports. + Body: { "activity_type": "Running", "training_type_id": 1, @@ -234,6 +201,8 @@ def bulk_categorize_activities( with get_db() as conn: cur = get_cursor(conn) + + # Update existing activities cur.execute(""" UPDATE activity_log SET training_type_id = %s, @@ -245,7 +214,20 @@ def bulk_categorize_activities( """, (training_type_id, training_category, training_subcategory, pid, activity_type)) updated_count = cur.rowcount - return {"updated": updated_count, "activity_type": activity_type} + # Save mapping for future imports (upsert) + cur.execute(""" + INSERT INTO activity_type_mappings (activity_type, training_type_id, profile_id, source, updated_at) + VALUES (%s, %s, %s, 'bulk', CURRENT_TIMESTAMP) + ON CONFLICT (activity_type, profile_id) + DO UPDATE SET + training_type_id = EXCLUDED.training_type_id, + source = 'bulk', + updated_at = CURRENT_TIMESTAMP + """, (activity_type, training_type_id, pid)) + + logger.info(f"[MAPPING] Saved bulk mapping: {activity_type} → training_type_id {training_type_id} (profile {pid})") + + return {"updated": updated_count, "activity_type": activity_type, "mapping_saved": True} @router.post("/import-csv") @@ -280,8 +262,8 @@ 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) + # Map activity_type to training_type_id using database mappings + training_type_id, training_category, training_subcategory = get_training_type_for_activity(wtype, pid) try: # Check if entry already exists (duplicate detection by date + start_time) diff --git a/backend/routers/admin_activity_mappings.py b/backend/routers/admin_activity_mappings.py new file mode 100644 index 0000000..6f04de6 --- /dev/null +++ b/backend/routers/admin_activity_mappings.py @@ -0,0 +1,219 @@ +""" +Admin Activity Type Mappings Management - v9d Phase 1b + +CRUD operations for activity_type_mappings (learnable system). +""" +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_admin + +router = APIRouter(prefix="/api/admin/activity-mappings", tags=["admin", "activity-mappings"]) +logger = logging.getLogger(__name__) + + +class ActivityMappingCreate(BaseModel): + activity_type: str + training_type_id: int + profile_id: Optional[str] = None + source: str = 'admin' + + +class ActivityMappingUpdate(BaseModel): + training_type_id: Optional[int] = None + profile_id: Optional[str] = None + source: Optional[str] = None + + +@router.get("") +def list_activity_mappings( + profile_id: Optional[str] = None, + global_only: bool = False, + session: dict = Depends(require_admin) +): + """ + Get all activity type mappings. + + Filters: + - profile_id: Show only mappings for specific profile + - global_only: Show only global mappings (profile_id IS NULL) + """ + with get_db() as conn: + cur = get_cursor(conn) + + query = """ + SELECT m.id, m.activity_type, m.training_type_id, m.profile_id, m.source, + m.created_at, m.updated_at, + t.name_de as training_type_name_de, + t.category, t.subcategory, t.icon + FROM activity_type_mappings m + JOIN training_types t ON m.training_type_id = t.id + """ + + conditions = [] + params = [] + + if global_only: + conditions.append("m.profile_id IS NULL") + elif profile_id: + conditions.append("m.profile_id = %s") + params.append(profile_id) + + if conditions: + query += " WHERE " + " AND ".join(conditions) + + query += " ORDER BY m.activity_type" + + cur.execute(query, params) + rows = cur.fetchall() + + return [r2d(r) for r in rows] + + +@router.get("/{mapping_id}") +def get_activity_mapping(mapping_id: int, session: dict = Depends(require_admin)): + """Get single activity mapping by ID.""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(""" + SELECT m.id, m.activity_type, m.training_type_id, m.profile_id, m.source, + m.created_at, m.updated_at, + t.name_de as training_type_name_de, + t.category, t.subcategory + FROM activity_type_mappings m + JOIN training_types t ON m.training_type_id = t.id + WHERE m.id = %s + """, (mapping_id,)) + row = cur.fetchone() + + if not row: + raise HTTPException(404, "Mapping not found") + + return r2d(row) + + +@router.post("") +def create_activity_mapping(data: ActivityMappingCreate, session: dict = Depends(require_admin)): + """ + Create new activity type mapping. + + Note: Duplicate (activity_type, profile_id) will fail with 409 Conflict. + """ + with get_db() as conn: + cur = get_cursor(conn) + + try: + cur.execute(""" + INSERT INTO activity_type_mappings + (activity_type, training_type_id, profile_id, source) + VALUES (%s, %s, %s, %s) + RETURNING id + """, ( + data.activity_type, + data.training_type_id, + data.profile_id, + data.source + )) + + new_id = cur.fetchone()['id'] + + logger.info(f"[ADMIN] Mapping created: {data.activity_type} → training_type_id {data.training_type_id} (profile: {data.profile_id})") + + except Exception as e: + if 'unique_activity_type_per_profile' in str(e): + raise HTTPException(409, f"Mapping for '{data.activity_type}' already exists (profile: {data.profile_id})") + raise HTTPException(400, f"Failed to create mapping: {str(e)}") + + return {"id": new_id, "message": "Mapping created"} + + +@router.put("/{mapping_id}") +def update_activity_mapping( + mapping_id: int, + data: ActivityMappingUpdate, + session: dict = Depends(require_admin) +): + """Update existing activity type mapping.""" + with get_db() as conn: + cur = get_cursor(conn) + + # Build update query dynamically + updates = [] + values = [] + + if data.training_type_id is not None: + updates.append("training_type_id = %s") + values.append(data.training_type_id) + if data.profile_id is not None: + updates.append("profile_id = %s") + values.append(data.profile_id) + if data.source is not None: + updates.append("source = %s") + values.append(data.source) + + if not updates: + raise HTTPException(400, "No fields to update") + + updates.append("updated_at = CURRENT_TIMESTAMP") + values.append(mapping_id) + + cur.execute(f""" + UPDATE activity_type_mappings + SET {', '.join(updates)} + WHERE id = %s + """, values) + + if cur.rowcount == 0: + raise HTTPException(404, "Mapping not found") + + logger.info(f"[ADMIN] Mapping updated: {mapping_id}") + + return {"id": mapping_id, "message": "Mapping updated"} + + +@router.delete("/{mapping_id}") +def delete_activity_mapping(mapping_id: int, session: dict = Depends(require_admin)): + """ + Delete activity type mapping. + + This will cause future imports to NOT auto-assign training type for this activity_type. + Existing activities with this mapping remain unchanged. + """ + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute("DELETE FROM activity_type_mappings WHERE id = %s", (mapping_id,)) + + if cur.rowcount == 0: + raise HTTPException(404, "Mapping not found") + + logger.info(f"[ADMIN] Mapping deleted: {mapping_id}") + + return {"message": "Mapping deleted"} + + +@router.get("/stats/coverage") +def get_mapping_coverage(session: dict = Depends(require_admin)): + """ + Get statistics about mapping coverage. + + Returns how many activities are mapped vs unmapped across all profiles. + """ + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute(""" + SELECT + COUNT(*) as total_activities, + COUNT(training_type_id) as mapped_activities, + COUNT(*) - COUNT(training_type_id) as unmapped_activities, + COUNT(DISTINCT activity_type) as unique_activity_types, + COUNT(DISTINCT CASE WHEN training_type_id IS NULL THEN activity_type END) as unmapped_types + FROM activity_log + """) + stats = r2d(cur.fetchone()) + + return stats diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8afed6f..d65ebac 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -28,6 +28,7 @@ import AdminTiersPage from './pages/AdminTiersPage' import AdminCouponsPage from './pages/AdminCouponsPage' import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage' import AdminTrainingTypesPage from './pages/AdminTrainingTypesPage' +import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage' import SubscriptionPage from './pages/SubscriptionPage' import './app.css' @@ -174,6 +175,7 @@ function AppShell() { }/> }/> }/> + }/> }/> diff --git a/frontend/src/pages/AdminActivityMappingsPage.jsx b/frontend/src/pages/AdminActivityMappingsPage.jsx new file mode 100644 index 0000000..c1a5c7b --- /dev/null +++ b/frontend/src/pages/AdminActivityMappingsPage.jsx @@ -0,0 +1,366 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { Pencil, Trash2, Plus, Save, X, ArrowLeft, TrendingUp } from 'lucide-react' +import { api } from '../utils/api' + +/** + * AdminActivityMappingsPage - Manage activity_type → training_type mappings + * v9d Phase 1b - Learnable system (replaces hardcoded mappings) + */ +export default function AdminActivityMappingsPage() { + const nav = useNavigate() + const [mappings, setMappings] = useState([]) + const [trainingTypes, setTrainingTypes] = useState([]) + const [coverage, setCoverage] = useState(null) + 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) + const [filter, setFilter] = useState('all') // 'all', 'global', 'user' + + useEffect(() => { + load() + }, [filter]) + + const load = () => { + setLoading(true) + Promise.all([ + api.adminListActivityMappings(null, filter === 'global'), + api.listTrainingTypesFlat(), + api.adminGetMappingCoverage() + ]).then(([mappingsData, typesData, coverageData]) => { + setMappings(mappingsData) + setTrainingTypes(typesData) + setCoverage(coverageData) + setLoading(false) + }).catch(err => { + console.error('Failed to load mappings:', err) + setError(err.message) + setLoading(false) + }) + } + + const startCreate = () => { + setFormData({ + activity_type: '', + training_type_id: trainingTypes[0]?.id || null, + profile_id: '', + source: 'admin' + }) + setEditingId('new') + } + + const startEdit = (mapping) => { + setFormData({ + activity_type: mapping.activity_type, + training_type_id: mapping.training_type_id, + profile_id: mapping.profile_id || '', + source: mapping.source + }) + setEditingId(mapping.id) + } + + const cancelEdit = () => { + setEditingId(null) + setFormData(null) + setError(null) + } + + const handleSave = async () => { + if (!formData.activity_type || !formData.training_type_id) { + setError('Activity Type und Training Type sind Pflichtfelder') + return + } + + setSaving(true) + setError(null) + + try { + const payload = { + ...formData, + profile_id: formData.profile_id || null + } + + if (editingId === 'new') { + await api.adminCreateActivityMapping(payload) + } else { + await api.adminUpdateActivityMapping(editingId, { + training_type_id: payload.training_type_id, + profile_id: payload.profile_id, + source: payload.source + }) + } + await load() + cancelEdit() + } catch (err) { + console.error('Save failed:', err) + setError(err.message) + } finally { + setSaving(false) + } + } + + const handleDelete = async (id, activityType) => { + if (!confirm(`Mapping für "${activityType}" wirklich löschen?\n\nZukünftige Imports werden diesen Typ nicht mehr automatisch zuordnen.`)) { + return + } + + try { + await api.adminDeleteActivityMapping(id) + await load() + } catch (err) { + alert('Löschen fehlgeschlagen: ' + err.message) + } + } + + if (loading) { + return ( +
+
+
+ ) + } + + const coveragePercent = coverage ? Math.round((coverage.mapped_activities / coverage.total_activities) * 100) : 0 + + return ( +
+
+ +

Activity-Mappings

+
+ + {error && ( +
+ {error} +
+ )} + + {/* Coverage Stats */} + {coverage && ( +
+
+ +
Mapping-Abdeckung
+
+
+
+
{coveragePercent}%
+
Zugeordnet
+
+
+
{coverage.mapped_activities}
+
Mit Typ
+
+
+
{coverage.unmapped_activities}
+
Ohne Typ
+
+
+
+ {coverage.unmapped_types} verschiedene Activity-Types noch nicht gemappt +
+
+ )} + + {/* Filter */} +
+ + +
+ + {/* Create new button */} + {!editingId && ( + + )} + + {/* Edit form */} + {editingId && formData && ( +
+
+ {editingId === 'new' ? '➕ Neues Mapping' : '✏️ Mapping bearbeiten'} +
+ +
+
+
Activity Type * (exakt wie in CSV)
+ setFormData({ ...formData, activity_type: e.target.value })} + placeholder="z.B. Traditionelles Krafttraining" + disabled={editingId !== 'new'} + style={{ width: '100%' }} + /> +
+ Groß-/Kleinschreibung beachten! Muss exakt mit CSV übereinstimmen. +
+
+ +
+
Training Type *
+ +
+ +
+
Profil-ID (leer = global)
+ setFormData({ ...formData, profile_id: e.target.value })} + placeholder="Leer lassen für globales Mapping" + style={{ width: '100%' }} + /> +
+ Global = für alle User, sonst user-spezifisch +
+
+ +
+ + +
+
+
+ )} + + {/* List */} + {mappings.length === 0 ? ( +
+ Keine Mappings gefunden +
+ ) : ( +
+ {mappings.map(mapping => ( +
+
+
{mapping.icon}
+
+
+ {mapping.activity_type} +
+
+ → {mapping.training_type_name_de} + {mapping.profile_id && <> · User-spezifisch} + {!mapping.profile_id && <> · Global} + {mapping.source && <> · {mapping.source}} +
+
+ + +
+
+ ))} +
+ )} + +
+ 💡 Tipp: Das System lernt automatisch! Wenn du im Tab "Kategorisieren" Aktivitäten zuordnest, wird das Mapping gespeichert und beim nächsten Import automatisch angewendet. +
+
+ ) +} diff --git a/frontend/src/pages/AdminPanel.jsx b/frontend/src/pages/AdminPanel.jsx index 1dd49bc..94b9822 100644 --- a/frontend/src/pages/AdminPanel.jsx +++ b/frontend/src/pages/AdminPanel.jsx @@ -431,7 +431,7 @@ export default function AdminPanel() { Trainingstypen (v9d)
- Verwalte Trainingstypen, Kategorien und Fähigkeiten-Mapping. + Verwalte Trainingstypen, Kategorien und Activity-Mappings (lernendes System).
@@ -439,6 +439,11 @@ export default function AdminPanel() { 🏋️ Trainingstypen verwalten + + +
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 8c42bc0..e2e3b39 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -204,4 +204,12 @@ export const api = { adminUpdateTrainingType: (id,d) => req(`/admin/training-types/${id}`, jput(d)), adminDeleteTrainingType: (id) => req(`/admin/training-types/${id}`, {method:'DELETE'}), getAbilitiesTaxonomy: () => req('/admin/training-types/taxonomy/abilities'), + + // Admin: Activity Type Mappings (v9d Phase 1b - Learnable System) + adminListActivityMappings: (profileId, globalOnly) => req(`/admin/activity-mappings${profileId?'?profile_id='+profileId:''}${globalOnly?'?global_only=true':''}`), + adminGetActivityMapping: (id) => req(`/admin/activity-mappings/${id}`), + adminCreateActivityMapping: (d) => req('/admin/activity-mappings', json(d)), + adminUpdateActivityMapping: (id,d) => req(`/admin/activity-mappings/${id}`, jput(d)), + adminDeleteActivityMapping: (id) => req(`/admin/activity-mappings/${id}`, {method:'DELETE'}), + adminGetMappingCoverage: () => req('/admin/activity-mappings/stats/coverage'), }