Merge pull request 'Abschluss 9c' (#11) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 54s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s

Reviewed-on: #11
This commit is contained in:
Lars 2026-03-21 21:20:10 +01:00
commit 3ff2a1bf45
23 changed files with 2535 additions and 38 deletions

View File

@ -49,7 +49,7 @@ frontend/src/
└── technical/ # MEMBERSHIP_SYSTEM.md └── technical/ # MEMBERSHIP_SYSTEM.md
``` ```
## Aktuelle Version: v9c (komplett) ## Aktuelle Version: v9c (komplett) 🚀 Production seit 21.03.2026
### Implementiert ✅ ### Implementiert ✅
- Login (E-Mail + bcrypt), Auth-Middleware alle Endpoints, Rate Limiting - Login (E-Mail + bcrypt), Auth-Middleware alle Endpoints, Rate Limiting
@ -81,15 +81,55 @@ frontend/src/
- ✅ **BUG-002:** Ernährungs-Daten Tab fehlte importierte Einträge nicht sichtbar - ✅ **BUG-002:** Ernährungs-Daten Tab fehlte importierte Einträge nicht sichtbar
- ✅ **BUG-003:** Korrelations-Chart Extrapolation (gestrichelte Linien für fehlende Werte) - ✅ **BUG-003:** Korrelations-Chart Extrapolation (gestrichelte Linien für fehlende Werte)
- ✅ **BUG-004:** Import-Historie Refresh (Force remount via key prop) - ✅ **BUG-004:** Import-Historie Refresh (Force remount via key prop)
- ✅ **BUG-005:** Login → leere Seite (window.location.href='/' nach login)
- ✅ **BUG-006:** Email-Verifizierung → leere Seite (window.location.href='/' statt navigate)
- ✅ **BUG-007:** Doppelklick Verifizierungslink → generischer JSON-Fehler (Error-Parsing + bessere Backend-Meldung)
- ✅ **BUG-008:** Dashboard infinite loading bei API-Fehlern (.catch() handler in load())
### v9c Finalisierung ✅ ### v9c Finalisierung ✅ (Deployed to Production 21.03.2026)
- ✅ **Selbst-Registrierung:** POST /api/auth/register, E-Mail-Verifizierung, Auto-Login - ✅ **Selbst-Registrierung:** POST /api/auth/register, E-Mail-Verifizierung, Auto-Login
- ✅ **Trial-System UI:** Countdown-Banner im Dashboard (3 Urgency-Level) - ✅ **Trial-System UI:** Countdown-Banner im Dashboard (3 Urgency-Level)
- ✅ **Migrations-System:** Automatische Schema-Migrationen beim Start (db_init.py) - ✅ **Migrations-System:** Automatische Schema-Migrationen beim Start (db_init.py)
- ✅ **Navigation-Fixes:** Alle Login/Verify-Flows funktionieren korrekt
- ✅ **Error-Handling:** JSON-Fehler sauber formatiert, Dashboard robust bei API-Fehlern
### Offen v9d 🔲 ### Auf develop (bereit für Prod) 🚀
- Schlaf-Modul **v9d Phase 1b - Feature-komplett, ready for deployment**
- Trainingstypen + Herzfrequenz
- ✅ **Trainingstypen-System (komplett):**
- 29 Trainingstypen (7 Kategorien)
- Admin-CRUD mit vollständiger UI
- Automatisches Apple Health Mapping (23 Workout-Typen)
- Bulk-Kategorisierung für bestehende Aktivitäten
- Farbige Typ-Badges in Aktivitätsliste
- TrainingTypeDistribution Chart in History-Seite
- TrainingTypeSelect in ActivityPage
- ✅ **Weitere Verbesserungen:**
- TrialBanner mailto (Vorbereitung zentrales Abo-System)
- Admin-Formular UX-Optimierung (Full-width inputs, größere Textareas)
- 📚 **Dokumentation:**
- `.claude/docs/technical/CENTRAL_SUBSCRIPTION_SYSTEM.md`
- `.claude/docs/functional/AI_PROMPTS.md` (erweitert um Fähigkeiten-Mapping)
### v9d Phase 1 ✅ (Deployed 21.03.2026)
- ✅ **Trainingstypen Basis:** DB-Schema, 23 Typen, API-Endpoints
- ✅ **Logout-Button:** Im Header neben Avatar, mit Bestätigung
- ✅ **Components:** TrainingTypeSelect, TrainingTypeDistribution
### v9d Phase 1b ✅ (Abgeschlossen, auf develop)
- ✅ ActivityPage: TrainingTypeSelect eingebunden
- ✅ History: TrainingTypeDistribution Chart + Typ-Badges bei Aktivitäten
- ✅ Apple Health Import: Automatisches Mapping (29 Typen)
- ✅ Bulk-Kategorisierung: UI + Endpoints
- ✅ Admin-CRUD: Vollständige Verwaltung inkl. UX-Optimierungen
### v9d Phase 2+ 🔲 (Später)
- 🔲 Ruhetage erfassen (rest_days Tabelle)
- 🔲 Ruhepuls erfassen (vitals_log Tabelle)
- 🔲 HF-Zonen + Erholungsstatus
- 🔲 Schlaf-Modul
📚 Details: `.claude/docs/technical/MEMBERSHIP_SYSTEM.md` · `.claude/docs/architecture/FEATURE_ENFORCEMENT.md` 📚 Details: `.claude/docs/technical/MEMBERSHIP_SYSTEM.md` · `.claude/docs/architecture/FEATURE_ENFORCEMENT.md`

View File

@ -19,7 +19,8 @@ from routers import auth, profiles, weight, circumference, caliper
from routers import activity, nutrition, photos, insights, prompts from routers import activity, nutrition, photos, insights, prompts
from routers import admin, stats, exportdata, importdata from routers import admin, stats, exportdata, importdata
from routers import subscription, coupons, features, tiers_mgmt, tier_limits from routers import subscription, coupons, features, tiers_mgmt, tier_limits
from routers import user_restrictions, access_grants from routers import user_restrictions, access_grants, training_types, admin_training_types
from routers import admin_activity_mappings
# ── App Configuration ───────────────────────────────────────────────────────── # ── App Configuration ─────────────────────────────────────────────────────────
DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
@ -85,6 +86,11 @@ app.include_router(tier_limits.router) # /api/tier-limits (admin)
app.include_router(user_restrictions.router) # /api/user-restrictions (admin) app.include_router(user_restrictions.router) # /api/user-restrictions (admin)
app.include_router(access_grants.router) # /api/access-grants (admin) app.include_router(access_grants.router) # /api/access-grants (admin)
# v9d Training Types
app.include_router(training_types.router) # /api/training-types/*
app.include_router(admin_training_types.router) # /api/admin/training-types/*
app.include_router(admin_activity_mappings.router) # /api/admin/activity-mappings/*
# ── Health Check ────────────────────────────────────────────────────────────── # ── Health Check ──────────────────────────────────────────────────────────────
@app.get("/") @app.get("/")
def root(): def root():

View File

@ -0,0 +1,86 @@
-- Migration 004: Training Types & Categories
-- Part of v9d: Schlaf + Sport-Vertiefung
-- Created: 2026-03-21
-- ========================================
-- 1. Create training_types table
-- ========================================
CREATE TABLE IF NOT EXISTS training_types (
id SERIAL PRIMARY KEY,
category VARCHAR(50) NOT NULL, -- Main category: 'cardio', 'strength', 'hiit', etc.
subcategory VARCHAR(50), -- Optional: 'running', 'hypertrophy', etc.
name_de VARCHAR(100) NOT NULL, -- German display name
name_en VARCHAR(100) NOT NULL, -- English display name
icon VARCHAR(10), -- Emoji icon
sort_order INTEGER DEFAULT 0, -- For UI ordering
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ========================================
-- 2. Add training type columns to activity_log
-- ========================================
ALTER TABLE activity_log
ADD COLUMN IF NOT EXISTS training_type_id INTEGER REFERENCES training_types(id),
ADD COLUMN IF NOT EXISTS training_category VARCHAR(50), -- Denormalized for fast queries
ADD COLUMN IF NOT EXISTS training_subcategory VARCHAR(50); -- Denormalized
-- ========================================
-- 3. Create indexes
-- ========================================
CREATE INDEX IF NOT EXISTS idx_activity_training_type ON activity_log(training_type_id);
CREATE INDEX IF NOT EXISTS idx_activity_training_category ON activity_log(training_category);
CREATE INDEX IF NOT EXISTS idx_training_types_category ON training_types(category);
-- ========================================
-- 4. Seed training types data
-- ========================================
-- Cardio (Ausdauer)
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
('cardio', 'running', 'Laufen', 'Running', '🏃', 100),
('cardio', 'cycling', 'Radfahren', 'Cycling', '🚴', 101),
('cardio', 'swimming', 'Schwimmen', 'Swimming', '🏊', 102),
('cardio', 'rowing', 'Rudern', 'Rowing', '🚣', 103),
('cardio', 'other', 'Sonstiges Cardio', 'Other Cardio', '❤️', 104);
-- Kraft
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
('strength', 'hypertrophy', 'Hypertrophie', 'Hypertrophy', '💪', 200),
('strength', 'maxstrength', 'Maximalkraft', 'Max Strength', '🏋️', 201),
('strength', 'endurance', 'Kraftausdauer', 'Strength Endurance', '🔁', 202),
('strength', 'functional', 'Funktionell', 'Functional', '', 203);
-- Schnellkraft / HIIT
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
('hiit', 'hiit', 'HIIT', 'HIIT', '🔥', 300),
('hiit', 'explosive', 'Explosiv', 'Explosive', '💥', 301),
('hiit', 'circuit', 'Circuit Training', 'Circuit Training', '🔄', 302);
-- Kampfsport / Technikkraft
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
('martial_arts', 'technique', 'Techniktraining', 'Technique Training', '🥋', 400),
('martial_arts', 'sparring', 'Sparring / Wettkampf', 'Sparring / Competition', '🥊', 401),
('martial_arts', 'strength', 'Kraft für Kampfsport', 'Martial Arts Strength', '⚔️', 402);
-- Mobility & Dehnung
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
('mobility', 'static', 'Statisches Dehnen', 'Static Stretching', '🧘', 500),
('mobility', 'dynamic', 'Dynamisches Dehnen', 'Dynamic Stretching', '🤸', 501),
('mobility', 'yoga', 'Yoga', 'Yoga', '🕉️', 502),
('mobility', 'fascia', 'Faszienarbeit', 'Fascia Work', '🎯', 503);
-- Erholung (aktiv)
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
('recovery', 'walk', 'Spaziergang', 'Walk', '🚶', 600),
('recovery', 'swim_light', 'Leichtes Schwimmen', 'Light Swimming', '🏊', 601),
('recovery', 'regeneration', 'Regenerationseinheit', 'Regeneration', '💆', 602);
-- General / Uncategorized
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
('other', NULL, 'Sonstiges', 'Other', '📝', 900);
-- ========================================
-- 5. Add comment
-- ========================================
COMMENT ON TABLE training_types IS 'v9d: Training type categories and subcategories';
COMMENT ON TABLE activity_log IS 'Extended in v9d with training_type_id for categorization';

View File

@ -0,0 +1,24 @@
-- Migration 005: Extended Training Types
-- Add: Cardio (Gehen, Tanzen), Mind & Meditation category
-- Created: 2026-03-21
-- ========================================
-- Add new cardio subcategories
-- ========================================
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
('cardio', 'walk', 'Gehen', 'Walking', '🚶', 105),
('cardio', 'dance', 'Tanzen', 'Dance', '💃', 106);
-- ========================================
-- Add new category: Geist & Meditation
-- ========================================
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
('mind', 'meditation', 'Meditation', 'Meditation', '🧘‍♂️', 700),
('mind', 'breathwork', 'Atemarbeit', 'Breathwork', '🫁', 701),
('mind', 'mindfulness', 'Achtsamkeit', 'Mindfulness', '☮️', 702),
('mind', 'visualization', 'Visualisierung', 'Visualization', '🎨', 703);
-- ========================================
-- Add comment
-- ========================================
COMMENT ON TABLE training_types IS 'v9d Phase 1b: Extended with cardio walk/dance and mind category';

View File

@ -0,0 +1,29 @@
-- Migration 006: Training Types - Abilities Mapping
-- Add abilities JSONB column for future AI analysis
-- Maps to: koordinativ, konditionell, kognitiv, psychisch, taktisch
-- Created: 2026-03-21
-- ========================================
-- Add abilities column
-- ========================================
ALTER TABLE training_types
ADD COLUMN IF NOT EXISTS abilities JSONB DEFAULT '{}';
-- ========================================
-- Add description columns for better documentation
-- ========================================
ALTER TABLE training_types
ADD COLUMN IF NOT EXISTS description_de TEXT,
ADD COLUMN IF NOT EXISTS description_en TEXT;
-- ========================================
-- Add index for abilities queries
-- ========================================
CREATE INDEX IF NOT EXISTS idx_training_types_abilities ON training_types USING GIN (abilities);
-- ========================================
-- Comment
-- ========================================
COMMENT ON COLUMN training_types.abilities IS 'JSONB: Maps to athletic abilities for AI analysis (koordinativ, konditionell, kognitiv, psychisch, taktisch)';
COMMENT ON COLUMN training_types.description_de IS 'German description for admin UI and AI context';
COMMENT ON COLUMN training_types.description_en IS 'English description for admin UI and AI context';

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

@ -84,6 +84,9 @@ class ActivityEntry(BaseModel):
rpe: Optional[int] = None rpe: Optional[int] = None
source: Optional[str] = 'manual' source: Optional[str] = 'manual'
notes: Optional[str] = None notes: Optional[str] = None
training_type_id: Optional[int] = None # v9d: Training type categorization
training_category: Optional[str] = None # v9d: Denormalized category
training_subcategory: Optional[str] = None # v9d: Denormalized subcategory
class NutritionDay(BaseModel): class NutritionDay(BaseModel):

View File

@ -113,9 +113,126 @@ 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} return {"count":len(rows),"total_kcal":round(total_kcal),"total_min":round(total_min),"by_type":by_type}
def get_training_type_for_activity(activity_type: str, profile_id: str = None):
"""
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)
# 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'])
# Try global mapping
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 IS NULL
LIMIT 1
""", (activity_type,))
row = cur.fetchone()
if row:
return (row['training_type_id'], row['category'], row['subcategory'])
return (None, None, None)
@router.get("/uncategorized")
def list_uncategorized_activities(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Get activities without assigned training type, grouped by activity_type."""
pid = get_pid(x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT activity_type, COUNT(*) as count,
MIN(date) as first_date, MAX(date) as last_date
FROM activity_log
WHERE profile_id=%s AND training_type_id IS NULL
GROUP BY activity_type
ORDER BY count DESC
""", (pid,))
return [r2d(r) for r in cur.fetchall()]
@router.post("/bulk-categorize")
def bulk_categorize_activities(
data: dict,
x_profile_id: Optional[str]=Header(default=None),
session: dict=Depends(require_auth)
):
"""
Bulk update training type for activities.
Also saves the mapping to activity_type_mappings for future imports.
Body: {
"activity_type": "Running",
"training_type_id": 1,
"training_category": "cardio",
"training_subcategory": "running"
}
"""
pid = get_pid(x_profile_id)
activity_type = data.get('activity_type')
training_type_id = data.get('training_type_id')
training_category = data.get('training_category')
training_subcategory = data.get('training_subcategory')
if not activity_type or not training_type_id:
raise HTTPException(400, "activity_type and training_type_id required")
with get_db() as conn:
cur = get_cursor(conn)
# Update existing activities
cur.execute("""
UPDATE activity_log
SET training_type_id = %s,
training_category = %s,
training_subcategory = %s
WHERE profile_id = %s
AND activity_type = %s
AND training_type_id IS NULL
""", (training_type_id, training_category, training_subcategory, pid, activity_type))
updated_count = cur.rowcount
# 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") @router.post("/import-csv")
async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Import Apple Health workout CSV.""" """Import Apple Health workout CSV with automatic training type mapping."""
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
raw = await file.read() raw = await file.read()
try: text = raw.decode('utf-8') try: text = raw.decode('utf-8')
@ -145,16 +262,58 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional
def tf(v): def tf(v):
try: return round(float(v),1) if v else None try: return round(float(v),1) if v else None
except: return None except: return None
# 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: try:
cur.execute("""INSERT INTO activity_log # Check if entry already exists (duplicate detection by date + start_time)
(id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting, cur.execute("""
hr_avg,hr_max,distance_km,source,created) SELECT id FROM activity_log
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'apple_health',CURRENT_TIMESTAMP)""", WHERE profile_id = %s AND date = %s AND start_time = %s
(str(uuid.uuid4()),pid,date,start,row.get('End',''),wtype,duration_min, """, (pid, date, start))
kj(row.get('Aktive Energie (kJ)','')),kj(row.get('Ruheeinträge (kJ)','')), existing = cur.fetchone()
tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
tf(row.get('Max. Herzfrequenz (count/min)','')), if existing:
tf(row.get('Distanz (km)','')))) # Update existing entry (e.g., to add training type mapping)
inserted+=1 cur.execute("""
except: skipped+=1 UPDATE activity_log
SET end_time = %s,
activity_type = %s,
duration_min = %s,
kcal_active = %s,
kcal_resting = %s,
hr_avg = %s,
hr_max = %s,
distance_km = %s,
training_type_id = %s,
training_category = %s,
training_subcategory = %s
WHERE id = %s
""", (
row.get('End',''), wtype, duration_min,
kj(row.get('Aktive Energie (kJ)','')),
kj(row.get('Ruheeinträge (kJ)','')),
tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
tf(row.get('Max. Herzfrequenz (count/min)','')),
tf(row.get('Distanz (km)','')),
training_type_id, training_category, training_subcategory,
existing['id']
))
skipped += 1 # Count as skipped (not newly inserted)
else:
# Insert new entry
cur.execute("""INSERT INTO activity_log
(id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting,
hr_avg,hr_max,distance_km,source,training_type_id,training_category,training_subcategory,created)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'apple_health',%s,%s,%s,CURRENT_TIMESTAMP)""",
(str(uuid.uuid4()),pid,date,start,row.get('End',''),wtype,duration_min,
kj(row.get('Aktive Energie (kJ)','')),kj(row.get('Ruheeinträge (kJ)','')),
tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
tf(row.get('Max. Herzfrequenz (count/min)','')),
tf(row.get('Distanz (km)','')),
training_type_id,training_category,training_subcategory))
inserted+=1
except Exception as e:
logger.warning(f"Import row failed: {e}")
skipped+=1
return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"} return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"}

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

@ -0,0 +1,281 @@
"""
Admin Training Types Management - v9d Phase 1b
CRUD operations for training types with abilities mapping preparation.
"""
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from db import get_db, get_cursor, r2d
from auth import require_auth, require_admin
router = APIRouter(prefix="/api/admin/training-types", tags=["admin", "training-types"])
logger = logging.getLogger(__name__)
class TrainingTypeCreate(BaseModel):
category: str
subcategory: Optional[str] = None
name_de: str
name_en: str
icon: Optional[str] = None
description_de: Optional[str] = None
description_en: Optional[str] = None
sort_order: int = 0
abilities: Optional[dict] = None
class TrainingTypeUpdate(BaseModel):
category: Optional[str] = None
subcategory: Optional[str] = None
name_de: Optional[str] = None
name_en: Optional[str] = None
icon: Optional[str] = None
description_de: Optional[str] = None
description_en: Optional[str] = None
sort_order: Optional[int] = None
abilities: Optional[dict] = None
@router.get("")
def list_training_types_admin(session: dict = Depends(require_admin)):
"""
Get all training types for admin management.
Returns full details including abilities.
"""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT id, category, subcategory, name_de, name_en, icon,
description_de, description_en, sort_order, abilities,
created_at
FROM training_types
ORDER BY sort_order, category, subcategory
""")
rows = cur.fetchall()
return [r2d(r) for r in rows]
@router.get("/{type_id}")
def get_training_type(type_id: int, session: dict = Depends(require_admin)):
"""Get single training type by ID."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT id, category, subcategory, name_de, name_en, icon,
description_de, description_en, sort_order, abilities,
created_at
FROM training_types
WHERE id = %s
""", (type_id,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Training type not found")
return r2d(row)
@router.post("")
def create_training_type(data: TrainingTypeCreate, session: dict = Depends(require_admin)):
"""Create new training type."""
with get_db() as conn:
cur = get_cursor(conn)
# Convert abilities dict to JSONB
abilities_json = data.abilities if data.abilities else {}
cur.execute("""
INSERT INTO training_types
(category, subcategory, name_de, name_en, icon,
description_de, description_en, sort_order, abilities)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (
data.category,
data.subcategory,
data.name_de,
data.name_en,
data.icon,
data.description_de,
data.description_en,
data.sort_order,
abilities_json
))
new_id = cur.fetchone()['id']
logger.info(f"[ADMIN] Training type created: {new_id} - {data.name_de} ({data.category}/{data.subcategory})")
return {"id": new_id, "message": "Training type created"}
@router.put("/{type_id}")
def update_training_type(
type_id: int,
data: TrainingTypeUpdate,
session: dict = Depends(require_admin)
):
"""Update existing training type."""
with get_db() as conn:
cur = get_cursor(conn)
# Build update query dynamically
updates = []
values = []
if data.category is not None:
updates.append("category = %s")
values.append(data.category)
if data.subcategory is not None:
updates.append("subcategory = %s")
values.append(data.subcategory)
if data.name_de is not None:
updates.append("name_de = %s")
values.append(data.name_de)
if data.name_en is not None:
updates.append("name_en = %s")
values.append(data.name_en)
if data.icon is not None:
updates.append("icon = %s")
values.append(data.icon)
if data.description_de is not None:
updates.append("description_de = %s")
values.append(data.description_de)
if data.description_en is not None:
updates.append("description_en = %s")
values.append(data.description_en)
if data.sort_order is not None:
updates.append("sort_order = %s")
values.append(data.sort_order)
if data.abilities is not None:
updates.append("abilities = %s")
values.append(data.abilities)
if not updates:
raise HTTPException(400, "No fields to update")
values.append(type_id)
cur.execute(f"""
UPDATE training_types
SET {', '.join(updates)}
WHERE id = %s
""", values)
if cur.rowcount == 0:
raise HTTPException(404, "Training type not found")
logger.info(f"[ADMIN] Training type updated: {type_id}")
return {"id": type_id, "message": "Training type updated"}
@router.delete("/{type_id}")
def delete_training_type(type_id: int, session: dict = Depends(require_admin)):
"""
Delete training type.
WARNING: This will fail if any activities reference this type.
Consider adding a soft-delete or archive mechanism if needed.
"""
with get_db() as conn:
cur = get_cursor(conn)
# Check if any activities use this type
cur.execute("""
SELECT COUNT(*) as count
FROM activity_log
WHERE training_type_id = %s
""", (type_id,))
count = cur.fetchone()['count']
if count > 0:
raise HTTPException(
400,
f"Cannot delete: {count} activities are using this training type. "
"Please reassign or delete those activities first."
)
cur.execute("DELETE FROM training_types WHERE id = %s", (type_id,))
if cur.rowcount == 0:
raise HTTPException(404, "Training type not found")
logger.info(f"[ADMIN] Training type deleted: {type_id}")
return {"message": "Training type deleted"}
@router.get("/taxonomy/abilities")
def get_abilities_taxonomy(session: dict = Depends(require_auth)):
"""
Get abilities taxonomy for UI and AI analysis.
This defines the 5 dimensions of athletic development.
"""
taxonomy = {
"koordinativ": {
"name_de": "Koordinative Fähigkeiten",
"name_en": "Coordination Abilities",
"icon": "🎯",
"abilities": [
{"key": "orientierung", "name_de": "Orientierung", "name_en": "Orientation"},
{"key": "differenzierung", "name_de": "Differenzierung", "name_en": "Differentiation"},
{"key": "kopplung", "name_de": "Kopplung", "name_en": "Coupling"},
{"key": "gleichgewicht", "name_de": "Gleichgewicht", "name_en": "Balance"},
{"key": "rhythmus", "name_de": "Rhythmisierung", "name_en": "Rhythm"},
{"key": "reaktion", "name_de": "Reaktion", "name_en": "Reaction"},
{"key": "umstellung", "name_de": "Umstellung", "name_en": "Adaptation"}
]
},
"konditionell": {
"name_de": "Konditionelle Fähigkeiten",
"name_en": "Conditional Abilities",
"icon": "💪",
"abilities": [
{"key": "kraft", "name_de": "Kraft", "name_en": "Strength"},
{"key": "ausdauer", "name_de": "Ausdauer", "name_en": "Endurance"},
{"key": "schnelligkeit", "name_de": "Schnelligkeit", "name_en": "Speed"},
{"key": "flexibilitaet", "name_de": "Flexibilität", "name_en": "Flexibility"}
]
},
"kognitiv": {
"name_de": "Kognitive Fähigkeiten",
"name_en": "Cognitive Abilities",
"icon": "🧠",
"abilities": [
{"key": "konzentration", "name_de": "Konzentration", "name_en": "Concentration"},
{"key": "aufmerksamkeit", "name_de": "Aufmerksamkeit", "name_en": "Attention"},
{"key": "wahrnehmung", "name_de": "Wahrnehmung", "name_en": "Perception"},
{"key": "entscheidung", "name_de": "Entscheidungsfindung", "name_en": "Decision Making"}
]
},
"psychisch": {
"name_de": "Psychische Fähigkeiten",
"name_en": "Psychological Abilities",
"icon": "🎭",
"abilities": [
{"key": "motivation", "name_de": "Motivation", "name_en": "Motivation"},
{"key": "willenskraft", "name_de": "Willenskraft", "name_en": "Willpower"},
{"key": "stressresistenz", "name_de": "Stressresistenz", "name_en": "Stress Resistance"},
{"key": "selbstvertrauen", "name_de": "Selbstvertrauen", "name_en": "Self-Confidence"}
]
},
"taktisch": {
"name_de": "Taktische Fähigkeiten",
"name_en": "Tactical Abilities",
"icon": "♟️",
"abilities": [
{"key": "timing", "name_de": "Timing", "name_en": "Timing"},
{"key": "strategie", "name_de": "Strategie", "name_en": "Strategy"},
{"key": "antizipation", "name_de": "Antizipation", "name_en": "Anticipation"},
{"key": "situationsanalyse", "name_de": "Situationsanalyse", "name_en": "Situation Analysis"}
]
}
}
return taxonomy

View File

@ -0,0 +1,129 @@
"""
Training Types API - v9d
Provides hierarchical list of training categories and subcategories
for activity classification.
"""
from fastapi import APIRouter, Depends
from db import get_db, get_cursor
from auth import require_auth
router = APIRouter(prefix="/api/training-types", tags=["training-types"])
@router.get("")
def list_training_types(session: dict = Depends(require_auth)):
"""
Get all training types, grouped by category.
Returns hierarchical structure:
{
"cardio": [
{"id": 1, "subcategory": "running", "name_de": "Laufen", ...},
...
],
"strength": [...],
...
}
"""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT id, category, subcategory, name_de, name_en, icon, sort_order
FROM training_types
ORDER BY sort_order, category, subcategory
""")
rows = cur.fetchall()
# Group by category
grouped = {}
for row in rows:
cat = row['category']
if cat not in grouped:
grouped[cat] = []
grouped[cat].append({
'id': row['id'],
'category': row['category'],
'subcategory': row['subcategory'],
'name_de': row['name_de'],
'name_en': row['name_en'],
'icon': row['icon'],
'sort_order': row['sort_order']
})
return grouped
@router.get("/flat")
def list_training_types_flat(session: dict = Depends(require_auth)):
"""
Get all training types as flat list (for simple dropdown).
"""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT id, category, subcategory, name_de, name_en, icon
FROM training_types
ORDER BY sort_order
""")
rows = cur.fetchall()
return [dict(row) for row in rows]
@router.get("/categories")
def list_categories(session: dict = Depends(require_auth)):
"""
Get list of unique categories with metadata.
"""
categories = {
'cardio': {
'name_de': 'Cardio (Ausdauer)',
'name_en': 'Cardio (Endurance)',
'icon': '❤️',
'color': '#EF4444'
},
'strength': {
'name_de': 'Kraft',
'name_en': 'Strength',
'icon': '💪',
'color': '#3B82F6'
},
'hiit': {
'name_de': 'Schnellkraft / HIIT',
'name_en': 'Power / HIIT',
'icon': '🔥',
'color': '#F59E0B'
},
'martial_arts': {
'name_de': 'Kampfsport',
'name_en': 'Martial Arts',
'icon': '🥋',
'color': '#8B5CF6'
},
'mobility': {
'name_de': 'Mobility & Dehnung',
'name_en': 'Mobility & Stretching',
'icon': '🧘',
'color': '#10B981'
},
'recovery': {
'name_de': 'Erholung (aktiv)',
'name_en': 'Recovery (active)',
'icon': '💆',
'color': '#6B7280'
},
'mind': {
'name_de': 'Geist & Meditation',
'name_en': 'Mind & Meditation',
'icon': '🧘‍♂️',
'color': '#A78BFA'
},
'other': {
'name_de': 'Sonstiges',
'name_en': 'Other',
'icon': '📝',
'color': '#9CA3AF'
}
}
return categories

View File

@ -1,6 +1,6 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { BrowserRouter, Routes, Route, NavLink, useNavigate } from 'react-router-dom' import { BrowserRouter, Routes, Route, NavLink, useNavigate } from 'react-router-dom'
import { LayoutDashboard, PlusSquare, TrendingUp, BarChart2, Settings } from 'lucide-react' import { LayoutDashboard, PlusSquare, TrendingUp, BarChart2, Settings, LogOut } from 'lucide-react'
import { ProfileProvider, useProfile } from './context/ProfileContext' import { ProfileProvider, useProfile } from './context/ProfileContext'
import { AuthProvider, useAuth } from './context/AuthContext' import { AuthProvider, useAuth } from './context/AuthContext'
import { setProfileId } from './utils/api' import { setProfileId } from './utils/api'
@ -27,6 +27,8 @@ import AdminFeaturesPage from './pages/AdminFeaturesPage'
import AdminTiersPage from './pages/AdminTiersPage' import AdminTiersPage from './pages/AdminTiersPage'
import AdminCouponsPage from './pages/AdminCouponsPage' import AdminCouponsPage from './pages/AdminCouponsPage'
import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage' import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage'
import AdminTrainingTypesPage from './pages/AdminTrainingTypesPage'
import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage'
import SubscriptionPage from './pages/SubscriptionPage' import SubscriptionPage from './pages/SubscriptionPage'
import './app.css' import './app.css'
@ -50,10 +52,17 @@ function Nav() {
} }
function AppShell() { function AppShell() {
const { session, loading: authLoading, needsSetup } = useAuth() const { session, loading: authLoading, needsSetup, logout } = useAuth()
const { activeProfile, loading: profileLoading } = useProfile() const { activeProfile, loading: profileLoading } = useProfile()
const nav = useNavigate() const nav = useNavigate()
const handleLogout = () => {
if (confirm('Wirklich abmelden?')) {
logout()
window.location.href = '/'
}
}
useEffect(()=>{ useEffect(()=>{
if (session?.profile_id) { if (session?.profile_id) {
setProfileId(session.profile_id) setProfileId(session.profile_id)
@ -119,12 +128,32 @@ function AppShell() {
<div className="app-shell"> <div className="app-shell">
<header className="app-header"> <header className="app-header">
<span className="app-logo">Mitai Jinkendo</span> <span className="app-logo">Mitai Jinkendo</span>
<NavLink to="/settings" style={{textDecoration:'none'}}> <div style={{display:'flex', gap:12, alignItems:'center'}}>
{activeProfile <button
? <Avatar profile={activeProfile} size={30}/> onClick={handleLogout}
: <div style={{width:30,height:30,borderRadius:'50%',background:'var(--accent)'}}/> title="Abmelden"
} style={{
</NavLink> background:'none',
border:'none',
cursor:'pointer',
padding:6,
display:'flex',
alignItems:'center',
color:'var(--text2)',
transition:'color 0.15s'
}}
onMouseEnter={e => e.currentTarget.style.color = '#D85A30'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text2)'}
>
<LogOut size={18}/>
</button>
<NavLink to="/settings" style={{textDecoration:'none'}}>
{activeProfile
? <Avatar profile={activeProfile} size={30}/>
: <div style={{width:30,height:30,borderRadius:'50%',background:'var(--accent)'}}/>
}
</NavLink>
</div>
</header> </header>
<main className="app-main"> <main className="app-main">
<Routes> <Routes>
@ -145,6 +174,8 @@ function AppShell() {
<Route path="/admin/tiers" element={<AdminTiersPage/>}/> <Route path="/admin/tiers" element={<AdminTiersPage/>}/>
<Route path="/admin/coupons" element={<AdminCouponsPage/>}/> <Route path="/admin/coupons" element={<AdminCouponsPage/>}/>
<Route path="/admin/user-restrictions" element={<AdminUserRestrictionsPage/>}/> <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/>}/> <Route path="/subscription" element={<SubscriptionPage/>}/>
</Routes> </Routes>
</main> </main>

View File

@ -0,0 +1,191 @@
import { useState, useEffect } from 'react'
import { api } from '../utils/api'
import TrainingTypeSelect from './TrainingTypeSelect'
/**
* BulkCategorize - UI for categorizing existing activities without training type
*
* Shows uncategorized activities grouped by activity_type,
* allows bulk assignment of training type to all activities of same type.
*/
export default function BulkCategorize({ onComplete }) {
const [uncategorized, setUncategorized] = useState([])
const [loading, setLoading] = useState(true)
const [assignments, setAssignments] = useState({})
const [saving, setSaving] = useState(null)
useEffect(() => {
loadUncategorized()
}, [])
const loadUncategorized = () => {
setLoading(true)
api.listUncategorizedActivities()
.then(data => {
setUncategorized(data)
setLoading(false)
})
.catch(err => {
console.error('Failed to load uncategorized activities:', err)
setLoading(false)
})
}
const handleAssignment = (activityType, typeId, category, subcategory) => {
setAssignments(prev => ({
...prev,
[activityType]: {
training_type_id: typeId,
training_category: category,
training_subcategory: subcategory
}
}))
}
const handleSave = async (activityType) => {
const assignment = assignments[activityType]
if (!assignment || !assignment.training_type_id) {
alert('Bitte wähle einen Trainingstyp aus')
return
}
setSaving(activityType)
try {
const result = await api.bulkCategorizeActivities({
activity_type: activityType,
...assignment
})
// Remove from list
setUncategorized(prev => prev.filter(u => u.activity_type !== activityType))
setAssignments(prev => {
const newAssignments = { ...prev }
delete newAssignments[activityType]
return newAssignments
})
// Show success message
console.log(`${result.updated} activities categorized`)
} catch (err) {
console.error('Failed to categorize:', err)
alert('Kategorisierung fehlgeschlagen: ' + err.message)
} finally {
setSaving(null)
}
}
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 20 }}>
<div className="spinner" style={{ width: 24, height: 24, margin: '0 auto' }} />
</div>
)
}
if (uncategorized.length === 0) {
return (
<div style={{
textAlign: 'center',
padding: 40,
color: 'var(--text3)',
fontSize: 14
}}>
<div style={{ fontSize: 32, marginBottom: 8 }}></div>
<div>Alle Aktivitäten sind kategorisiert</div>
{onComplete && (
<button
onClick={onComplete}
className="btn btn-secondary"
style={{ marginTop: 16 }}
>
Schließen
</button>
)}
</div>
)
}
return (
<div style={{ maxWidth: 600, margin: '0 auto' }}>
<div style={{
marginBottom: 20,
padding: 16,
background: 'var(--surface)',
borderRadius: 8,
fontSize: 13,
color: 'var(--text2)'
}}>
<strong style={{ color: 'var(--text1)' }}>
{uncategorized.reduce((sum, u) => sum + u.count, 0)} Aktivitäten
</strong> ohne Trainingstyp gefunden. Weise jedem Aktivitätstyp einen Trainingstyp zu.
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{uncategorized.map(item => (
<div
key={item.activity_type}
className="card"
style={{ padding: 16 }}
>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 12
}}>
<div>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 4 }}>
{item.activity_type}
</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
{item.count} Einheiten
{item.first_date && item.last_date && (
<> · {item.first_date} bis {item.last_date}</>
)}
</div>
</div>
</div>
<TrainingTypeSelect
value={assignments[item.activity_type]?.training_type_id || null}
onChange={(typeId, category, subcategory) =>
handleAssignment(item.activity_type, typeId, category, subcategory)
}
required={false}
/>
<button
onClick={() => handleSave(item.activity_type)}
disabled={
!assignments[item.activity_type]?.training_type_id ||
saving === item.activity_type
}
className="btn btn-primary btn-full"
style={{ marginTop: 12 }}
>
{saving === item.activity_type ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'center' }}>
<div className="spinner" style={{ width: 14, height: 14 }} />
Speichere...
</div>
) : (
`${item.count} Einheiten kategorisieren`
)}
</button>
</div>
))}
</div>
{onComplete && (
<button
onClick={onComplete}
className="btn btn-secondary btn-full"
style={{ marginTop: 16 }}
>
Später fortsetzen
</button>
)}
</div>
)
}

View File

@ -0,0 +1,120 @@
import { useState, useEffect } from 'react'
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from 'recharts'
import { api } from '../utils/api'
/**
* TrainingTypeDistribution - Pie chart showing activity distribution by type
*
* @param {number} days - Number of days to analyze (default: 28)
*/
export default function TrainingTypeDistribution({ days = 28 }) {
const [data, setData] = useState([])
const [categories, setCategories] = useState({})
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([
api.listActivity(days),
api.getTrainingCategories()
]).then(([activities, cats]) => {
setCategories(cats)
// Group by training_category
const grouped = {}
activities.forEach(act => {
const cat = act.training_category || 'other'
if (!grouped[cat]) grouped[cat] = 0
grouped[cat]++
})
// Convert to chart data
const chartData = Object.entries(grouped).map(([cat, count]) => ({
name: cats[cat]?.name_de || 'Sonstiges',
value: count,
color: cats[cat]?.color || '#9CA3AF',
icon: cats[cat]?.icon || '📝'
}))
setData(chartData.sort((a, b) => b.value - a.value))
setLoading(false)
}).catch(err => {
console.error('Failed to load training type distribution:', err)
setLoading(false)
})
}, [days])
if (loading) {
return (
<div style={{textAlign:'center', padding:20}}>
<div className="spinner" style={{width:24, height:24, margin:'0 auto'}}/>
</div>
)
}
if (data.length === 0) {
return (
<div style={{textAlign:'center', padding:20, color:'var(--text3)', fontSize:13}}>
Keine Aktivitäten in den letzten {days} Tagen
</div>
)
}
const total = data.reduce((sum, d) => sum + d.value, 0)
return (
<div>
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={2}
dataKey="value"
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
formatter={(value) => `${value} Einheiten (${Math.round(value/total*100)}%)`}
contentStyle={{
background:'var(--surface)',
border:'1px solid var(--border)',
borderRadius:8,
fontSize:12
}}
/>
</PieChart>
</ResponsiveContainer>
{/* Legend */}
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap:8, marginTop:12}}>
{data.map((entry, i) => (
<div key={i} style={{display:'flex', alignItems:'center', gap:6, fontSize:12}}>
<div style={{
width:12, height:12, borderRadius:'50%',
background:entry.color, flexShrink:0
}}/>
<span style={{color:'var(--text2)'}}>
{entry.icon} {entry.name}
</span>
<span style={{marginLeft:'auto', fontWeight:600, color:'var(--text1)'}}>
{entry.value}
</span>
</div>
))}
</div>
<div style={{
marginTop:12, padding:'8px 12px',
background:'var(--surface)', borderRadius:8,
fontSize:12, color:'var(--text3)', textAlign:'center'
}}>
Gesamt: <strong style={{color:'var(--text1)'}}>{total}</strong> Einheiten in {days} Tagen
</div>
</div>
)
}

View File

@ -0,0 +1,114 @@
import { useState, useEffect } from 'react'
import { api } from '../utils/api'
/**
* TrainingTypeSelect - Two-level dropdown for training type selection
*
* @param {number|null} value - Selected training_type_id
* @param {function} onChange - Callback (training_type_id, category, subcategory) => void
* @param {boolean} required - Is selection required?
*/
export default function TrainingTypeSelect({ value, onChange, required = false }) {
const [types, setTypes] = useState({}) // Grouped by category
const [categories, setCategories] = useState({}) // Category metadata
const [selectedCategory, setSelectedCategory] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([
api.listTrainingTypes(),
api.getTrainingCategories()
]).then(([typesData, catsData]) => {
setTypes(typesData)
setCategories(catsData)
// If value is set, find and select the category
if (value) {
for (const cat in typesData) {
const found = typesData[cat].find(t => t.id === value)
if (found) {
setSelectedCategory(cat)
break
}
}
}
setLoading(false)
}).catch(err => {
console.error('Failed to load training types:', err)
setLoading(false)
})
}, [value])
const handleCategoryChange = (cat) => {
setSelectedCategory(cat)
// Auto-select first subcategory if available
if (types[cat] && types[cat].length > 0) {
const firstType = types[cat][0]
onChange(firstType.id, firstType.category, firstType.subcategory)
} else {
onChange(null, null, null)
}
}
const handleTypeChange = (typeId) => {
const type = Object.values(types)
.flat()
.find(t => t.id === parseInt(typeId))
if (type) {
onChange(type.id, type.category, type.subcategory)
}
}
if (loading) {
return <div style={{fontSize:13, color:'var(--text3)'}}>Lade Trainingstypen...</div>
}
const availableCategories = Object.keys(categories)
const availableTypes = selectedCategory ? (types[selectedCategory] || []) : []
return (
<div style={{display:'flex', gap:8, flexDirection:'column'}}>
{/* Category dropdown */}
<div>
<label className="form-label">Kategorie</label>
<select
className="form-input"
value={selectedCategory}
onChange={(e) => handleCategoryChange(e.target.value)}
required={required}
style={{width:'100%', boxSizing:'border-box'}}
>
<option value="">-- Wähle Kategorie --</option>
{availableCategories.map(cat => (
<option key={cat} value={cat}>
{categories[cat].icon} {categories[cat].name_de}
</option>
))}
</select>
</div>
{/* Subcategory dropdown (conditional) */}
{selectedCategory && availableTypes.length > 0 && (
<div>
<label className="form-label">Untertyp</label>
<select
className="form-input"
value={value || ''}
onChange={(e) => handleTypeChange(e.target.value)}
required={required}
style={{width:'100%', boxSizing:'border-box'}}
>
<option value="">-- Wähle Untertyp --</option>
{availableTypes.map(type => (
<option key={type.id} value={type.id}>
{type.icon} {type.name_de}
</option>
))}
</select>
</div>
)}
</div>
)
}

View File

@ -64,8 +64,8 @@ export default function TrialBanner({ profile }) {
</div> </div>
</div> </div>
<Link <a
to="/settings?tab=subscription" href="mailto:mitai@jinkendo.de?subject=Abo-Anfrage%20für%20Mitai%20Jinkendo&body=Hallo,%0A%0Aich%20möchte%20gerne%20ein%20Abo%20für%20Mitai%20Jinkendo%20abschließen.%0A%0AMein%20Profil:%20"
style={{ style={{
padding: '10px 20px', padding: '10px 20px',
borderRadius: 8, borderRadius: 8,
@ -76,11 +76,12 @@ export default function TrialBanner({ profile }) {
textDecoration: 'none', textDecoration: 'none',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
border: 'none', border: 'none',
cursor: 'pointer' cursor: 'pointer',
display: 'inline-block'
}} }}
> >
{isUrgent ? 'Jetzt upgraden' : 'Abo wählen'} {isUrgent ? 'Kontakt aufnehmen' : 'Abo anfragen'}
</Link> </a>
</div> </div>
) )
} }

View File

@ -3,6 +3,8 @@ import { Upload, Pencil, Trash2, Check, X, CheckCircle } from 'lucide-react'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts' import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
import { api } from '../utils/api' import { api } from '../utils/api'
import UsageBadge from '../components/UsageBadge' import UsageBadge from '../components/UsageBadge'
import TrainingTypeSelect from '../components/TrainingTypeSelect'
import BulkCategorize from '../components/BulkCategorize'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import 'dayjs/locale/de' import 'dayjs/locale/de'
dayjs.locale('de') dayjs.locale('de')
@ -18,7 +20,10 @@ function empty() {
date: dayjs().format('YYYY-MM-DD'), date: dayjs().format('YYYY-MM-DD'),
activity_type: 'Traditionelles Krafttraining', activity_type: 'Traditionelles Krafttraining',
duration_min: '', kcal_active: '', duration_min: '', kcal_active: '',
hr_avg: '', hr_max: '', rpe: '', notes: '' hr_avg: '', hr_max: '', rpe: '', notes: '',
training_type_id: null,
training_category: null,
training_subcategory: null
} }
} }
@ -89,11 +94,19 @@ function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', sav
<input type="date" className="form-input" style={{width:140}} value={form.date} onChange={e=>set('date',e.target.value)}/> <input type="date" className="form-input" style={{width:140}} value={form.date} onChange={e=>set('date',e.target.value)}/>
<span className="form-unit"/> <span className="form-unit"/>
</div> </div>
<div className="form-row"> <div style={{marginBottom:12}}>
<label className="form-label">Trainingsart</label> <TrainingTypeSelect
<select className="form-select" value={form.activity_type} onChange={e=>set('activity_type',e.target.value)}> value={form.training_type_id}
{ACTIVITY_TYPES.map(t=><option key={t} value={t}>{t}</option>)} onChange={(typeId, category, subcategory) => {
</select> setForm(f => ({
...f,
training_type_id: typeId,
training_category: category,
training_subcategory: subcategory
}))
}}
required={false}
/>
</div> </div>
<div className="form-row"> <div className="form-row">
<label className="form-label">Dauer</label> <label className="form-label">Dauer</label>
@ -167,6 +180,7 @@ export default function ActivityPage() {
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [activityUsage, setActivityUsage] = useState(null) // Phase 4: Usage badge const [activityUsage, setActivityUsage] = useState(null) // Phase 4: Usage badge
const [categories, setCategories] = useState({}) // v9d: Training categories
const load = async () => { const load = async () => {
const [e, s] = await Promise.all([api.listActivity(), api.activityStats()]) const [e, s] = await Promise.all([api.listActivity(), api.activityStats()])
@ -183,6 +197,7 @@ export default function ActivityPage() {
useEffect(()=>{ useEffect(()=>{
load() load()
loadUsage() loadUsage()
api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err))
},[]) },[])
const handleSave = async () => { const handleSave = async () => {
@ -246,6 +261,7 @@ export default function ActivityPage() {
<button className={'tab'+(tab==='list'?' active':'')} onClick={()=>setTab('list')}>Verlauf</button> <button className={'tab'+(tab==='list'?' active':'')} onClick={()=>setTab('list')}>Verlauf</button>
<button className={'tab'+(tab==='add'?' active':'')} onClick={()=>setTab('add')}>+ Manuell</button> <button className={'tab'+(tab==='add'?' active':'')} onClick={()=>setTab('add')}>+ Manuell</button>
<button className={'tab'+(tab==='import'?' active':'')} onClick={()=>setTab('import')}>Import</button> <button className={'tab'+(tab==='import'?' active':'')} onClick={()=>setTab('import')}>Import</button>
<button className={'tab'+(tab==='categorize'?' active':'')} onClick={()=>setTab('categorize')}>Kategorisieren</button>
<button className={'tab'+(tab==='stats'?' active':'')} onClick={()=>setTab('stats')}>Statistik</button> <button className={'tab'+(tab==='stats'?' active':'')} onClick={()=>setTab('stats')}>Statistik</button>
</div> </div>
@ -267,6 +283,13 @@ export default function ActivityPage() {
{tab==='import' && <ImportPanel onImported={load}/>} {tab==='import' && <ImportPanel onImported={load}/>}
{tab==='categorize' && (
<div className="card section-gap">
<div className="card-title">🏷 Aktivitäten kategorisieren</div>
<BulkCategorize onComplete={() => { load(); setTab('list'); }} />
</div>
)}
{tab==='add' && ( {tab==='add' && (
<div className="card section-gap"> <div className="card section-gap">
<div className="card-title badge-container-right"> <div className="card-title badge-container-right">
@ -330,7 +353,26 @@ export default function ActivityPage() {
<div> <div>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'flex-start'}}> <div style={{display:'flex',justifyContent:'space-between',alignItems:'flex-start'}}>
<div style={{flex:1}}> <div style={{flex:1}}>
<div style={{fontSize:14,fontWeight:600}}>{e.activity_type}</div> <div style={{display:'flex',alignItems:'center',gap:6,marginBottom:2}}>
<div style={{fontSize:14,fontWeight:600}}>{e.activity_type}</div>
{e.training_category && categories[e.training_category] && (
<div style={{
display:'inline-flex',
alignItems:'center',
gap:3,
padding:'2px 6px',
background:categories[e.training_category].color + '22',
border:`1px solid ${categories[e.training_category].color}`,
borderRadius:4,
fontSize:10,
fontWeight:600,
color:categories[e.training_category].color
}}>
<span>{categories[e.training_category].icon}</span>
<span>{categories[e.training_category].name_de}</span>
</div>
)}
</div>
<div style={{fontSize:11,color:'var(--text3)',marginBottom:4}}> <div style={{fontSize:11,color:'var(--text3)',marginBottom:4}}>
{dayjs(e.date).format('dd, DD. MMMM YYYY')} {dayjs(e.date).format('dd, DD. MMMM YYYY')}
{e.start_time && e.start_time.length>10 && ` · ${e.start_time.slice(11,16)}`} {e.start_time && e.start_time.length>10 && ` · ${e.start_time.slice(11,16)}`}

View File

@ -0,0 +1,446 @@
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 */}
<button
onClick={startCreate}
className="btn btn-primary btn-full"
style={{ marginBottom: 16 }}
>
<Plus size={16} /> Neues Mapping anlegen
</button>
{/* New mapping form (only shown when creating) */}
{editingId === 'new' && formData && (
<div className="card" style={{ padding: 16, marginBottom: 16, border: '2px solid var(--accent)' }}>
<div style={{ fontWeight: 600, marginBottom: 12 }}> Neues Mapping</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"
style={{ width: '100%' }}
autoFocus
/>
<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 with inline editing */}
{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 => {
const isEditing = editingId === mapping.id
return (
<div
key={mapping.id}
className="card"
style={{
padding: 12,
border: isEditing ? '2px solid var(--accent)' : undefined
}}
>
{isEditing && formData ? (
/* Inline edit form */
<div>
<div style={{ fontWeight: 600, marginBottom: 12, color: 'var(--accent)' }}>
Mapping bearbeiten
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<div className="form-label">Activity Type (nicht änderbar)</div>
<input
className="form-input"
value={formData.activity_type}
disabled
style={{ width: '100%', background: 'var(--surface2)' }}
/>
</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>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button
onClick={handleSave}
disabled={saving}
className="btn btn-primary"
style={{ flex: 1 }}
>
{saving ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'center' }}>
<div className="spinner" style={{ width: 14, height: 14 }} />
Speichere...
</div>
) : (
<>
<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>
) : (
/* Normal view */
<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

@ -424,6 +424,28 @@ export default function AdminPanel() {
</Link> </Link>
</div> </div>
</div> </div>
{/* v9d Training Types Management */}
<div className="card section-gap" style={{marginTop:16}}>
<div style={{fontWeight:700,fontSize:14,marginBottom:12,display:'flex',alignItems:'center',gap:6}}>
<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 Activity-Mappings (lernendes System).
</div>
<div style={{display:'grid',gap:8}}>
<Link to="/admin/training-types">
<button className="btn btn-secondary btn-full">
🏋 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> </div>
) )
} }

View File

@ -0,0 +1,390 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Pencil, Trash2, Plus, Save, X, ArrowLeft } from 'lucide-react'
import { api } from '../utils/api'
/**
* AdminTrainingTypesPage - CRUD for training types
* v9d Phase 1b - Basic CRUD without abilities mapping
*/
export default function AdminTrainingTypesPage() {
const nav = useNavigate()
const [types, setTypes] = useState([])
const [categories, setCategories] = useState({})
const [loading, setLoading] = useState(true)
const [editingId, setEditingId] = useState(null)
const [formData, setFormData] = useState(null)
const [error, setError] = useState(null)
const [saving, setSaving] = useState(false)
useEffect(() => {
load()
}, [])
const load = () => {
setLoading(true)
Promise.all([
api.adminListTrainingTypes(),
api.getTrainingCategories()
]).then(([typesData, catsData]) => {
setTypes(typesData)
setCategories(catsData)
setLoading(false)
}).catch(err => {
console.error('Failed to load training types:', err)
setError(err.message)
setLoading(false)
})
}
const startCreate = () => {
setFormData({
category: 'cardio',
subcategory: '',
name_de: '',
name_en: '',
icon: '',
description_de: '',
description_en: '',
sort_order: 0
})
setEditingId('new')
}
const startEdit = (type) => {
setFormData({
category: type.category,
subcategory: type.subcategory || '',
name_de: type.name_de,
name_en: type.name_en,
icon: type.icon || '',
description_de: type.description_de || '',
description_en: type.description_en || '',
sort_order: type.sort_order
})
setEditingId(type.id)
}
const cancelEdit = () => {
setEditingId(null)
setFormData(null)
setError(null)
}
const handleSave = async () => {
if (!formData.name_de || !formData.name_en) {
setError('Name (DE) und Name (EN) sind Pflichtfelder')
return
}
setSaving(true)
setError(null)
try {
if (editingId === 'new') {
await api.adminCreateTrainingType(formData)
} else {
await api.adminUpdateTrainingType(editingId, formData)
}
await load()
cancelEdit()
} catch (err) {
console.error('Save failed:', err)
setError(err.message)
} finally {
setSaving(false)
}
}
const handleDelete = async (id, name) => {
if (!confirm(`Trainingstyp "${name}" wirklich löschen?\n\nHinweis: Löschen ist nur möglich wenn keine Aktivitäten diesen Typ verwenden.`)) {
return
}
try {
await api.adminDeleteTrainingType(id)
await load()
} catch (err) {
alert('Löschen fehlgeschlagen: ' + err.message)
}
}
// Group by category
const grouped = {}
types.forEach(type => {
if (!grouped[type.category]) {
grouped[type.category] = []
}
grouped[type.category].push(type)
})
if (loading) {
return (
<div style={{ padding: 20, textAlign: 'center' }}>
<div className="spinner" style={{ width: 32, height: 32, margin: '0 auto' }} />
</div>
)
}
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 }}>Trainingstypen verwalten</h1>
</div>
{error && (
<div className="card" style={{ padding: 12, marginBottom: 16, background: '#FCEBEB', color: '#D85A30' }}>
{error}
</div>
)}
{/* Create new button */}
{!editingId && (
<button
onClick={startCreate}
className="btn btn-primary btn-full"
style={{ marginBottom: 16 }}
>
<Plus size={16} /> Neuen Trainingstyp anlegen
</button>
)}
{/* Edit form */}
{editingId && formData && (
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div style={{ fontWeight: 600, marginBottom: 12 }}>
{editingId === 'new' ? ' Neuer Trainingstyp' : '✏️ Trainingstyp bearbeiten'}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<div className="form-label">Kategorie *</div>
<select
className="form-input"
value={formData.category}
onChange={e => setFormData({ ...formData, category: e.target.value })}
style={{ width: '100%' }}
>
{Object.keys(categories).map(cat => (
<option key={cat} value={cat}>
{categories[cat].icon} {categories[cat].name_de}
</option>
))}
</select>
</div>
<div>
<div className="form-label">Subkategorie</div>
<input
className="form-input"
value={formData.subcategory}
onChange={e => setFormData({ ...formData, subcategory: e.target.value })}
placeholder="z.B. running, hypertrophy, meditation"
style={{ width: '100%' }}
/>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
Kleingeschrieben, ohne Leerzeichen, eindeutig
</div>
</div>
<div>
<div className="form-label">Name (Deutsch) *</div>
<input
className="form-input"
value={formData.name_de}
onChange={e => setFormData({ ...formData, name_de: e.target.value })}
placeholder="z.B. Laufen"
style={{ width: '100%' }}
/>
</div>
<div>
<div className="form-label">Name (English) *</div>
<input
className="form-input"
value={formData.name_en}
onChange={e => setFormData({ ...formData, name_en: e.target.value })}
placeholder="e.g. Running"
style={{ width: '100%' }}
/>
</div>
<div>
<div className="form-label">Icon (Emoji)</div>
<input
className="form-input"
value={formData.icon}
onChange={e => setFormData({ ...formData, icon: e.target.value })}
placeholder="🏃"
maxLength={10}
style={{ width: '100%' }}
/>
</div>
<div>
<div className="form-label">Sortierung</div>
<input
type="number"
className="form-input"
value={formData.sort_order}
onChange={e => setFormData({ ...formData, sort_order: parseInt(e.target.value) })}
style={{ width: '100%' }}
/>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
Niedrigere Zahlen werden zuerst angezeigt
</div>
</div>
<div>
<div className="form-label">Beschreibung (Deutsch)</div>
<textarea
className="form-input"
value={formData.description_de}
onChange={e => setFormData({ ...formData, description_de: e.target.value })}
placeholder="Optional: Beschreibung für KI-Analyse"
rows={4}
style={{ width: '100%', resize: 'vertical' }}
/>
</div>
<div>
<div className="form-label">Beschreibung (English)</div>
<textarea
className="form-input"
value={formData.description_en}
onChange={e => setFormData({ ...formData, description_en: e.target.value })}
placeholder="Optional: Description for AI analysis"
rows={4}
style={{ width: '100%', resize: 'vertical' }}
/>
</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 grouped by category */}
{Object.entries(grouped).sort((a, b) => {
const orderA = categories[a[0]]?.sort_order || 999
const orderB = categories[b[0]]?.sort_order || 999
return orderA - orderB
}).map(([cat, catTypes]) => (
<div key={cat} className="card" style={{ padding: 16, marginBottom: 12 }}>
<div style={{
fontWeight: 600,
fontSize: 14,
marginBottom: 12,
color: categories[cat]?.color || 'var(--text1)'
}}>
{categories[cat]?.icon} {categories[cat]?.name_de}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{catTypes.sort((a, b) => a.sort_order - b.sort_order).map(type => (
<div
key={type.id}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: 8,
background: 'var(--surface)',
borderRadius: 6
}}
>
<div style={{ fontSize: 18 }}>{type.icon}</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>
{type.name_de} <span style={{ color: 'var(--text3)' }}>/ {type.name_en}</span>
</div>
{type.subcategory && (
<div style={{ fontSize: 11, color: 'var(--text3)' }}>
Subkategorie: {type.subcategory}
</div>
)}
</div>
<button
onClick={() => startEdit(type)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 6,
color: 'var(--accent)'
}}
title="Bearbeiten"
>
<Pencil size={16} />
</button>
<button
onClick={() => handleDelete(type.id, type.name_de)}
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>Hinweis:</strong> Das Fähigkeiten-Mapping (koordinativ, konditionell, etc.) wird in einer späteren Version hinzugefügt.
</div>
</div>
)
}

View File

@ -10,6 +10,7 @@ import { useProfile } from '../context/ProfileContext'
import { getBfCategory } from '../utils/calc' import { getBfCategory } from '../utils/calc'
import TrialBanner from '../components/TrialBanner' import TrialBanner from '../components/TrialBanner'
import EmailVerificationBanner from '../components/EmailVerificationBanner' import EmailVerificationBanner from '../components/EmailVerificationBanner'
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret' import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
import Markdown from '../utils/Markdown' import Markdown from '../utils/Markdown'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@ -470,6 +471,20 @@ export default function Dashboard() {
)} )}
</div> </div>
{/* Training Type Distribution */}
{activities.length > 0 && (
<div className="card section-gap" style={{marginBottom:16}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:12}}>
<div style={{fontWeight:600,fontSize:13}}>🏋 Trainingstyp-Verteilung</div>
<button style={{background:'none',border:'none',fontSize:12,color:'var(--accent)',cursor:'pointer'}}
onClick={()=>nav('/activity')}>
Details
</button>
</div>
<TrainingTypeDistribution days={28} />
</div>
)}
{/* Latest AI insight */} {/* Latest AI insight */}
<div className="card section-gap"> <div className="card section-gap">
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:8}}> <div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:8}}>

View File

@ -10,6 +10,7 @@ import { api } from '../utils/api'
import { getBfCategory } from '../utils/calc' import { getBfCategory } from '../utils/calc'
import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret' import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
import Markdown from '../utils/Markdown' import Markdown from '../utils/Markdown'
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import 'dayjs/locale/de' import 'dayjs/locale/de'
dayjs.locale('de') dayjs.locale('de')
@ -653,6 +654,10 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA
</div> </div>
))} ))}
</div> </div>
<div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>Trainingstyp-Verteilung</div>
<TrainingTypeDistribution days={period === 9999 ? 365 : period} />
</div>
<div style={{marginBottom:12}}> <div style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BEWERTUNG</div> <div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BEWERTUNG</div>
{actRules.map((item,i)=><RuleCard key={i} item={item}/>)} {actRules.map((item,i)=><RuleCard key={i} item={item}/>)}

View File

@ -56,6 +56,8 @@ export const api = {
updateActivity: (id,d) => req(`/activity/${id}`,jput(d)), updateActivity: (id,d) => req(`/activity/${id}`,jput(d)),
deleteActivity: (id) => req(`/activity/${id}`,{method:'DELETE'}), deleteActivity: (id) => req(`/activity/${id}`,{method:'DELETE'}),
activityStats: () => req('/activity/stats'), activityStats: () => req('/activity/stats'),
listUncategorizedActivities: () => req('/activity/uncategorized'),
bulkCategorizeActivities: (d) => req('/activity/bulk-categorize', json(d)),
importActivityCsv: async(file)=>{ importActivityCsv: async(file)=>{
const fd=new FormData();fd.append('file',file) const fd=new FormData();fd.append('file',file)
const r=await fetch(`${BASE}/activity/import-csv`,{method:'POST',body:fd,headers:hdrs()}) const r=await fetch(`${BASE}/activity/import-csv`,{method:'POST',body:fd,headers:hdrs()})
@ -189,4 +191,25 @@ export const api = {
createAccessGrant: (d) => req('/access-grants',json(d)), createAccessGrant: (d) => req('/access-grants',json(d)),
updateAccessGrant: (id,d) => req(`/access-grants/${id}`,jput(d)), updateAccessGrant: (id,d) => req(`/access-grants/${id}`,jput(d)),
revokeAccessGrant: (id) => req(`/access-grants/${id}`,{method:'DELETE'}), revokeAccessGrant: (id) => req(`/access-grants/${id}`,{method:'DELETE'}),
// v9d: Training Types
listTrainingTypes: () => req('/training-types'), // Grouped by category
listTrainingTypesFlat:() => req('/training-types/flat'), // Flat list
getTrainingCategories:() => req('/training-types/categories'), // Category metadata
// Admin: Training Types (v9d Phase 1b)
adminListTrainingTypes: () => req('/admin/training-types'),
adminGetTrainingType: (id) => req(`/admin/training-types/${id}`),
adminCreateTrainingType: (d) => req('/admin/training-types', json(d)),
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'),
} }