diff --git a/CLAUDE.md b/CLAUDE.md index 75e5320..51c236e 100644 --- a/CLAUDE.md +++ b/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 ``` diff --git a/backend/routers/sleep.py b/backend/routers/sleep.py index 87753ec..916fa9f 100644 --- a/backend/routers/sleep.py +++ b/backend/routers/sleep.py @@ -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)" + } diff --git a/frontend/src/pages/SleepPage.jsx b/frontend/src/pages/SleepPage.jsx index a17bff8..538b3a9 100644 --- a/frontend/src/pages/SleepPage.jsx +++ b/frontend/src/pages/SleepPage.jsx @@ -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() { )} - {/* Add Button */} + {/* Action Buttons */} {!showForm && ( - +