- Introduced `diagnose_blood_pressure_row` and `diagnose_activity_row` functions to validate and analyze blood pressure and activity data from CSV imports. - Updated the CSV import logic to handle combined datetime columns for blood pressure and activity, improving data integrity during import. - Enhanced type conversion specifications to include `start_time` for blood pressure and activity, ensuring accurate data mapping. - Added tests to validate the new diagnosis functions and their integration with existing import processes, ensuring robustness and reliability. - Updated frontend messages to provide clearer guidance on blood pressure and activity data handling during CSV imports.
585 lines
20 KiB
JavaScript
585 lines
20 KiB
JavaScript
import { useState, useMemo, useRef } from 'react'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import { ArrowLeft, FileSpreadsheet, Loader2, Upload } from 'lucide-react'
|
||
import { api } from '../utils/api'
|
||
import { csvPreviewTdStyle } from '../utils/csvPreviewCells'
|
||
|
||
/** Ziele, die der Universal-Executor bereits schreiben kann (ohne manuelle Modul-Wahl). */
|
||
const EXECUTOR_READY = new Set([
|
||
'nutrition',
|
||
'weight',
|
||
'blood_pressure',
|
||
'activity',
|
||
'sleep',
|
||
'vitals_baseline',
|
||
])
|
||
|
||
const MODULE_LABEL = {
|
||
nutrition: 'Ernährung',
|
||
weight: 'Gewicht',
|
||
blood_pressure: 'Blutdruck',
|
||
activity: 'Aktivität',
|
||
sleep: 'Schlaf',
|
||
vitals_baseline: 'Vitalwerte (Baseline)',
|
||
}
|
||
|
||
function mergeMappingChoices(detected, mapData) {
|
||
const sys = mapData.system_templates || []
|
||
const usr = mapData.user_mappings || []
|
||
const flat = [
|
||
...sys.map((m) => ({ ...m, _isSys: true })),
|
||
...usr.map((m) => ({ ...m, _isSys: false })),
|
||
]
|
||
const byId = new Map(flat.map((m) => [m.id, m]))
|
||
const out = []
|
||
const seen = new Set()
|
||
for (const d of detected || []) {
|
||
const row = byId.get(d.mapping_id)
|
||
if (row) {
|
||
out.push({
|
||
id: row.id,
|
||
module: row.module,
|
||
name: row.name,
|
||
is_system: row.is_system,
|
||
confidence: d.confidence ?? 0,
|
||
jaccard: d.jaccard,
|
||
template_recall: d.template_recall,
|
||
columns_matched: d.columns_matched,
|
||
columns_in_template: d.columns_in_template,
|
||
columns_in_csv: d.columns_in_csv,
|
||
})
|
||
seen.add(row.id)
|
||
}
|
||
}
|
||
for (const row of flat) {
|
||
if (!seen.has(row.id)) {
|
||
out.push({
|
||
id: row.id,
|
||
module: row.module,
|
||
name: row.name,
|
||
is_system: row.is_system,
|
||
confidence: 0,
|
||
})
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
function SampleTable({ sampleRows, columns }) {
|
||
if (!sampleRows?.length || !columns?.length) return null
|
||
const showCols = columns.slice(0, 8)
|
||
return (
|
||
<div style={{ overflowX: 'auto', marginTop: 12 }}>
|
||
<table style={{ width: '100%', fontSize: 12, borderCollapse: 'collapse' }}>
|
||
<thead>
|
||
<tr>
|
||
{showCols.map((c) => (
|
||
<th
|
||
key={c}
|
||
style={{
|
||
textAlign: 'left',
|
||
padding: '8px 6px',
|
||
borderBottom: '1px solid var(--border)',
|
||
color: 'var(--text2)',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
>
|
||
{c}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{sampleRows.slice(0, 5).map((row, i) => (
|
||
<tr key={i}>
|
||
{showCols.map((c) => (
|
||
<td key={c} style={csvPreviewTdStyle(row[c] ?? '—')}>
|
||
{row[c] ?? '—'}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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)
|
||
const [mappingChoices, setMappingChoices] = useState([])
|
||
const [mappingId, setMappingId] = useState('')
|
||
const [loadingAnalyze, setLoadingAnalyze] = useState(false)
|
||
const [loadingImport, setLoadingImport] = useState(false)
|
||
const [loadingDiagnose, setLoadingDiagnose] = useState(false)
|
||
const [error, setError] = useState(null)
|
||
const [success, setSuccess] = useState(null)
|
||
const [lastImport, setLastImport] = useState(null)
|
||
const [diagnoseResult, setDiagnoseResult] = useState(null)
|
||
|
||
const selectedChoice = useMemo(
|
||
() => mappingChoices.find((c) => String(c.id) === String(mappingId)),
|
||
[mappingChoices, mappingId],
|
||
)
|
||
const importAllowed = selectedChoice && EXECUTOR_READY.has(selectedChoice.module)
|
||
|
||
const runAnalyze = async (fileToAnalyze) => {
|
||
if (!fileToAnalyze) return
|
||
const gen = ++analyzeGenRef.current
|
||
setLoadingAnalyze(true)
|
||
setError(null)
|
||
setSuccess(null)
|
||
setMappingId('')
|
||
try {
|
||
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)
|
||
|
||
const best = res.recommended || (res.detected_mappings || [])[0]
|
||
let pick = ''
|
||
if (best && merged.some((m) => m.id === best.mapping_id)) {
|
||
pick = String(best.mapping_id)
|
||
} else if (merged.length) {
|
||
const firstReady = merged.find((m) => EXECUTOR_READY.has(m.module))
|
||
pick = String((firstReady || merged[0]).id)
|
||
}
|
||
setMappingId(pick)
|
||
} catch (e) {
|
||
if (gen !== analyzeGenRef.current) return
|
||
setError(e.message || 'Analyse fehlgeschlagen')
|
||
setAnalyzeResult(null)
|
||
setMappingChoices([])
|
||
} finally {
|
||
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)
|
||
setLastImport(null)
|
||
setDiagnoseResult(null)
|
||
void runAnalyze(f)
|
||
}
|
||
|
||
const handleImport = async () => {
|
||
if (!file || !mappingId) {
|
||
setError('Bitte Datei und Vorlage wählen')
|
||
return
|
||
}
|
||
if (!importAllowed) {
|
||
setError('Diese Vorlage wird vom Universal-Importer noch nicht unterstützt.')
|
||
return
|
||
}
|
||
setLoadingImport(true)
|
||
setError(null)
|
||
setSuccess(null)
|
||
try {
|
||
const res = await api.importUniversalCsv(file, Number(mappingId))
|
||
setLastImport(res)
|
||
const st = res.stats || {}
|
||
const modLabel = MODULE_LABEL[res.module] || res.module || ''
|
||
setSuccess(
|
||
(modLabel ? `${modLabel}: ` : '') +
|
||
`Import fertig — ${st.imported ?? 0} neu, ${st.updated ?? 0} aktualisiert, ` +
|
||
`${st.skipped ?? 0} übersprungen, ${st.errors ?? 0} Zeilenfehler.`,
|
||
)
|
||
} catch (e) {
|
||
setError(e.message || 'Import fehlgeschlagen')
|
||
} finally {
|
||
setLoadingImport(false)
|
||
}
|
||
}
|
||
|
||
const runDiagnose = async () => {
|
||
if (!file || !mappingId) {
|
||
setError('Bitte Datei und Vorlage wählen')
|
||
return
|
||
}
|
||
setLoadingDiagnose(true)
|
||
setError(null)
|
||
setDiagnoseResult(null)
|
||
try {
|
||
const res = await api.diagnoseUniversalCsv(file, Number(mappingId))
|
||
setDiagnoseResult(res)
|
||
} catch (e) {
|
||
setError(e.message || 'Diagnose fehlgeschlagen')
|
||
} finally {
|
||
setLoadingDiagnose(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="capture-page" style={{ paddingBottom: 88 }}>
|
||
<button
|
||
type="button"
|
||
onClick={() => navigate(-1)}
|
||
className="btn btn-secondary"
|
||
style={{
|
||
marginBottom: 16,
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
padding: '8px 12px',
|
||
}}
|
||
>
|
||
<ArrowLeft size={18} /> Zurück
|
||
</button>
|
||
|
||
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<FileSpreadsheet size={26} strokeWidth={2} />
|
||
CSV-Import
|
||
</h1>
|
||
<p style={{ fontSize: 14, color: 'var(--text2)', marginBottom: 20, lineHeight: 1.55 }}>
|
||
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>
|
||
|
||
{error && (
|
||
<div
|
||
className="card"
|
||
style={{
|
||
marginBottom: 16,
|
||
padding: 12,
|
||
borderColor: 'var(--danger)',
|
||
color: 'var(--danger)',
|
||
}}
|
||
>
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{success && (
|
||
<div
|
||
className="card"
|
||
style={{
|
||
marginBottom: 16,
|
||
padding: 12,
|
||
borderColor: 'var(--accent)',
|
||
color: 'var(--accent-dark)',
|
||
}}
|
||
>
|
||
{success}
|
||
</div>
|
||
)}
|
||
|
||
{lastImport?.error_details?.length > 0 && (
|
||
<details
|
||
className="card"
|
||
style={{ marginBottom: 16, padding: 16, cursor: 'pointer' }}
|
||
open
|
||
>
|
||
<summary style={{ fontWeight: 600, color: 'var(--text1)' }}>
|
||
Zeilenfehler vom letzten Import ({lastImport.error_details.length}) — zum Kopieren aufklappen
|
||
</summary>
|
||
<pre
|
||
style={{
|
||
marginTop: 12,
|
||
fontSize: 12,
|
||
overflow: 'auto',
|
||
maxHeight: 320,
|
||
background: 'var(--surface2)',
|
||
padding: 12,
|
||
borderRadius: 8,
|
||
color: 'var(--text1)',
|
||
}}
|
||
>
|
||
{JSON.stringify(lastImport.error_details, null, 2)}
|
||
</pre>
|
||
</details>
|
||
)}
|
||
|
||
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
|
||
<div className="form-label">1. CSV-Datei</div>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept=".csv,text/csv"
|
||
className="form-input"
|
||
style={{ position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' }}
|
||
onChange={(e) => assignCsvFile(e.target.files?.[0] || null)}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
onDragEnter={(e) => {
|
||
e.preventDefault()
|
||
setDragActive(true)
|
||
}}
|
||
onDragOver={(e) => {
|
||
e.preventDefault()
|
||
setDragActive(true)
|
||
}}
|
||
onDragLeave={(e) => {
|
||
e.preventDefault()
|
||
if (!e.currentTarget.contains(e.relatedTarget)) setDragActive(false)
|
||
}}
|
||
onDrop={(e) => {
|
||
e.preventDefault()
|
||
setDragActive(false)
|
||
assignCsvFile(e.dataTransfer?.files?.[0] || null)
|
||
}}
|
||
className="btn btn-secondary"
|
||
style={{
|
||
marginTop: 8,
|
||
width: '100%',
|
||
minHeight: 120,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 10,
|
||
border: `2px dashed ${dragActive ? 'var(--accent)' : 'var(--border)'}`,
|
||
borderRadius: 12,
|
||
background: dragActive ? 'var(--surface2)' : 'var(--surface)',
|
||
cursor: 'pointer',
|
||
padding: 16,
|
||
}}
|
||
>
|
||
<Upload size={28} strokeWidth={1.75} color="var(--accent)" />
|
||
<span style={{ fontSize: 15, color: 'var(--text1)', fontWeight: 600 }}>
|
||
Datei ablegen oder tippen zum Auswählen
|
||
</span>
|
||
<span style={{ fontSize: 13, color: 'var(--text3)' }}>
|
||
{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>
|
||
{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 && (
|
||
<div
|
||
style={{
|
||
marginTop: 12,
|
||
padding: 12,
|
||
borderRadius: 12,
|
||
background: 'var(--surface2)',
|
||
fontSize: 14,
|
||
}}
|
||
>
|
||
<strong>Vorschlag:</strong>{' '}
|
||
{MODULE_LABEL[analyzeResult.recommended.module] || analyzeResult.recommended.module} —{' '}
|
||
{analyzeResult.recommended.mapping_name}.
|
||
<br />
|
||
<span style={{ fontSize: 13, color: 'var(--text2)', fontWeight: 500 }}>
|
||
Vorlage abgedeckt:{' '}
|
||
<strong>{Math.round((analyzeResult.recommended.confidence || 0) * 100)} %</strong>
|
||
{analyzeResult.recommended.columns_matched != null &&
|
||
analyzeResult.recommended.columns_in_template != null
|
||
? ` (${analyzeResult.recommended.columns_matched}/${analyzeResult.recommended.columns_in_template} erwartete Spalten in der Datei)`
|
||
: ''}
|
||
.{' '}
|
||
{analyzeResult.recommended.jaccard != null && (
|
||
<>
|
||
Jaccard{' '}
|
||
<strong>{Math.round(analyzeResult.recommended.jaccard * 100)} %</strong> (gesamte
|
||
Spalten-Überlappung — niedriger, wenn die CSV viele Zusatzspalten hat).
|
||
</>
|
||
)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{(analyzeResult.detected_mappings || []).length > 1 && (
|
||
<details style={{ marginTop: 12, fontSize: 13 }}>
|
||
<summary style={{ cursor: 'pointer', color: 'var(--text2)' }}>Weitere Treffer</summary>
|
||
<ul style={{ margin: '8px 0 0 18px', padding: 0 }}>
|
||
{analyzeResult.detected_mappings.slice(1, 8).map((d) => (
|
||
<li key={d.mapping_id}>
|
||
{MODULE_LABEL[d.module] || d.module}: {d.mapping_name} · Vorlage{' '}
|
||
{Math.round((d.confidence || 0) * 100)} %
|
||
{d.jaccard != null ? ` · Jaccard ${Math.round(d.jaccard * 100)} %` : ''}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</details>
|
||
)}
|
||
|
||
<SampleTable sampleRows={analyzeResult.sample_rows} columns={analyzeResult.columns} />
|
||
|
||
<div className="form-label" style={{ marginTop: 16 }}>
|
||
Import-Vorlage
|
||
</div>
|
||
<select
|
||
className="form-input"
|
||
value={mappingId}
|
||
onChange={(e) => setMappingId(e.target.value)}
|
||
style={{
|
||
width: '100%',
|
||
marginTop: 8,
|
||
minHeight: 48,
|
||
textAlign: 'left',
|
||
padding: '12px 14px',
|
||
fontSize: 15,
|
||
}}
|
||
>
|
||
{mappingChoices.length === 0 ? (
|
||
<option value="">Keine Vorlage geladen</option>
|
||
) : (
|
||
mappingChoices.map((o) => (
|
||
<option key={o.id} value={o.id}>
|
||
{MODULE_LABEL[o.module] || o.module} — {o.name}
|
||
{o.is_system ? ' (System)' : ''}
|
||
{o.confidence > 0
|
||
? ` · Vorlage ${Math.round(o.confidence * 100)} %${
|
||
o.jaccard != null ? ` · Jaccard ${Math.round(o.jaccard * 100)} %` : ''
|
||
}`
|
||
: ''}
|
||
{!EXECUTOR_READY.has(o.module) ? ' · Import: noch nicht hier' : ''}
|
||
</option>
|
||
))
|
||
)}
|
||
</select>
|
||
|
||
{selectedChoice && !importAllowed && (
|
||
<p style={{ fontSize: 13, color: 'var(--text3)', marginTop: 10 }}>
|
||
Diese Vorlage passt strukturell; dieser Import-Weg unterstützt das Zielmodul noch nicht.
|
||
</p>
|
||
)}
|
||
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 16 }}>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
style={{ width: '100%' }}
|
||
disabled={!file || !mappingId || !importAllowed || loadingDiagnose}
|
||
onClick={() => void runDiagnose()}
|
||
>
|
||
{loadingDiagnose ? (
|
||
<>
|
||
<Loader2
|
||
size={18}
|
||
style={{ marginRight: 8, animation: 'spin 0.7s linear infinite' }}
|
||
/>{' '}
|
||
Diagnose …
|
||
</>
|
||
) : (
|
||
'Mapping prüfen (ohne Import)'
|
||
)}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary"
|
||
style={{ width: '100%' }}
|
||
disabled={!file || !mappingId || !importAllowed || loadingImport}
|
||
onClick={handleImport}
|
||
>
|
||
{loadingImport ? (
|
||
<>
|
||
<Loader2 size={18} style={{ marginRight: 8, animation: 'spin 0.7s linear infinite' }} /> Import
|
||
läuft …
|
||
</>
|
||
) : (
|
||
'Import starten'
|
||
)}
|
||
</button>
|
||
</div>
|
||
|
||
{diagnoseResult && (
|
||
<details style={{ marginTop: 20 }} open>
|
||
<summary style={{ cursor: 'pointer', fontWeight: 600, color: 'var(--text2)' }}>
|
||
Diagnose-Ergebnis ({diagnoseResult.rows_diagnosed ?? 0} Zeilen)
|
||
</summary>
|
||
<p style={{ fontSize: 13, color: 'var(--text3)', marginTop: 8, lineHeight: 1.5 }}>
|
||
Vorlage #{diagnoseResult.mapping_id} · {diagnoseResult.mapping_name} · Modul{' '}
|
||
{MODULE_LABEL[diagnoseResult.module] || diagnoseResult.module}. Hinweise: Vitalwerte{' '}
|
||
<code>vitals.*</code>, Blutdruck <code>blood_pressure.*</code>, Workouts{' '}
|
||
<code>activity.*</code> (z. B. <code>would_pass_row_gate</code> /{' '}
|
||
<code>prefilter_fail_reason</code>).
|
||
</p>
|
||
<pre
|
||
style={{
|
||
marginTop: 8,
|
||
fontSize: 11,
|
||
overflow: 'auto',
|
||
maxHeight: 480,
|
||
background: 'var(--surface2)',
|
||
padding: 12,
|
||
borderRadius: 8,
|
||
color: 'var(--text1)',
|
||
}}
|
||
>
|
||
{JSON.stringify(diagnoseResult, null, 2)}
|
||
</pre>
|
||
</details>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|