feat(csv-import): Enhance Apple sleep CSV import functionality
- 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:
parent
26ab11eb7b
commit
41cc0ed2a8
|
|
@ -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 = [
|
||||
|
|
|
|||
27
backend/migrations/045_sleep_template_schlafanalyse_cols.sql
Normal file
27
backend/migrations/045_sleep_template_schlafanalyse_cols.sql
Normal 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)';
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 & 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 && (
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user