diff --git a/CLAUDE.md b/CLAUDE.md index cc01896..2ab20cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,17 +95,23 @@ frontend/src/ ### Auf develop (nicht deployed) 📦 - ✅ **TrialBanner mailto:** "Abo wählen" → mailto:mitai@jinkendo.de (Vorbereitung für zentrales Abo-System) +- ✅ **Apple Health Mapping:** Automatische Trainingstyp-Zuordnung beim CSV-Import (23 Workout-Typen) +- ✅ **Bulk-Kategorisierung:** Nachträgliche Typ-Zuweisung für bestehende Aktivitäten +- ✅ **ActivityPage Integration:** TrainingTypeSelect + "Kategorisieren"-Tab +- ✅ **Dashboard Integration:** TrainingTypeDistribution Chart (28 Tage) - 📚 Dokumentation: `.claude/docs/technical/CENTRAL_SUBSCRIPTION_SYSTEM.md` ### 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 (noch nicht eingebunden) +- ✅ **Components:** TrainingTypeSelect, TrainingTypeDistribution -### v9d – Phase 1b 🔲 (Integration) -- 🔲 ActivityPage: TrainingTypeSelect einbinden -- 🔲 Dashboard: TrainingTypeDistribution Chart -- 🔲 History: Typ-Badge bei Aktivitäten +### v9d – Phase 1b ⏳ (In Progress) +- ✅ ActivityPage: TrainingTypeSelect eingebunden +- ✅ Dashboard: TrainingTypeDistribution Chart eingebunden +- ✅ Apple Health Import: Automatisches Mapping +- ✅ Bulk-Kategorisierung: UI + Endpoints +- 🔲 History: Typ-Badge bei Aktivitäten (ausstehend) ### v9d – Phase 2+ 🔲 (Später) - 🔲 Ruhetage erfassen (rest_days Tabelle) diff --git a/backend/routers/activity.py b/backend/routers/activity.py index 0d12000..5abcf0d 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -113,9 +113,113 @@ 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): + """ + Map Apple Health workout type to training_type_id + category + subcategory. + 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 + mapping = { + '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': 'other', + 'core training': 'functional', + 'flexibility': 'static', + 'cooldown': 'regeneration', + } + + subcategory = mapping.get(workout_type.lower()) + if not subcategory: + return (None, None, None) + + # Find training_type_id by subcategory + cur.execute(""" + SELECT id, category, subcategory + FROM training_types + WHERE LOWER(subcategory) = %s + LIMIT 1 + """, (subcategory,)) + row = cur.fetchone() + + if row: + return (row['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. + 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) + 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 + + return {"updated": updated_count, "activity_type": activity_type} + + @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)): - """Import Apple Health workout CSV.""" + """Import Apple Health workout CSV with automatic training type mapping.""" pid = get_pid(x_profile_id) raw = await file.read() try: text = raw.decode('utf-8') @@ -145,16 +249,20 @@ 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) + try: 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,created) - VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'apple_health',CURRENT_TIMESTAMP)""", + 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)','')))) + tf(row.get('Distanz (km)','')), + training_type_id,training_category,training_subcategory)) inserted+=1 except: skipped+=1 return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"} diff --git a/frontend/src/components/BulkCategorize.jsx b/frontend/src/components/BulkCategorize.jsx new file mode 100644 index 0000000..f4fa36d --- /dev/null +++ b/frontend/src/components/BulkCategorize.jsx @@ -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 ( +
+
+
+ ) + } + + if (uncategorized.length === 0) { + return ( +
+
âś“
+
Alle Aktivitäten sind kategorisiert
+ {onComplete && ( + + )} +
+ ) + } + + return ( +
+
+ + {uncategorized.reduce((sum, u) => sum + u.count, 0)} Aktivitäten + ohne Trainingstyp gefunden. Weise jedem Aktivitätstyp einen Trainingstyp zu. +
+ +
+ {uncategorized.map(item => ( +
+
+
+
+ {item.activity_type} +
+
+ {item.count} Einheiten + {item.first_date && item.last_date && ( + <> · {item.first_date} bis {item.last_date} + )} +
+
+
+ + + handleAssignment(item.activity_type, typeId, category, subcategory) + } + required={false} + /> + + +
+ ))} +
+ + {onComplete && ( + + )} +
+ ) +} diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx index 5d721aa..05730cc 100644 --- a/frontend/src/pages/ActivityPage.jsx +++ b/frontend/src/pages/ActivityPage.jsx @@ -4,6 +4,7 @@ import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGri import { api } from '../utils/api' import UsageBadge from '../components/UsageBadge' import TrainingTypeSelect from '../components/TrainingTypeSelect' +import BulkCategorize from '../components/BulkCategorize' import dayjs from 'dayjs' import 'dayjs/locale/de' dayjs.locale('de') @@ -258,6 +259,7 @@ export default function ActivityPage() { +
@@ -279,6 +281,13 @@ export default function ActivityPage() { {tab==='import' && } + {tab==='categorize' && ( +
+
🏷️ Aktivitäten kategorisieren
+ { load(); setTab('list'); }} /> +
+ )} + {tab==='add' && (
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 22004ef..c4a24b1 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -56,6 +56,8 @@ export const api = { updateActivity: (id,d) => req(`/activity/${id}`,jput(d)), deleteActivity: (id) => req(`/activity/${id}`,{method:'DELETE'}), activityStats: () => req('/activity/stats'), + listUncategorizedActivities: () => req('/activity/uncategorized'), + bulkCategorizeActivities: (d) => req('/activity/bulk-categorize', json(d)), importActivityCsv: async(file)=>{ const fd=new FormData();fd.append('file',file) const r=await fetch(`${BASE}/activity/import-csv`,{method:'POST',body:fd,headers:hdrs()})