feat: Apple Health CSV import for sleep data (v9d Phase 2c)
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 12s

Backend:
- New endpoint POST /api/sleep/import/apple-health
- Parses Apple Health sleep CSV format
- Maps German phase names (Kern→light, REM→rem, Tief→deep, Wach→awake)
- Aggregates segments by night (wake date)
- Stores raw segments in JSONB (sleep_segments)
- Does NOT overwrite manual entries (source='manual')

Frontend:
- Import button in SleepPage with file picker
- Progress indicator during import
- Success/error messages
- Auto-refresh after import

Documentation:
- Added architecture rules reference to CLAUDE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-22 11:49:09 +01:00
parent b65efd3b71
commit b1a92c01fc
4 changed files with 272 additions and 27 deletions

View File

@ -1,5 +1,12 @@
# Mitai Jinkendo Entwickler-Kontext für Claude Code # Mitai Jinkendo Entwickler-Kontext für Claude Code
## Pflicht-Lektüre für Claude Code
> VOR jeder Implementierung lesen:
> | Architektur-Regeln | `.claude/rules/ARCHITECTURE.md` |
> | Coding-Regeln | `.claude/rules/CODING_RULES.md` |
> | Lessons Learned | `.claude/rules/LESSONS_LEARNED.md` |
## Projekt-Übersicht ## Projekt-Übersicht
**Mitai Jinkendo** (身体 Jinkendo) selbst-gehostete PWA für Körper-Tracking mit KI-Auswertung. **Mitai Jinkendo** (身体 Jinkendo) selbst-gehostete PWA für Körper-Tracking mit KI-Auswertung.
Teil der **Jinkendo**-App-Familie (人拳道). Domains: jinkendo.de / .com / .life Teil der **Jinkendo**-App-Familie (人拳道). Domains: jinkendo.de / .com / .life
@ -283,25 +290,31 @@ Bottom-Padding Mobile: 80px (Navigation)
> Vollständige CSS-Variablen und Komponenten-Muster: `frontend/src/app.css` > Vollständige CSS-Variablen und Komponenten-Muster: `frontend/src/app.css`
> Responsive Layout-Spec: `.claude/docs/functional/RESPONSIVE_UI.md` > Responsive Layout-Spec: `.claude/docs/functional/RESPONSIVE_UI.md`
## Dokumentations-Referenzen ## Dokumentations-Struktur
```
.claude/
├── BACKLOG.md ← Feature-Übersicht
├── commands/ ← Slash-Commands (/deploy, /document etc.)
├── docs/
│ ├── functional/ ← Fachliche Specs (WAS soll gebaut werden)
│ ├── technical/ ← Technische Specs (WIE wird es gebaut)
│ └── rules/ ← Verbindliche Regeln
└── library/ ← Ergebnis-Dokumentation (WAS wurde gebaut)
```
|Bereich|Pfad|Inhalt|
|-|-|-|
|Architektur-Übersicht|`.claude/library/ARCHITECTURE.md`|Gesamt-Überblick|
|Frontend-Dokumentation|`.claude/library/FRONTEND.md`|Seiten + Komponenten|
|Auth-Flow|`.claude/library/AUTH.md`|Sicherheit + Sessions|
|API-Referenz|`.claude/library/API\_REFERENCE.md`|Alle Endpoints|
|Datenbankschema|`.claude/library/DATABASE.md`|Tabellen + Beziehungen|
> Library-Dateien werden mit `/document` generiert und nach größeren
> Änderungen aktualisiert.
> **Für Claude Code:** Beim Arbeiten an einem Thema die entsprechende Datei lesen:
| Thema | Datei |
|-------|-------|
| Backend-Architektur, Router, DB-Zugriff | `.claude/docs/architecture/BACKEND.md` |
| Frontend-Architektur, api.js, Komponenten | `.claude/docs/architecture/FRONTEND.md` |
| **Feature-Enforcement (neue Features hinzufügen)** | `.claude/docs/architecture/FEATURE_ENFORCEMENT.md` |
| **Database Migrations (Schema-Änderungen)** | `.claude/docs/technical/MIGRATIONS.md` |
| Coding Rules (Pflichtregeln) | `.claude/docs/rules/CODING_RULES.md` |
| Lessons Learned (Fehler vermeiden) | `.claude/docs/rules/LESSONS_LEARNED.md` |
| Feature Backlog (Übersicht) | `.claude/docs/BACKLOG.md` |
| **Pending Features (noch nicht enforced)** | `.claude/docs/PENDING_FEATURES.md` |
| **Known Issues (Bugs & Tech Debt)** | `.claude/docs/KNOWN_ISSUES.md` |
| Membership-System (v9c, technisch) | `.claude/docs/technical/MEMBERSHIP_SYSTEM.md` |
| Trainingstypen + HF (v9d, fachlich) | `.claude/docs/functional/TRAINING_TYPES.md` |
| KI-Prompt Flexibilisierung (v9f, fachlich) | `.claude/docs/functional/AI_PROMPTS.md` |
| Responsive UI (fachlich) | `.claude/docs/functional/RESPONSIVE_UI.md` |
## Jinkendo App-Familie ## Jinkendo App-Familie
``` ```

