diff --git a/backend/csv_parser/sleep_apple_import.py b/backend/csv_parser/sleep_apple_import.py index ae22e94..0a44d2b 100644 --- a/backend/csv_parser/sleep_apple_import.py +++ b/backend/csv_parser/sleep_apple_import.py @@ -13,6 +13,8 @@ from datetime import date, datetime from decimal import Decimal, InvalidOperation from typing import Any, Literal +from dateutil import parser as dateutil_parser + logger = logging.getLogger(__name__) @@ -44,13 +46,15 @@ def _parse_apple_sleep_datetime(value: str) -> datetime: "%d.%m.%Y %H:%M:%S", "%Y-%m-%d %H:%M:%S", ) - last_err = None for fmt in fmts: try: return datetime.strptime(raw, fmt) - except ValueError as e: - last_err = e - raise ValueError(f"Unbekanntes Datumsformat: {raw!r}") from last_err + except ValueError: + continue + try: + return dateutil_parser.parse(raw, dayfirst=False) + except (ValueError, TypeError, OverflowError) as e: + raise ValueError(f"Unbekanntes Datumsformat: {raw!r}") from e def _hr_to_minutes(hours: float | None) -> int: @@ -102,6 +106,7 @@ def _build_nights_from_apple_summary(reader: csv.DictReader) -> dict[date, dict] rem_min = _hr_to_minutes(_safe_float(row.get("REM (hr)"))) light_min = _hr_to_minutes(core_hr) awake_min = _hr_to_minutes(_safe_float(row.get("Awake (hr)"))) + total_sleep_hr = _safe_float(row.get("Total Sleep (hr)")) nights_dict[wake_d] = { "bedtime": start_dt, "wake_time": end_dt, @@ -110,6 +115,7 @@ def _build_nights_from_apple_summary(reader: csv.DictReader) -> dict[date, dict] "rem_minutes": rem_min, "light_minutes": light_min, "awake_minutes": awake_min, + "total_sleep_hr": total_sleep_hr, } return nights_dict @@ -219,9 +225,24 @@ def import_apple_sleep_nights(cur, profile_id: str, text: str) -> dict[str, Any] row_hint = 0 for wake_date, night in nights_dict.items(): row_hint += 1 - duration_minutes = ( + phase_sum = ( night["deep_minutes"] + night["rem_minutes"] + night["light_minutes"] ) + total_hr = night.get("total_sleep_hr") + fallback_min = int(round(float(total_hr) * 60)) if total_hr is not None else 0 + duration_minutes = phase_sum if phase_sum > 0 else fallback_min + if duration_minutes <= 0: + logger.warning( + "Sleep import: überspringe %s — Dauer 0 (Phasen-Summe und Total Sleep (hr) leer/0).", + wake_date, + ) + error_details.append( + { + "row": row_hint, + "error": f"Schlafdauer für {wake_date} ist 0 — Phasen oder Total Sleep (hr) fehlen.", + } + ) + continue wake_count = sum(1 for seg in night["segments"] if seg["phase"] == "awake") sleep_segments = [ diff --git a/backend/migrations/045_sleep_template_schlafanalyse_cols.sql b/backend/migrations/045_sleep_template_schlafanalyse_cols.sql new file mode 100644 index 0000000..a051457 --- /dev/null +++ b/backend/migrations/045_sleep_template_schlafanalyse_cols.sql @@ -0,0 +1,27 @@ +-- Migration 045: Schlaf-Systemvorlage — Signatur wie „Schlafanalyse“-Export (Date/Time, Sources, …) +-- Ergänzt die Vorlage aus 044 für besseres Matching; Import-Logik unverändert (Apple-Aggregat-Parser). + +UPDATE csv_field_mappings +SET + column_signature = ARRAY[ + 'Date/Time', + 'Start', + 'End', + 'Total Sleep (hr)', + 'Asleep (Unspecified) (hr)', + 'In Bed (hr)', + 'Core (hr)', + 'Deep (hr)', + 'REM (hr)', + 'Awake (hr)', + 'Sources' + ]::TEXT[], + description = COALESCE( + description, + 'Apple-Health-Schlafanalyse (Nacht-Zusammenfassung): Spalten wie Date/Time, Start/End mit Zeitzone, Kern/Tief/REM etc.' + ), + updated_at = CURRENT_TIMESTAMP +WHERE is_system = true + AND profile_id IS NULL + AND module = 'sleep' + AND mapping_name = 'Apple Health Schlaf (Schlafanalyse / Nacht)'; diff --git a/backend/routers/csv_import.py b/backend/routers/csv_import.py index 5d1e4d7..53ab1d2 100644 --- a/backend/routers/csv_import.py +++ b/backend/routers/csv_import.py @@ -25,6 +25,7 @@ from csv_parser.core import ( parse_csv_sample, ) from csv_parser.module_registry import get_module_definition, list_modules, validate_field_mappings +from csv_parser.sleep_apple_import import detect_apple_sleep_csv_format router = APIRouter(prefix="/api/csv", tags=["csv-import"]) logger = logging.getLogger(__name__) @@ -59,7 +60,14 @@ def csv_modules(session: dict = Depends(require_auth)): for mid in list_modules(): d = get_module_definition(mid) if d: - out.append({"id": mid, "table": d["table"], "fields": d["fields"]}) + out.append( + { + "id": mid, + "table": d["table"], + "fields": d["fields"], + "import_mode": d.get("import_mode"), + } + ) return {"modules": out} @@ -216,6 +224,13 @@ async def analyze_csv( headers, sample_rows, used_delim = parse_csv_sample(text, delimiter=delim, max_data_rows=5) sig = column_signature(headers) + apple_sleep_csv = False + try: + detect_apple_sleep_csv_format(headers) + apple_sleep_csv = True + except ValueError: + pass + mod_def = get_module_definition(module) if module else None available_fields = mod_def["fields"] if mod_def else None @@ -248,13 +263,16 @@ async def analyze_csv( for t in templates: t_sig = list(t["column_signature"]) if t["column_signature"] else [] metrics = headers_signature_rank_metrics(sig, t_sig) + conf = float(metrics["confidence"] or 0) + if apple_sleep_csv and t.get("module") == "sleep": + conf = max(conf, 1.0) ranked.append( { "mapping_id": t["id"], "module": t["module"], "mapping_name": t["mapping_name"], "is_system": bool(t.get("is_system")), - "confidence": metrics["confidence"], + "confidence": round(conf, 4), "template_recall": metrics["template_recall"], "jaccard": metrics["jaccard"], "columns_matched": metrics["columns_matched"], @@ -274,6 +292,24 @@ async def analyze_csv( top = ranked[:25] recommended = top[0] if top and (top[0]["confidence"] or 0) > 0 else None + warnings: list[str] = [] + if apple_sleep_csv and not any(t.get("module") == "sleep" for t in templates): + warnings.append( + "Diese Datei ist ein Apple-Schlafexport, aber es fehlt eine Vorlage für das Modul «Schlaf» " + "(z. B. Migration 044 / Admin → CSV-Vorlagen)." + ) + if ( + apple_sleep_csv + and recommended + and recommended.get("module") != "sleep" + and any(t.get("module") == "sleep" for t in templates) + ): + warnings.append( + "Apple-Schlaf-CSV erkannt — bitte eine Vorlage unter Modul «Schlaf» wählen, nicht «" + + str(recommended.get("module") or "") + + "».", + ) + return { "module_filter": module, "filename": file.filename, @@ -285,6 +321,8 @@ async def analyze_csv( "detected_mappings": top, "recommended": recommended, "available_fields": available_fields, + "format_detection": {"apple_sleep": apple_sleep_csv}, + "warnings": warnings, } diff --git a/frontend/src/pages/AdminCsvTemplateEditorPage.jsx b/frontend/src/pages/AdminCsvTemplateEditorPage.jsx index 510bd90..50884aa 100644 --- a/frontend/src/pages/AdminCsvTemplateEditorPage.jsx +++ b/frontend/src/pages/AdminCsvTemplateEditorPage.jsx @@ -84,13 +84,14 @@ export default function AdminCsvTemplateEditorPage() { const [error, setError] = useState(null) const modMeta = useMemo(() => modules.find((m) => m.id === module), [modules, module]) + const aggregateSleepImport = modMeta?.import_mode === 'apple_sleep_aggregate' const targetOptions = useMemo(() => { - if (!modMeta?.fields) return [] + if (!modMeta?.fields || aggregateSleepImport) return [] return Object.entries(modMeta.fields).map(([key, meta]) => ({ value: key, label: `${key}${meta.required ? ' *' : ''}`, })) - }, [modMeta]) + }, [modMeta, aggregateSleepImport]) const requiredTargets = useMemo(() => { if (!modMeta?.fields) return [] @@ -302,7 +303,27 @@ export default function AdminCsvTemplateEditorPage() { {!isNew && (
- Modul bestehender Vorlagen kann nicht geändert werden. + Modul bestehender Vorlagen kann nicht geändert werden. System-Vorlagen können hier bearbeitet und + gespeichert werden (Signatur, Trennzeichen, Zuordnungen). +
+ )} + {aggregateSleepImport && ( ++ Schlaf (Apple-Aggregat): Die Zeilen werden nicht über Spalten-Ziele importiert, + sondern vom Apple-Schlaf-Parser ausgewertet (Schlafanalyse oder Segment-Export). Alle CSV-Spalten + bleiben auf „ignorieren“. Wichtig sind die{' '} + gespeicherte Spalten-Signatur und das passende Datei-Format — damit die Datei + in der Nutzer-Auswahl erkannt wird.
)} @@ -430,12 +451,39 @@ export default function AdminCsvTemplateEditorPage() { + +