feat: automatic training type mapping for Apple Health import and bulk categorization
- 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:
parent
08cead49fe
commit
96b0acacd2
16
CLAUDE.md
16
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)
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
191
frontend/src/components/BulkCategorize.jsx
Normal file
191
frontend/src/components/BulkCategorize.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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()})
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user