feat(csv-import): Enhance Apple sleep CSV import functionality
Some checks failed
Deploy Development / deploy (push) Successful in 54s
Build Test / pytest-backend-csv (push) Failing after 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

- Integrated date parsing improvements using dateutil for better handling of various date formats in sleep data.
- Added total sleep hours to the nights dictionary for comprehensive sleep analysis.
- Updated the import logic to handle cases where sleep duration is zero, providing appropriate warnings.
- Enhanced the CSV import interface to detect Apple sleep CSV format and provide user feedback on template selection.
- Improved the admin CSV template editor to accommodate new sleep import requirements and clarify usage instructions.
This commit is contained in:
Lars 2026-04-10 07:52:04 +02:00
parent 26ab11eb7b
commit 41cc0ed2a8
6 changed files with 223 additions and 54 deletions

View File

@ -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 = [

View File

@ -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)';

View File

@ -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,
}

View File

@ -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() {
</select>
{!isNew && (
<p style={{ fontSize: 13, color: 'var(--text3)', marginTop: 8 }}>
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).
</p>
)}
{aggregateSleepImport && (
<p
style={{
fontSize: 13,
color: 'var(--text2)',
marginTop: 12,
lineHeight: 1.55,
padding: 12,
background: 'var(--surface2)',
borderRadius: 12,
}}
>
<strong>Schlaf (Apple-Aggregat):</strong> 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{' '}
<strong>gespeicherte Spalten-Signatur</strong> und das passende Datei-Format damit die Datei
in der Nutzer-Auswahl erkannt wird.
</p>
)}
</div>
@ -430,12 +451,39 @@ export default function AdminCsvTemplateEditorPage() {
</select>
</label>
</div>
<label className="form-label" style={{ marginTop: 16, display: 'block' }}>
Spalten-Signatur (Vorlagen-Matching eine Original-Überschrift pro Zeile, manuell anpassbar)
</label>
<textarea
className="form-input"
style={{
width: '100%',
minHeight: 100,
marginTop: 8,
fontFamily: 'monospace',
fontSize: 12,
textAlign: 'left',
}}
value={columnSignature.join('\n')}
onChange={(e) =>
setColumnSignature(
e.target.value
.split('\n')
.map((s) => s.trim())
.filter(Boolean),
)
}
placeholder={'Date/Time\nStart\nEnd\nTotal Sleep (hr)\n…'}
/>
</div>
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div className="form-label">3. Spalten Zielfelder (* = Pflicht)</div>
{!columns.length ? (
<p style={{ fontSize: 14, color: 'var(--text2)', marginTop: 8 }}>Nach CSV-Analyse erscheinen die Zeilen hier.</p>
<p style={{ fontSize: 14, color: 'var(--text2)', marginTop: 8 }}>
Nach CSV-Analyse erscheinen die Zeilen hier. Bei Schlaf-Vorlagen ohne Analyse: Signatur oben pflegen,
speichern, oder Beispiel-CSV analysieren.
</p>
) : (
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
{columns.map((col) => (
@ -455,6 +503,7 @@ export default function AdminCsvTemplateEditorPage() {
className="form-input"
value={fieldMappings[col] || '-'}
onChange={(e) => updateMapping(col, e.target.value)}
disabled={aggregateSleepImport}
style={{
width: '100%',
minHeight: 46,

View File

@ -74,6 +74,8 @@ export default function AdminCsvTemplatesPage() {
<option value="weight">Gewicht</option>
<option value="blood_pressure">Blutdruck</option>
<option value="activity">Aktivität</option>
<option value="sleep">Schlaf</option>
<option value="vitals_baseline">Vitalwerte (Baseline)</option>
</select>
</label>
</div>

View File

@ -108,6 +108,7 @@ function SampleTable({ sampleRows, columns }) {
export default function UniversalCsvImportPage() {
const navigate = useNavigate()
const fileInputRef = useRef(null)
const analyzeGenRef = useRef(0)
const [file, setFile] = useState(null)
const [dragActive, setDragActive] = useState(false)
const [analyzeResult, setAnalyzeResult] = useState(null)
@ -124,34 +125,19 @@ export default function UniversalCsvImportPage() {
)
const importAllowed = selectedChoice && EXECUTOR_READY.has(selectedChoice.module)
const assignCsvFile = (f) => {
if (!f) return
const name = (f.name || '').toLowerCase()
if (!name.endsWith('.csv')) {
setError('Bitte eine .csv-Datei wählen.')
return
}
setFile(f)
setAnalyzeResult(null)
setMappingChoices([])
setMappingId('')
setSuccess(null)
setError(null)
}
const handleAnalyze = async () => {
if (!file) {
setError('Bitte eine CSV-Datei wählen')
return
}
const runAnalyze = async (fileToAnalyze) => {
if (!fileToAnalyze) return
const gen = ++analyzeGenRef.current
setLoadingAnalyze(true)
setError(null)
setSuccess(null)
setMappingId('')
try {
const res = await api.analyzeCsv(file)
const res = await api.analyzeCsv(fileToAnalyze)
if (gen !== analyzeGenRef.current) return
setAnalyzeResult(res)
const mapData = await api.getCsvMappings()
if (gen !== analyzeGenRef.current) return
const merged = mergeMappingChoices(res.detected_mappings || [], mapData)
setMappingChoices(merged)
@ -165,14 +151,33 @@ export default function UniversalCsvImportPage() {
}
setMappingId(pick)
} catch (e) {
if (gen !== analyzeGenRef.current) return
setError(e.message || 'Analyse fehlgeschlagen')
setAnalyzeResult(null)
setMappingChoices([])
} finally {
setLoadingAnalyze(false)
if (gen === analyzeGenRef.current) {
setLoadingAnalyze(false)
}
}
}
const assignCsvFile = (f) => {
if (!f) return
const name = (f.name || '').toLowerCase()
if (!name.endsWith('.csv')) {
setError('Bitte eine .csv-Datei wählen.')
return
}
setFile(f)
setAnalyzeResult(null)
setMappingChoices([])
setMappingId('')
setSuccess(null)
setError(null)
void runAnalyze(f)
}
const handleImport = async () => {
if (!file || !mappingId) {
setError('Bitte Datei und Vorlage wählen')
@ -223,10 +228,10 @@ export default function UniversalCsvImportPage() {
CSV-Import
</h1>
<p style={{ fontSize: 14, color: 'var(--text2)', marginBottom: 20, lineHeight: 1.55 }}>
CSV hier ablegen oder die Fläche antippen: Die App vergleicht die Spalten mit gespeicherten Vorlagen
(Ernährung, Gewicht, Blutdruck, Aktivität, Schlaf, Vitalwerte) und schlägt passende Ziele vor. Du
bestätigst die Vorlage ohne zuerst ein Modell raten zu müssen. Schlaf-Import erwartet das
Apple-Health-Schlaf-CSV (Segment- oder Zusammenfassungs-Export).{' '}
CSV hier ablegen oder die Fläche antippen die Analyse startet sofort. Die App vergleicht die Spalten
mit gespeicherten Vorlagen (Ernährung, Gewicht, Blutdruck, Aktivität, Schlaf, Vitalwerte) und schlägt
passende Ziele vor. Du bestätigst die Vorlage ohne zuerst ein Modell raten zu müssen. Schlaf-Import
erwartet das Apple-Health-Schlaf-CSV (Segment- oder Zusammenfassungs-Export).{' '}
<strong>Ausblick:</strong> Eine Datei mehrere Zieltabellen.
</p>
@ -310,33 +315,60 @@ export default function UniversalCsvImportPage() {
Datei ablegen oder tippen zum Auswählen
</span>
<span style={{ fontSize: 13, color: 'var(--text3)' }}>
{file ? file.name : 'Noch keine Datei gewählt'}
{loadingAnalyze ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<Loader2 size={16} style={{ animation: 'spin 0.7s linear infinite' }} />
Datei wird analysiert
</span>
) : file ? (
file.name
) : (
'Noch keine Datei gewählt'
)}
</span>
</button>
<button
type="button"
className="btn btn-primary"
style={{ marginTop: 12, width: '100%' }}
disabled={!file || loadingAnalyze}
onClick={handleAnalyze}
>
{loadingAnalyze ? (
<>
<Loader2 size={18} style={{ marginRight: 8, animation: 'spin 0.7s linear infinite' }} /> Datei
wird erkannt
</>
) : (
'Datei analysieren'
)}
</button>
{file && !loadingAnalyze && (
<button
type="button"
className="btn btn-secondary"
style={{ marginTop: 12, width: '100%' }}
onClick={() => void runAnalyze(file)}
>
Dieselbe Datei erneut analysieren
</button>
)}
</div>
{analyzeResult && (
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
<div className="form-label">2. Erkennung &amp; Vorschau</div>
{(analyzeResult.warnings || []).length > 0 && (
<div
style={{
marginTop: 12,
padding: 12,
borderRadius: 12,
background: 'rgba(216, 90, 48, 0.12)',
border: '1px solid var(--danger)',
color: 'var(--danger-dark, var(--danger))',
fontSize: 14,
lineHeight: 1.5,
}}
>
{(analyzeResult.warnings || []).map((w, i) => (
<div key={i}>{w}</div>
))}
</div>
)}
<div style={{ fontSize: 13, color: 'var(--text2)', marginTop: 8 }}>
Trennzeichen: <strong>{analyzeResult.delimiter}</strong> · Spalten:{' '}
{analyzeResult.columns?.length ?? 0}
{analyzeResult.format_detection?.apple_sleep ? (
<>
{' '}
· <strong>Format:</strong> Apple-Schlaf (Schlafanalyse oder Segment-Export)
</>
) : null}
</div>
{analyzeResult.recommended && (