View File

@ -10,6 +10,9 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel
from typing import Literal from typing import Literal
from datetime import datetime, timedelta from datetime import datetime, timedelta
import csv
import io
import json
from auth import require_auth from auth import require_auth
from db import get_db, get_cursor from db import get_db, get_cursor
@ -424,3 +427,172 @@ def get_sleep_phases(
} }
for row in rows for row in rows
] ]
# ── Import Endpoints ──────────────────────────────────────────────────────────
@router.post("/import/apple-health")
async def import_apple_health_sleep(
file: UploadFile = File(...),
session: dict = Depends(require_auth)
):
"""
Import sleep data from Apple Health CSV export.
Expected CSV format:
Start,End,Duration (hr),Value,Source
2026-03-14 22:44:23,2026-03-14 23:00:19,0.266,Kern,Apple Watch
- Aggregates segments by night (wake date)
- Maps German phase names: Kernlight, REMrem, Tiefdeep, Wachawake
- Stores raw segments in JSONB
- Does NOT overwrite manual entries (source='manual')
"""
pid = session['profile_id']
# Read CSV
content = await file.read()
csv_text = content.decode('utf-8-sig') # Handle BOM
reader = csv.DictReader(io.StringIO(csv_text))
# Phase mapping (German → English)
phase_map = {
'Kern': 'light',
'REM': 'rem',
'Tief': 'deep',
'Wach': 'awake',
'Schlafend': None # Ignore initial sleep entry
}
# Parse segments
segments = []
for row in reader:
phase_de = row['Value'].strip()
phase_en = phase_map.get(phase_de)
if phase_en is None: # Skip "Schlafend"
continue
start_dt = datetime.strptime(row['Start'], '%Y-%m-%d %H:%M:%S')
end_dt = datetime.strptime(row['End'], '%Y-%m-%d %H:%M:%S')
duration_hr = float(row['Duration (hr)'])
duration_min = int(duration_hr * 60)
segments.append({
'start': start_dt,
'end': end_dt,
'duration_min': duration_min,
'phase': phase_en
})
# Group by night (wake date)
nights = {}
for seg in segments:
wake_date = seg['end'].date() # Date of waking up
if wake_date not in nights:
nights[wake_date] = {
'bedtime': seg['start'],
'wake_time': seg['end'],
'segments': [],
'deep_minutes': 0,
'rem_minutes': 0,
'light_minutes': 0,
'awake_minutes': 0
}
night = nights[wake_date]
night['segments'].append(seg)
night['wake_time'] = max(night['wake_time'], seg['end']) # Latest wake time
night['bedtime'] = min(night['bedtime'], seg['start']) # Earliest bed time
# Sum phases
if seg['phase'] == 'deep':
night['deep_minutes'] += seg['duration_min']
elif seg['phase'] == 'rem':
night['rem_minutes'] += seg['duration_min']
elif seg['phase'] == 'light':
night['light_minutes'] += seg['duration_min']
elif seg['phase'] == 'awake':
night['awake_minutes'] += seg['duration_min']
# Insert nights
imported = 0
skipped = 0
with get_db() as conn:
cur = get_cursor(conn)
for date, night in nights.items():
# Calculate total duration (sum of all phases)
duration_minutes = (
night['deep_minutes'] +
night['rem_minutes'] +
night['light_minutes'] +
night['awake_minutes']
)
# Prepare JSONB segments
sleep_segments = [
{
'phase': seg['phase'],
'start': seg['start'].strftime('%H:%M'),
'duration_min': seg['duration_min']
}
for seg in night['segments']
]
# Check if manual entry exists
cur.execute("""
SELECT id, source FROM sleep_log
WHERE profile_id = %s AND date = %s
""", (pid, date))
existing = cur.fetchone()
if existing and existing['source'] == 'manual':
skipped += 1
continue # Don't overwrite manual entries
# Upsert
cur.execute("""
INSERT INTO sleep_log (
profile_id, date, bedtime, wake_time, duration_minutes,
deep_minutes, rem_minutes, light_minutes, awake_minutes,
sleep_segments, source, updated_at
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'apple_health', CURRENT_TIMESTAMP
)
ON CONFLICT (profile_id, date) DO UPDATE SET
bedtime = EXCLUDED.bedtime,
wake_time = EXCLUDED.wake_time,
duration_minutes = EXCLUDED.duration_minutes,
deep_minutes = EXCLUDED.deep_minutes,
rem_minutes = EXCLUDED.rem_minutes,
light_minutes = EXCLUDED.light_minutes,
awake_minutes = EXCLUDED.awake_minutes,
sleep_segments = EXCLUDED.sleep_segments,
source = EXCLUDED.source,
updated_at = CURRENT_TIMESTAMP
WHERE sleep_log.source != 'manual'
""", (
pid,
date,
night['bedtime'].time(),
night['wake_time'].time(),
duration_minutes,
night['deep_minutes'],
night['rem_minutes'],
night['light_minutes'],
night['awake_minutes'],
json.dumps(sleep_segments)
))
imported += 1
conn.commit()
return {
"imported": imported,
"skipped": skipped,
"total_nights": len(nights),
"message": f"{imported} Nächte importiert, {skipped} übersprungen (manuelle Einträge)"
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import { Moon, Plus, Edit2, Trash2, X, Save, TrendingUp } from 'lucide-react' import { Moon, Plus, Edit2, Trash2, X, Save, TrendingUp, Upload } from 'lucide-react'
import { api } from '../utils/api' import { api } from '../utils/api'
/** /**
@ -20,6 +20,8 @@ export default function SleepPage() {
const [editingId, setEditingId] = useState(null) const [editingId, setEditingId] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [importing, setImporting] = useState(false)
const fileInputRef = useRef(null)
// Form state // Form state
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@ -133,6 +135,31 @@ export default function SleepPage() {
} }
} }
const handleImport = async (e) => {
const file = e.target.files[0]
if (!file) return
if (!file.name.endsWith('.csv')) {
alert('Bitte eine CSV-Datei auswählen')
return
}
setImporting(true)
try {
const result = await api.importAppleHealthSleep(file)
await load()
alert(result.message || `${result.imported} Nächte importiert, ${result.skipped} übersprungen`)
} catch (err) {
alert('Import fehlgeschlagen: ' + err.message)
} finally {
setImporting(false)
if (fileInputRef.current) {
fileInputRef.current.value = '' // Reset input
}
}
}
const formatDuration = (minutes) => { const formatDuration = (minutes) => {
const h = Math.floor(minutes / 60) const h = Math.floor(minutes / 60)
const m = minutes % 60 const m = minutes % 60
@ -184,15 +211,41 @@ export default function SleepPage() {
</div> </div>
)} )}
{/* Add Button */} {/* Action Buttons */}
{!showForm && ( {!showForm && (
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<button <button
onClick={startCreate} onClick={startCreate}
className="btn btn-primary btn-full" className="btn btn-primary"
style={{ marginBottom: 16 }} style={{ flex: 1 }}
> >
<Plus size={16} /> Schlaf erfassen <Plus size={16} /> Schlaf erfassen
</button> </button>
<button
onClick={() => fileInputRef.current?.click()}
disabled={importing}
className="btn btn-secondary"
style={{ flex: 1 }}
>
{importing ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'center' }}>
<div className="spinner" style={{ width: 14, height: 14 }} />
Importiere...
</div>
) : (
<>
<Upload size={16} /> CSV Import
</>
)}
</button>
<input
ref={fileInputRef}
type="file"
accept=".csv"
onChange={handleImport}
style={{ display: 'none' }}
/>
</div>
)} )}
{/* Entry Form */} {/* Entry Form */}

View File

@ -223,4 +223,11 @@ export const api = {
getSleepDebt: (days=14) => req(`/sleep/debt?days=${days}`), getSleepDebt: (days=14) => req(`/sleep/debt?days=${days}`),
getSleepTrend: (days=30) => req(`/sleep/trend?days=${days}`), getSleepTrend: (days=30) => req(`/sleep/trend?days=${days}`),
getSleepPhases: (days=30) => req(`/sleep/phases?days=${days}`), getSleepPhases: (days=30) => req(`/sleep/phases?days=${days}`),
// Sleep Import (v9d Phase 2c)
importAppleHealthSleep: (file) => {
const fd = new FormData()
fd.append('file', file)
return req('/sleep/import/apple-health', {method:'POST', body:fd})
},
} }