feat: Apple Health CSV import for sleep data (v9d Phase 2c)
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:
parent
b65efd3b71
commit
b1a92c01fc
47
CLAUDE.md
47
CLAUDE.md
|
|
@ -1,5 +1,12 @@
|
|||
# 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
|
||||
**Mitai Jinkendo** (身体 Jinkendo) – selbst-gehostete PWA für Körper-Tracking mit KI-Auswertung.
|
||||
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`
|
||||
> 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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
|||
from pydantic import BaseModel
|
||||
from typing import Literal
|
||||
from datetime import datetime, timedelta
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
|
||||
from auth import require_auth
|
||||
from db import get_db, get_cursor
|
||||
|
|
@ -424,3 +427,172 @@ def get_sleep_phases(
|
|||
}
|
||||
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: Kern→light, REM→rem, Tief→deep, Wach→awake
|
||||
- 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)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Moon, Plus, Edit2, Trash2, X, Save, TrendingUp } from 'lucide-react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Moon, Plus, Edit2, Trash2, X, Save, TrendingUp, Upload } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
/**
|
||||
|
|
@ -20,6 +20,8 @@ export default function SleepPage() {
|
|||
const [editingId, setEditingId] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
// Form state
|
||||
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 h = Math.floor(minutes / 60)
|
||||
const m = minutes % 60
|
||||
|
|
@ -184,15 +211,41 @@ export default function SleepPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Button */}
|
||||
{/* Action Buttons */}
|
||||
{!showForm && (
|
||||
<button
|
||||
onClick={startCreate}
|
||||
className="btn btn-primary btn-full"
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Plus size={16} /> Schlaf erfassen
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||
<button
|
||||
onClick={startCreate}
|
||||
className="btn btn-primary"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Plus size={16} /> Schlaf erfassen
|
||||
</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 */}
|
||||
|
|
|
|||
|
|
@ -223,4 +223,11 @@ export const api = {
|
|||
getSleepDebt: (days=14) => req(`/sleep/debt?days=${days}`),
|
||||
getSleepTrend: (days=30) => req(`/sleep/trend?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})
|
||||
},
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user