feat: learnable activity type mapping system (DB-based, auto-learning)
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:
parent
a4bd738e6f
commit
829edecbdc
|
|
@ -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("/")
|
||||
|
|
|
|||
121
backend/migrations/007_activity_type_mappings.sql
Normal file
121
backend/migrations/007_activity_type_mappings.sql
Normal 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.';
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
219
backend/routers/admin_activity_mappings.py
Normal file
219
backend/routers/admin_activity_mappings.py
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
366
frontend/src/pages/AdminActivityMappingsPage.jsx
Normal file
366
frontend/src/pages/AdminActivityMappingsPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user