feat: learnable activity type mapping system (DB-based, auto-learning)
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s

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 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-21 19:31:58 +01:00
parent a4bd738e6f
commit 829edecbdc
8 changed files with 772 additions and 67 deletions

View File

@ -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("/")

View File

@ -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.';

View File

@ -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)

View File

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

View File

@ -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() {
<Route path="/admin/coupons" element={<AdminCouponsPage/>}/>
<Route path="/admin/user-restrictions" element={<AdminUserRestrictionsPage/>}/>
<Route path="/admin/training-types" element={<AdminTrainingTypesPage/>}/>
<Route path="/admin/activity-mappings" element={<AdminActivityMappingsPage/>}/>
<Route path="/subscription" element={<SubscriptionPage/>}/>
</Routes>
</main>

View File

@ -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 (
<div style={{ padding: 20, textAlign: 'center' }}>
<div className="spinner" style={{ width: 32, height: 32, margin: '0 auto' }} />
</div>
)
}
const coveragePercent = coverage ? Math.round((coverage.mapped_activities / coverage.total_activities) * 100) : 0
return (
<div style={{ padding: '16px 16px 80px' }}>
<div style={{ marginBottom: 20, display: 'flex', alignItems: 'center', gap: 12 }}>
<button
onClick={() => nav('/settings')}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 6,
display: 'flex',
color: 'var(--text2)'
}}
>
<ArrowLeft size={20} />
</button>
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>Activity-Mappings</h1>
</div>
{error && (
<div className="card" style={{ padding: 12, marginBottom: 16, background: '#FCEBEB', color: '#D85A30' }}>
{error}
</div>
)}
{/* Coverage Stats */}
{coverage && (
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<TrendingUp size={16} color="var(--accent)" />
<div style={{ fontWeight: 600, fontSize: 14 }}>Mapping-Abdeckung</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8, fontSize: 12 }}>
<div style={{ textAlign: 'center', padding: 8, background: 'var(--surface)', borderRadius: 6 }}>
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--accent)' }}>{coveragePercent}%</div>
<div style={{ color: 'var(--text3)' }}>Zugeordnet</div>
</div>
<div style={{ textAlign: 'center', padding: 8, background: 'var(--surface)', borderRadius: 6 }}>
<div style={{ fontSize: 18, fontWeight: 700 }}>{coverage.mapped_activities}</div>
<div style={{ color: 'var(--text3)' }}>Mit Typ</div>
</div>
<div style={{ textAlign: 'center', padding: 8, background: 'var(--surface)', borderRadius: 6 }}>
<div style={{ fontSize: 18, fontWeight: 700, color: '#D85A30' }}>{coverage.unmapped_activities}</div>
<div style={{ color: 'var(--text3)' }}>Ohne Typ</div>
</div>
</div>
<div style={{ marginTop: 8, fontSize: 11, color: 'var(--text3)' }}>
{coverage.unmapped_types} verschiedene Activity-Types noch nicht gemappt
</div>
</div>
)}
{/* Filter */}
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<button
onClick={() => setFilter('all')}
className={filter === 'all' ? 'btn btn-primary' : 'btn btn-secondary'}
style={{ flex: 1, fontSize: 12 }}
>
Alle ({mappings.length})
</button>
<button
onClick={() => setFilter('global')}
className={filter === 'global' ? 'btn btn-primary' : 'btn btn-secondary'}
style={{ flex: 1, fontSize: 12 }}
>
Global
</button>
</div>
{/* Create new button */}
{!editingId && (
<button
onClick={startCreate}
className="btn btn-primary btn-full"
style={{ marginBottom: 16 }}
>
<Plus size={16} /> Neues Mapping anlegen
</button>
)}
{/* Edit form */}
{editingId && formData && (
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div style={{ fontWeight: 600, marginBottom: 12 }}>
{editingId === 'new' ? ' Neues Mapping' : '✏️ Mapping bearbeiten'}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<div className="form-label">Activity Type * (exakt wie in CSV)</div>
<input
className="form-input"
value={formData.activity_type}
onChange={e => setFormData({ ...formData, activity_type: e.target.value })}
placeholder="z.B. Traditionelles Krafttraining"
disabled={editingId !== 'new'}
style={{ width: '100%' }}
/>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
Groß-/Kleinschreibung beachten! Muss exakt mit CSV übereinstimmen.
</div>
</div>
<div>
<div className="form-label">Training Type *</div>
<select
className="form-input"
value={formData.training_type_id}
onChange={e => setFormData({ ...formData, training_type_id: parseInt(e.target.value) })}
style={{ width: '100%' }}
>
{trainingTypes.map(type => (
<option key={type.id} value={type.id}>
{type.icon} {type.name_de} ({type.category})
</option>
))}
</select>
</div>
<div>
<div className="form-label">Profil-ID (leer = global)</div>
<input
className="form-input"
value={formData.profile_id}
onChange={e => setFormData({ ...formData, profile_id: e.target.value })}
placeholder="Leer lassen für globales Mapping"
style={{ width: '100%' }}
/>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
Global = für alle User, sonst user-spezifisch
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button
onClick={handleSave}
disabled={saving}
className="btn btn-primary"
style={{ flex: 1 }}
>
{saving ? (
<>
<div className="spinner" style={{ width: 14, height: 14 }} />
Speichere...
</>
) : (
<>
<Save size={16} /> Speichern
</>
)}
</button>
<button
onClick={cancelEdit}
disabled={saving}
className="btn btn-secondary"
style={{ flex: 1 }}
>
<X size={16} /> Abbrechen
</button>
</div>
</div>
</div>
)}
{/* List */}
{mappings.length === 0 ? (
<div className="card" style={{ padding: 40, textAlign: 'center', color: 'var(--text3)' }}>
Keine Mappings gefunden
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{mappings.map(mapping => (
<div
key={mapping.id}
className="card"
style={{ padding: 12 }}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 8
}}>
<div style={{ fontSize: 18 }}>{mapping.icon}</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>
{mapping.activity_type}
</div>
<div style={{ fontSize: 11, color: 'var(--text3)' }}>
{mapping.training_type_name_de}
{mapping.profile_id && <> · User-spezifisch</>}
{!mapping.profile_id && <> · Global</>}
{mapping.source && <> · {mapping.source}</>}
</div>
</div>
<button
onClick={() => startEdit(mapping)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 6,
color: 'var(--accent)'
}}
title="Bearbeiten"
>
<Pencil size={16} />
</button>
<button
onClick={() => handleDelete(mapping.id, mapping.activity_type)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 6,
color: '#D85A30'
}}
title="Löschen"
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
</div>
)}
<div style={{
marginTop: 20,
padding: 12,
background: 'var(--surface2)',
borderRadius: 8,
fontSize: 12,
color: 'var(--text3)'
}}>
<strong>💡 Tipp:</strong> Das System lernt automatisch! Wenn du im Tab "Kategorisieren" Aktivitäten zuordnest, wird das Mapping gespeichert und beim nächsten Import automatisch angewendet.
</div>
</div>
)
}

View File

@ -431,7 +431,7 @@ export default function AdminPanel() {
<Settings size={16} color="var(--accent)"/> Trainingstypen (v9d)
</div>
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
Verwalte Trainingstypen, Kategorien und Fähigkeiten-Mapping.
Verwalte Trainingstypen, Kategorien und Activity-Mappings (lernendes System).
</div>
<div style={{display:'grid',gap:8}}>
<Link to="/admin/training-types">
@ -439,6 +439,11 @@ export default function AdminPanel() {
🏋 Trainingstypen verwalten
</button>
</Link>
<Link to="/admin/activity-mappings">
<button className="btn btn-secondary btn-full">
🔗 Activity-Mappings (lernendes System)
</button>
</Link>
</div>
</div>
</div>

View File

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