feat: automatic training type mapping for Apple Health import and bulk categorization
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s

- Add get_training_type_for_apple_health() mapping function (23 workout types)
- CSV import now automatically assigns training_type_id/category/subcategory
- New endpoint: GET /activity/uncategorized (grouped by activity_type)
- New endpoint: POST /activity/bulk-categorize (bulk update training types)
- New component: BulkCategorize with two-level dropdown selection
- ActivityPage: new "Kategorisieren" tab for existing activities
- Update CLAUDE.md: v9d Phase 1b progress

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-21 15:08:18 +01:00
parent 08cead49fe
commit 96b0acacd2
5 changed files with 325 additions and 9 deletions

View File

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

View File

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

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

@ -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() {
<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==='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>
</div>
@ -279,6 +281,13 @@ export default function ActivityPage() {
{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' && (
<div className="card section-gap">
<div className="card-title badge-container-right">

View File

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