import { useEffect, useMemo, useState } from 'react' import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' import { FileText, X } from 'lucide-react' import type { LogEventPayload } from '../utils/logEntryPayload.js' import { sortLogEventsByTime } from '../utils/logEntryPayload.js' import { parseNmeaFile, nmeaPointsToWaypoints } from '../services/nmea/nmeaParse.js' import { filterPointsForDate } from '../services/nmea/nmeaTimeSeries.js' import { generateNmeaJournalCandidates } from '../services/nmea/nmeaJournalGenerator.js' import type { NmeaImportMode, NmeaParseResult } from '../services/nmea/nmeaTypes.js' import { saveNmeaArchive, recordNmeaFileImport, type NmeaArchiveRecord } from '../services/nmeaArchive.js' import { nmeaFileCrc32 } from '../utils/crc32.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import type { TrackWaypoint } from '../services/trackUpload.js' interface NmeaImportWizardProps { open: boolean onClose: () => void logbookId: string entryId: string entryDate: string nmeaArchive: NmeaArchiveRecord | null onImport: (events: LogEventPayload[], waypoints?: TrackWaypoint[]) => void } type WizardStep = 'config' | 'preview' | 'archive' export default function NmeaImportWizard({ open, onClose, logbookId, entryId, entryDate, nmeaArchive, onImport }: NmeaImportWizardProps) { const { t } = useTranslation() const [step, setStep] = useState('config') const [parseResult, setParseResult] = useState(null) const [mode, setMode] = useState('both') const [intervalMinutes, setIntervalMinutes] = useState(60) const [importTrack, setImportTrack] = useState(true) const [selectedIds, setSelectedIds] = useState>(new Set()) const [error, setError] = useState(null) const [pendingRaw, setPendingRaw] = useState<{ filename: string; text: string } | null>(null) const [duplicateFile, setDuplicateFile] = useState(false) const filteredPoints = useMemo(() => { if (!parseResult) return [] return filterPointsForDate(parseResult.points, entryDate) }, [parseResult, entryDate]) const candidates = useMemo(() => { if (!parseResult || filteredPoints.length === 0) return [] return generateNmeaJournalCandidates({ points: filteredPoints, mode, intervalMinutes, t }).candidates }, [parseResult, filteredPoints, mode, intervalMinutes, t]) const reset = () => { setStep('config') setParseResult(null) setMode('both') setIntervalMinutes(60) setImportTrack(true) setSelectedIds(new Set()) setError(null) setDuplicateFile(false) setPendingRaw(null) } const handleClose = () => { reset() onClose() } const handleFile = (file: File) => { setError(null) setDuplicateFile(false) const reader = new FileReader() reader.onload = () => { try { const text = String(reader.result ?? '') const crc32 = nmeaFileCrc32(text) const alreadyImported = nmeaArchive?.importedFiles.some((item) => item.crc32 === crc32) ?? false setDuplicateFile(alreadyImported) const result = parseNmeaFile(text, file.name) if (result.points.length === 0) { setError(t('logs.nmea_error_no_samples')) return } setParseResult(result) setPendingRaw({ filename: file.name, text }) const generated = generateNmeaJournalCandidates({ points: filterPointsForDate(result.points, entryDate), mode, intervalMinutes, t }).candidates setSelectedIds(new Set(generated.map((c) => c.id))) trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, { duplicate: alreadyImported, lines: result.stats.parsedLines, candidates: generated.length, has_position: !result.warnings.includes('no_position') }) } catch (err) { setError(err instanceof Error ? err.message : t('logs.nmea_error_parse')) } } reader.onerror = () => setError(t('logs.nmea_error_read')) reader.readAsText(file) } const toggleAll = (checked: boolean) => { setSelectedIds(checked ? new Set(candidates.map((c) => c.id)) : new Set()) } const toggleOne = (id: string) => { setSelectedIds((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) } const goPreview = () => { if (!parseResult) { setError(t('logs.nmea_error_no_file')) return } const generated = generateNmeaJournalCandidates({ points: filteredPoints, mode, intervalMinutes, t }).candidates setSelectedIds(new Set(generated.map((c) => c.id))) setStep('preview') } const applyImport = async () => { const picked = candidates.filter((c) => selectedIds.has(c.id)).map((c) => c.event) if (picked.length === 0) { setError(t('logs.nmea_error_no_selection')) return } const waypoints = importTrack ? nmeaPointsToWaypoints(filteredPoints) : undefined onImport(sortLogEventsByTime(picked), waypoints) if (pendingRaw) { try { await recordNmeaFileImport(logbookId, entryId, pendingRaw.filename, pendingRaw.text) } catch (err) { console.warn('NMEA import CRC record failed:', err) } } trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode, events: picked.length, track: importTrack && (waypoints?.length ?? 0) > 0 }) setStep('archive') } const finishArchive = async (archive: boolean) => { try { if (archive && pendingRaw) { await saveNmeaArchive(logbookId, entryId, pendingRaw.filename, pendingRaw.text) } } catch (err) { console.warn('NMEA archive save failed:', err) } handleClose() } useEffect(() => { if (!open) return const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') handleClose() } window.addEventListener('keydown', onKeyDown) const prevOverflow = document.body.style.overflow document.body.style.overflow = 'hidden' return () => { window.removeEventListener('keydown', onKeyDown) document.body.style.overflow = prevOverflow } }, [open]) if (!open) return null return createPortal(
e.stopPropagation()}>

{t('logs.nmea_import_title')}

{error &&
{error}
} {duplicateFile && (
{t('logs.nmea_warn_duplicate_file')}
)} {step === 'config' && ( <>

{t('logs.nmea_import_intro')}

{parseResult && (

{t('logs.nmea_stats', { lines: parseResult.stats.parsedLines, types: parseResult.stats.sentenceTypes.join(', ') })}

{parseResult.warnings.includes('no_position') && (

{t('logs.nmea_warn_no_position')}

)}
)}
{t('logs.nmea_mode_label')}
{(mode === 'interval' || mode === 'both') && ( )}
)} {step === 'preview' && ( <>

{t('logs.nmea_preview_hint', { count: candidates.length })}

{candidates.map((c) => ( ))}
)} {step === 'archive' && ( <>

{t('logs.nmea_archive_question')}

)}
, document.body ) }