import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { db } from '../services/db.js' import { getActiveMasterKey } from '../services/auth.js' import { getLogbookKey } from '../services/logbookKeys.js' import { encryptJson, decryptJson } from '../services/crypto.js' import { syncLogbook } from '../services/sync.js' import { saveEntryDraft, clearEntryDraft } from '../services/entryDraft.js' import { getErrorMessage } from '../utils/errors.js' import { downloadLogbookPagePdf } from '../services/pdfExport.js' import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp } from 'lucide-react' import PhotoCapture from './PhotoCapture.tsx' import SignatureSection from './SignatureSection.tsx' import EntryCrewSection from './EntryCrewSection.tsx' import { emptyEntryCrewFields, type EntryCrewFields } from '../types/person.js' import { entryCrewFromPreviousEntry } from '../utils/personSnapshots.js' import TrackMap from './TrackMap.tsx' import { useDialog } from './ModalDialog.tsx' import { normalizeSignature, fingerprintSignature, normalizedSerializedSignature, isPasskeySignature, isClassicSignature, createClassicSignature, isSignatureValidForEntry, hasAnySignature } from '../utils/signatures.js' import type { SignatureValue } from '../types/signatures.js' import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, hasUnsavedEventDraft, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js' import EventTimeInput24h from './EventTimeInput24h.tsx' import CourseDialInput from './CourseDialInput.tsx' import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js' import { hashEntryForSigning } from '../utils/entryCanonicalHash.js' import { signLogEntry } from '../services/entrySigning.js' import { getLogbookAccess } from '../services/logbookAccess.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js' import { getDecryptedTrack, saveUploadedTrack, deleteTrack, downloadTrackFile, parseTrackFile, type SavedTrack, type TrackWaypoint } from '../services/trackUpload.js' import NmeaImportWizard from './NmeaImportWizard.tsx' import { deleteNmeaArchive, downloadNmeaArchive, getNmeaArchive, type NmeaArchiveRecord } from '../services/nmeaArchive.js' import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js' import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js' import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx' import TankLiterInput from './TankLiterInput.tsx' import { computeEveningTankMaxLiters, computeRefilledTankMaxLiters, extractTankCapacitiesFromYacht, formatTankLitersForInput, type VesselTankCapacities } from '../utils/tankCapacity.js' function emptyTankLevels() { return { morning: 0, refilled: 0, evening: 0, consumption: 0 } } function fingerprintFromStoredEntry(decrypted: Record): string { const fw = (decrypted.freshwater as Record | undefined) ?? emptyTankLevels() const fuel = (decrypted.fuel as Record | undefined) ?? emptyTankLevels() const gw = decrypted.greywater as { level?: number } | undefined const trackDistance = decrypted.trackDistanceNm const trackSpeedMax = decrypted.trackSpeedMaxKn const trackSpeedAvg = decrypted.trackSpeedAvgKn const motorHoursRaw = decrypted.motorHours const payload = buildLogEntryPayload({ date: String(decrypted.date || ''), dayOfTravel: String(decrypted.dayOfTravel || ''), departure: String(decrypted.departure || ''), destination: String(decrypted.destination || ''), freshwater: { morning: fw.morning || 0, refilled: fw.refilled || 0, evening: fw.evening || 0, consumption: fw.consumption ?? 0 }, fuel: { morning: fuel.morning || 0, refilled: fuel.refilled || 0, evening: fuel.evening || 0, consumption: fuel.consumption ?? 0 }, greywater: gw ? { level: gw.level || 0 } : undefined, trackDistanceNm: trackDistance != null && trackDistance !== '' ? parseFloat(String(trackDistance)) : undefined, trackSpeedMaxKn: trackSpeedMax != null && trackSpeedMax !== '' ? parseFloat(String(trackSpeedMax)) : undefined, trackSpeedAvgKn: trackSpeedAvg != null && trackSpeedAvg !== '' ? parseFloat(String(trackSpeedAvg)) : undefined, motorHours: motorHoursRaw != null && motorHoursRaw !== '' ? parseFloat(String(motorHoursRaw)) : undefined, events: (decrypted.events as LogEventPayload[]) || [], entryCrew: entryCrewFromPreviousEntry(decrypted as Record) }) return JSON.stringify({ ...payload, signSkipper: fingerprintSignature(decrypted.signSkipper), signCrew: fingerprintSignature(decrypted.signCrew) }) } interface LogEntryEditorProps { entryId: string logbookId: string onBack: () => void readOnly?: boolean preloadedEntry?: any preloadedPhotos?: any[] preloadedTrack?: any preloadedYacht?: any } interface LogEvent extends LogEventPayload {} export default function LogEntryEditor({ entryId, logbookId, onBack, readOnly = false, preloadedEntry, preloadedPhotos, preloadedTrack, preloadedYacht }: LogEntryEditorProps) { const { t, i18n } = useTranslation() const { showAlert, showConfirm } = useDialog() const showAlertRef = useRef(showAlert) showAlertRef.current = showAlert // General details state const [date, setDate] = useState('') const [dayOfTravel, setDayOfTravel] = useState('') const [yachtSails, setYachtSails] = useState([]) const [departure, setDeparture] = useState('') const [destination, setDestination] = useState('') // Freshwater state const [fwMorning, setFwMorning] = useState('0') const [fwRefilled, setFwRefilled] = useState('0') const [fwEvening, setFwEvening] = useState('0') const [fwConsumption, setFwConsumption] = useState('0') // Fuel state const [fuelMorning, setFuelMorning] = useState('0') const [fuelRefilled, setFuelRefilled] = useState('0') const [fuelEvening, setFuelEvening] = useState('0') const [fuelConsumption, setFuelConsumption] = useState('0') const [greywaterLevel, setGreywaterLevel] = useState('0') const [tankCapacities, setTankCapacities] = useState({}) const [entryCrew, setEntryCrew] = useState(emptyEntryCrewFields()) // Signatures const [signSkipper, setSignSkipper] = useState('') const [signCrew, setSignCrew] = useState('') const [canSignSkipper, setCanSignSkipper] = useState(false) const [canSignCrew, setCanSignCrew] = useState(false) const [isOnline, setIsOnline] = useState(navigator.onLine) const [entryHash, setEntryHash] = useState('') // GPS track stats (from uploaded track) const [trackDistanceNm, setTrackDistanceNm] = useState('') const [trackSpeedMaxKn, setTrackSpeedMaxKn] = useState('') const [trackSpeedAvgKn, setTrackSpeedAvgKn] = useState('') // Motor hours under engine propulsion (per travel day) const [motorHours, setMotorHours] = useState('') // Events list state const [events, setEvents] = useState([]) // Add Event Form State const [evTime, setEvTime] = useState(() => currentLocalTimeHHMM()) const [evMgk, setEvMgk] = useState('') const [evRwk, setEvRwk] = useState('') const [evWindPressure, setEvWindPressure] = useState('') const [evWindDirection, setEvWindDirection] = useState('') const [evWindStrength, setEvWindStrength] = useState('') const [evSeaState, setEvSeaState] = useState('') const [evWeatherIcon, setEvWeatherIcon] = useState('') const [evCurrent, setEvCurrent] = useState('') const [evHeel, setEvHeel] = useState('') const [evSailsOrMotor, setEvSailsOrMotor] = useState('') const [sailsPickerExpanded, setSailsPickerExpanded] = useState(false) const [evLogReading, setEvLogReading] = useState('') const [evDistance, setEvDistance] = useState('') const [evGpsLat, setEvGpsLat] = useState('') const [evGpsLng, setEvGpsLng] = useState('') const [evRemarks, setEvRemarks] = useState('') const [evLocationName, setEvLocationName] = useState('') const [activeCourseTab, setActiveCourseTab] = useState<'mgk' | 'rwk'>('mgk') const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) const [exporting, setExporting] = useState(false) const [success, setSuccess] = useState(false) const [error, setError] = useState(null) const [weatherLoading, setWeatherLoading] = useState(false) const [savedFingerprint, setSavedFingerprint] = useState(null) // Track file upload const [savedTrack, setSavedTrack] = useState(null) const [dragOver, setDragOver] = useState(false) const [uploadError, setUploadError] = useState(null) const [nmeaWizardOpen, setNmeaWizardOpen] = useState(false) const [nmeaArchive, setNmeaArchive] = useState(null) const fileInputRef = useRef(null) const lockedContentHashRef = useRef(null) const contentReadyRef = useRef(false) const lastSignatureAlertHashRef = useRef(null) const skipCrewSignClearRef = useRef(false) const entryHashSeqRef = useRef(0) const [editingEventIndex, setEditingEventIndex] = useState(null) const applyTrackStats = (waypoints: SavedTrack['waypoints']) => { const stats = computeTrackStats(waypoints) if (!stats) return const formatted = formatTrackStats(stats) setTrackDistanceNm(formatted.distanceNm) setTrackSpeedMaxKn(formatted.speedMaxKn) setTrackSpeedAvgKn(formatted.speedAvgKn) } const loadTrackStatsFromEntry = (entry: any) => { if (entry?.trackDistanceNm != null && entry.trackDistanceNm !== '') { setTrackDistanceNm(String(entry.trackDistanceNm)) } if (entry?.trackSpeedMaxKn != null && entry.trackSpeedMaxKn !== '') { setTrackSpeedMaxKn(String(entry.trackSpeedMaxKn)) } if (entry?.trackSpeedAvgKn != null && entry.trackSpeedAvgKn !== '') { setTrackSpeedAvgKn(String(entry.trackSpeedAvgKn)) } if (entry?.motorHours != null && entry.motorHours !== '') { setMotorHours(String(entry.motorHours)) } else { setMotorHours('') } } const buildPayloadForSigning = useCallback((eventsOverride?: LogEvent[]) => { return buildLogEntryPayload({ date, dayOfTravel, departure, destination, freshwater: { morning: parseFloat(fwMorning) || 0, refilled: parseFloat(fwRefilled) || 0, evening: parseFloat(fwEvening) || 0, consumption: parseFloat(fwConsumption) || 0 }, fuel: { morning: parseFloat(fuelMorning) || 0, refilled: parseFloat(fuelRefilled) || 0, evening: parseFloat(fuelEvening) || 0, consumption: parseFloat(fuelConsumption) || 0 }, greywater: { level: parseFloat(greywaterLevel) || 0 }, trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined, trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined, trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined, motorHours: motorHours.trim() ? parseFloat(motorHours) : undefined, events: eventsOverride ?? events, entryCrew }) }, [ date, dayOfTravel, departure, destination, fwMorning, fwRefilled, fwEvening, fwConsumption, fuelMorning, fuelRefilled, fuelEvening, fuelConsumption, greywaterLevel, trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours, events, entryCrew ]) useEffect(() => { if (readOnly || loading || !date) return const timer = window.setTimeout(() => { void saveEntryDraft(logbookId, entryId, buildPayloadForSigning()) }, 4000) return () => window.clearTimeout(timer) }, [readOnly, loading, logbookId, entryId, buildPayloadForSigning, date]) const fuelPerMotorHour = useMemo( () => computeFuelPerMotorHour(parseFloat(fuelConsumption) || 0, parseFloat(motorHours) || 0), [fuelConsumption, motorHours] ) const tankCapacityTooltip = t('logs.tank_capacity_tooltip') const fwRefilledMax = useMemo( () => computeRefilledTankMaxLiters(fwMorning, tankCapacities.freshwaterCapacityL), [fwMorning, tankCapacities.freshwaterCapacityL] ) const fwEveningMax = useMemo( () => computeEveningTankMaxLiters( fwMorning, fwRefilled, tankCapacities.freshwaterCapacityL ), [fwMorning, fwRefilled, tankCapacities.freshwaterCapacityL] ) const fuelRefilledMax = useMemo( () => computeRefilledTankMaxLiters(fuelMorning, tankCapacities.fuelCapacityL), [fuelMorning, tankCapacities.fuelCapacityL] ) const fuelEveningMax = useMemo( () => computeEveningTankMaxLiters( fuelMorning, fuelRefilled, tankCapacities.fuelCapacityL ), [fuelMorning, fuelRefilled, tankCapacities.fuelCapacityL] ) const currentFingerprint = useMemo(() => { const payload = buildPayloadForSigning() return JSON.stringify({ ...payload, signSkipper: fingerprintSignature(signSkipper), signCrew: fingerprintSignature(signCrew) }) }, [buildPayloadForSigning, signSkipper, signCrew]) const buildEventFromForm = (): LogEvent => normalizeLogEvent({ time: evTime, mgk: evMgk, rwk: evRwk, windPressure: evWindPressure, windDirection: evWindDirection, windStrength: evWindStrength, seaState: evSeaState, weatherIcon: evWeatherIcon, current: evCurrent, heel: evHeel, sailsOrMotor: evSailsOrMotor, logReading: evLogReading, distance: evDistance, gpsLat: evGpsLat, gpsLng: evGpsLng, remarks: evRemarks }) const applyEventFormToEvents = (eventData: LogEvent): LogEvent[] => { if (editingEventIndex !== null) { return sortLogEventsByTime(events.map((ev, idx) => (idx === editingEventIndex ? eventData : ev))) } return sortLogEventsByTime([...events, eventData]) } const hasPendingEventForm = useMemo(() => { return hasUnsavedEventDraft(buildEventFromForm(), editingEventIndex, events) }, [ evTime, evMgk, evRwk, evWindPressure, evWindDirection, evWindStrength, evSeaState, evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance, evGpsLat, evGpsLng, evRemarks, editingEventIndex, events ]) const isDirty = savedFingerprint !== null && ( currentFingerprint !== savedFingerprint || hasPendingEventForm ) const { confirmLeave } = useRegisterUnsavedChanges( `log-entry-${entryId}`, !readOnly && !loading && isDirty ) const handleBack = async () => { if (!(await confirmLeave())) return onBack() } const persistEntryToDb = useCallback(async ( options?: LogEvent[] | { eventsOverride?: LogEvent[] signSkipper?: SignatureValue | '' signCrew?: SignatureValue | '' } ) => { if (readOnly) return const normalized = Array.isArray(options) ? { eventsOverride: options } : (options ?? {}) const eventsOverride = normalized.eventsOverride const skipperToSave = normalized.signSkipper !== undefined ? normalized.signSkipper : signSkipper const crewToSave = normalized.signCrew !== undefined ? normalized.signCrew : signCrew const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() if (!masterKey) throw new Error('Encryption key not found. Please log in.') const entryData = { ...buildPayloadForSigning(eventsOverride), signSkipper: normalizedSerializedSignature(skipperToSave), signCrew: normalizedSerializedSignature(crewToSave) } const encrypted = await encryptJson(entryData, masterKey) const now = new Date().toISOString() await db.entries.put({ payloadId: entryId, logbookId, encryptedData: encrypted.ciphertext, iv: encrypted.iv, tag: encrypted.tag, updatedAt: now }) await db.syncQueue.put({ action: 'update', type: 'entry', payloadId: entryId, logbookId, data: JSON.stringify(encrypted), updatedAt: now }) syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err)) setSavedFingerprint(JSON.stringify({ ...buildPayloadForSigning(eventsOverride), signSkipper: fingerprintSignature(skipperToSave), signCrew: fingerprintSignature(crewToSave) })) const hash = await hashEntryForSigning(buildPayloadForSigning(eventsOverride)) entryHashSeqRef.current += 1 setEntryHash(hash) lockedContentHashRef.current = hasAnySignature(skipperToSave, crewToSave) ? hash : null }, [ readOnly, logbookId, entryId, events, buildPayloadForSigning, signSkipper, signCrew ]) useEffect(() => { const handleOnline = () => setIsOnline(true) const handleOffline = () => setIsOnline(false) window.addEventListener('online', handleOnline) window.addEventListener('offline', handleOffline) return () => { window.removeEventListener('online', handleOnline) window.removeEventListener('offline', handleOffline) } }, []) useEffect(() => { getLogbookAccess(logbookId).then((access) => { if (!access) return setCanSignSkipper(access.isOwner) setCanSignCrew( access.role === 'WRITE' || (access.isOwner && access.writeCollaboratorCount === 0) ) }) }, [logbookId]) useEffect(() => { const seq = ++entryHashSeqRef.current let cancelled = false hashEntryForSigning(buildPayloadForSigning()).then((hash) => { if (cancelled || seq !== entryHashSeqRef.current) return setEntryHash(hash) }) return () => { cancelled = true } }, [buildPayloadForSigning]) useEffect(() => { contentReadyRef.current = false if (loading) return const timer = window.setTimeout(() => { contentReadyRef.current = true }, 0) return () => window.clearTimeout(timer) }, [loading]) useEffect(() => { if (!entryHash || !contentReadyRef.current || readOnly) return const hasSig = hasAnySignature(signSkipper, signCrew) if (!hasSig) { lockedContentHashRef.current = null return } if (!lockedContentHashRef.current) { lockedContentHashRef.current = entryHash return } if (entryHash !== lockedContentHashRef.current) { lockedContentHashRef.current = null const hadSkipper = !!signSkipper const hadCrew = !!signCrew const skipperOnly = skipCrewSignClearRef.current skipCrewSignClearRef.current = false if (hadSkipper) setSignSkipper('') if (hadCrew && !skipperOnly) setSignCrew('') if (lastSignatureAlertHashRef.current !== entryHash && (hadSkipper || (hadCrew && !skipperOnly))) { lastSignatureAlertHashRef.current = entryHash void showAlertRef.current( skipperOnly ? t('logs.sign_cleared_skipper_re_sign') : t('logs.sign_cleared_re_sign'), skipperOnly ? t('logs.sign_cleared_skipper_re_sign_title') : t('logs.sign_cleared_re_sign_title') ) } } }, [entryHash, signSkipper, signCrew, readOnly, t]) const confirmSignWarning = useCallback(async (): Promise => { return showConfirm( t('logs.sign_lock_warning'), t('logs.sign_lock_warning_title'), t('logs.sign_proceed'), t('logs.sign_cancel') ) }, [showConfirm, t]) const skipperSignatureValid = !isPasskeySignature(signSkipper) || isSignatureValidForEntry(signSkipper, entryHash) const crewSignatureValid = !isPasskeySignature(signCrew) || isSignatureValidForEntry(signCrew, entryHash) const handlePasskeySignSkipper = async () => { if (!canSignSkipper) return const confirmed = await confirmSignWarning() if (!confirmed) return const hash = await hashEntryForSigning(buildPayloadForSigning()) const signature = await signLogEntry({ logbookId, entryId, entryHash: hash, role: 'skipper' }) setSignSkipper(signature) entryHashSeqRef.current += 1 setEntryHash(hash) lockedContentHashRef.current = hash trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' }) } const handlePasskeySignCrew = async () => { if (!canSignCrew) return const confirmed = await confirmSignWarning() if (!confirmed) return const hash = await hashEntryForSigning(buildPayloadForSigning()) const signature = await signLogEntry({ logbookId, entryId, entryHash: hash, role: 'crew' }) setSignCrew(signature) entryHashSeqRef.current += 1 setEntryHash(hash) lockedContentHashRef.current = hash trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'crew' }) } // Auto-calculate Freshwater Consumption useEffect(() => { const morning = parseFloat(fwMorning) || 0 const refilled = parseFloat(fwRefilled) || 0 const evening = parseFloat(fwEvening) || 0 const cons = morning + refilled - evening setFwConsumption(cons >= 0 ? String(cons) : '0') }, [fwMorning, fwRefilled, fwEvening]) // Auto-calculate Fuel Consumption useEffect(() => { const morning = parseFloat(fuelMorning) || 0 const refilled = parseFloat(fuelRefilled) || 0 const evening = parseFloat(fuelEvening) || 0 const cons = morning + refilled - evening setFuelConsumption(cons >= 0 ? String(cons) : '0') }, [fuelMorning, fuelRefilled, fuelEvening]) const fwRefilledNoCapacity = (tankCapacities.freshwaterCapacityL ?? 0) > 0 && fwRefilledMax == null const fuelRefilledNoCapacity = (tankCapacities.fuelCapacityL ?? 0) > 0 && fuelRefilledMax == null useEffect(() => { const refilled = parseFloat(fwRefilled) || 0 if (fwRefilledMax == null) { if (fwRefilledNoCapacity && refilled > 0) { setFwRefilled(formatTankLitersForInput(0)) } return } if (refilled > fwRefilledMax) { setFwRefilled(formatTankLitersForInput(fwRefilledMax)) } }, [fwRefilledMax, fwRefilled, fwRefilledNoCapacity]) useEffect(() => { if (fwEveningMax == null) return const evening = parseFloat(fwEvening) || 0 if (evening > fwEveningMax) { setFwEvening(formatTankLitersForInput(fwEveningMax)) } }, [fwEveningMax, fwEvening]) useEffect(() => { const refilled = parseFloat(fuelRefilled) || 0 if (fuelRefilledMax == null) { if (fuelRefilledNoCapacity && refilled > 0) { setFuelRefilled(formatTankLitersForInput(0)) } return } if (refilled > fuelRefilledMax) { setFuelRefilled(formatTankLitersForInput(fuelRefilledMax)) } }, [fuelRefilledMax, fuelRefilled, fuelRefilledNoCapacity]) useEffect(() => { if (fuelEveningMax == null) return const evening = parseFloat(fuelEvening) || 0 if (evening > fuelEveningMax) { setFuelEvening(formatTankLitersForInput(fuelEveningMax)) } }, [fuelEveningMax, fuelEvening]) // Load yacht sails and tank capacities useEffect(() => { async function loadYachtMeta() { if (readOnly && preloadedYacht) { if (preloadedYacht.sails) setYachtSails(preloadedYacht.sails) setTankCapacities(extractTankCapacitiesFromYacht(preloadedYacht)) return } try { const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() if (!masterKey) return const yacht = await db.yachts.get(logbookId) if (yacht) { const decrypted = await decryptJson(yacht.encryptedData, yacht.iv, yacht.tag, masterKey) if (decrypted) { if (decrypted.sails && Array.isArray(decrypted.sails)) { setYachtSails(decrypted.sails) } setTankCapacities(extractTankCapacitiesFromYacht(decrypted)) } } } catch (err) { console.error('Failed to load yacht meta in editor:', err) } } loadYachtMeta() }, [logbookId, preloadedYacht, readOnly]) // Load entry details useEffect(() => { async function loadEntry() { setLoading(true) setError(null) setSavedFingerprint(null) lockedContentHashRef.current = null contentReadyRef.current = false lastSignatureAlertHashRef.current = null try { if (readOnly && preloadedEntry) { setDate(preloadedEntry.date || '') setDayOfTravel(preloadedEntry.dayOfTravel || '') setDeparture(preloadedEntry.departure || '') setDestination(preloadedEntry.destination || '') if (preloadedEntry.freshwater) { setFwMorning(String(preloadedEntry.freshwater.morning || 0)) setFwRefilled(String(preloadedEntry.freshwater.refilled || 0)) setFwEvening(String(preloadedEntry.freshwater.evening || 0)) setFwConsumption(String(preloadedEntry.freshwater.consumption ?? 0)) } if (preloadedEntry.fuel) { setFuelMorning(String(preloadedEntry.fuel.morning || 0)) setFuelRefilled(String(preloadedEntry.fuel.refilled || 0)) setFuelEvening(String(preloadedEntry.fuel.evening || 0)) setFuelConsumption(String(preloadedEntry.fuel.consumption ?? 0)) } if (preloadedEntry.greywater) { setGreywaterLevel(String(preloadedEntry.greywater.level || 0)) } else { setGreywaterLevel('0') } setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '') setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '') setEntryCrew(entryCrewFromPreviousEntry(preloadedEntry as Record)) loadTrackStatsFromEntry(preloadedEntry) setEvents(sortLogEventsByTime((preloadedEntry.events || []).map(normalizeLogEvent))) setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry)) return } const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() if (!masterKey) throw new Error('Encryption key not found. Please log in.') const local = await db.entries.get(entryId) if (local) { const decrypted = await decryptJson(local.encryptedData, local.iv, local.tag, masterKey) if (decrypted) { setDate(decrypted.date || '') setDayOfTravel(decrypted.dayOfTravel || '') setDeparture(decrypted.departure || '') setDestination(decrypted.destination || '') if (decrypted.freshwater) { setFwMorning(String(decrypted.freshwater.morning || 0)) setFwRefilled(String(decrypted.freshwater.refilled || 0)) setFwEvening(String(decrypted.freshwater.evening || 0)) setFwConsumption(String(decrypted.freshwater.consumption ?? 0)) } if (decrypted.fuel) { setFuelMorning(String(decrypted.fuel.morning || 0)) setFuelRefilled(String(decrypted.fuel.refilled || 0)) setFuelEvening(String(decrypted.fuel.evening || 0)) setFuelConsumption(String(decrypted.fuel.consumption ?? 0)) } if (decrypted.greywater) { setGreywaterLevel(String(decrypted.greywater.level || 0)) } else { setGreywaterLevel('0') } setSignSkipper(normalizeSignature(decrypted.signSkipper) || '') setSignCrew(normalizeSignature(decrypted.signCrew) || '') setEntryCrew(entryCrewFromPreviousEntry(decrypted as Record)) loadTrackStatsFromEntry(decrypted) setEvents(sortLogEventsByTime((decrypted.events || []).map(normalizeLogEvent))) setSavedFingerprint(fingerprintFromStoredEntry(decrypted)) } } } catch (err: any) { console.error('Failed to load entry details:', err) setError(err.message || 'Decryption failed. Could not load entry details.') } finally { setLoading(false) } } loadEntry() }, [entryId, preloadedEntry]) const loadTrack = async () => { if (readOnly && preloadedTrack) { setSavedTrack({ waypoints: preloadedTrack.waypoints ?? [], gpxContent: preloadedTrack.gpxContent ?? '', filename: preloadedTrack.filename ?? 'track.gpx', fileType: preloadedTrack.fileType ?? 'gpx' }) return } try { const track = await getDecryptedTrack(entryId) setSavedTrack(track) } catch (e) { console.warn('Failed to load track file:', e) } } useEffect(() => { loadTrack() }, [entryId, preloadedTrack]) const loadNmeaArchive = async () => { if (readOnly) return try { const archive = await getNmeaArchive(entryId) setNmeaArchive(archive) } catch { setNmeaArchive(null) } } useEffect(() => { loadNmeaArchive() }, [entryId, readOnly]) const handleNmeaImport = async (importedEvents: LogEventPayload[], waypoints?: TrackWaypoint[]) => { setEvents((prev) => sortLogEventsByTime([...prev, ...importedEvents])) if (waypoints && waypoints.length > 0) { try { const gpxLike = waypoints .map((wp) => ` `) .join('\n') const content = `\n${gpxLike}\n` await saveUploadedTrack(logbookId, entryId, content, waypoints, 'imported-from-nmea.nmea', 'nmea') applyTrackStats(waypoints) await loadTrack() trackPlausibleEvent(PlausibleEvents.GPS_TRACK_UPLOADED) } catch (err: unknown) { console.warn('Failed to save NMEA track:', err) } } await loadNmeaArchive() } const handleDeleteNmeaArchive = async () => { if (!window.confirm(t('logs.nmea_archive_delete_confirm'))) return await deleteNmeaArchive(entryId) setNmeaArchive(null) } useEffect(() => { if (!savedTrack || savedTrack.waypoints.length < 2) return if (trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) return applyTrackStats(savedTrack.waypoints) }, [savedTrack, trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn]) // Track file upload handlers const handleFileUpload = async (file: File) => { if (readOnly) return setUploadError(null) const reader = new FileReader() reader.onload = async (e) => { try { const text = e.target?.result as string if (!text) { throw new Error('File is empty') } const { waypoints: parsedWps, type: fileType } = parseTrackFile(text, file.name) if (parsedWps.length === 0) { throw new Error('No coordinates found in file. Supported formats: GPX, KML, GeoJSON.') } await saveUploadedTrack(logbookId, entryId, text, parsedWps, file.name, fileType) applyTrackStats(parsedWps) await loadTrack() trackPlausibleEvent(PlausibleEvents.GPS_TRACK_UPLOADED) } catch (err: any) { console.error('File parsing failed:', err) setUploadError(err.message || 'Failed to parse track file.') } } reader.onerror = () => { setUploadError('Failed to read file.') } reader.readAsText(file) } const handleFileChange = (e: React.ChangeEvent) => { if (e.target.files && e.target.files.length > 0) { handleFileUpload(e.target.files[0]) } } const handleDragOver = (e: React.DragEvent) => { e.preventDefault() setDragOver(true) } const handleDragLeave = () => { setDragOver(false) } const handleDrop = (e: React.DragEvent) => { e.preventDefault() setDragOver(false) if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { handleFileUpload(e.dataTransfer.files[0]) } } const handleDeleteTrack = async () => { if (readOnly) return if (!window.confirm(t('logs.gps_track_delete_confirm'))) { return } try { await deleteTrack(logbookId, entryId) setSavedTrack(null) setTrackDistanceNm('') setTrackSpeedMaxKn('') setTrackSpeedAvgKn('') setUploadError(null) } catch (err: any) { showAlert(err.message || 'Failed to delete track') } } const handleGetGps = () => { if (readOnly) return const lookupFallback = async () => { const locationQuery = evLocationName.trim() || departure.trim() || destination.trim() if (!locationQuery) { showAlert('GPS capturing failed, and no location name is entered in "Ort / Hafen" or "Start-Hafen" to look up coordinates.') return } try { const data = await fetchOpenWeatherCurrent( { q: locationQuery }, { analyticsSource: 'entry_editor_gps_lookup' } ) const coord = data.coord as { lat?: number; lon?: number } | undefined if (coord?.lat !== undefined && coord?.lon !== undefined) { setEvGpsLat(Number(coord.lat).toFixed(6)) setEvGpsLng(Number(coord.lon).toFixed(6)) showAlert(`Coordinates loaded for "${locationQuery}" via OpenWeatherMap.`) } } catch (e) { if (e instanceof WeatherApiError && e.code === 'NO_KEY') { showAlert(t('settings.no_key')) return } showAlert('Failed to retrieve GPS location or look up coordinates by location name.') } } if (!navigator.geolocation) { lookupFallback() return } navigator.geolocation.getCurrentPosition( (pos) => { setEvGpsLat(pos.coords.latitude.toFixed(6)) setEvGpsLng(pos.coords.longitude.toFixed(6)) }, (err) => { console.warn('GPS capturing failed, trying fallback:', err) lookupFallback() } ) } const handleFetchWeather = async () => { const localToday = new Date() const todayStr = `${localToday.getFullYear()}-${String(localToday.getMonth() + 1).padStart(2, '0')}-${String(localToday.getDate()).padStart(2, '0')}` if (date && date !== todayStr) { showAlert(t('settings.weather_date_mismatch', { date, today: todayStr })) return } const hasGps = evGpsLat && evGpsLng const fallbackLocation = evLocationName.trim() || departure.trim() || destination.trim() if (!hasGps && !fallbackLocation) { showAlert(t('settings.gps_error')) return } setWeatherLoading(true) try { const data = await fetchOpenWeatherCurrent( hasGps ? { lat: evGpsLat, lon: evGpsLng } : { q: fallbackLocation }, { analyticsSource: 'entry_editor' } ) const coord = data.coord as { lat?: number; lon?: number } | undefined // If fetched by location, automatically pre-fill GPS coordinates if (!hasGps && coord?.lat !== undefined && coord?.lon !== undefined) { setEvGpsLat(Number(coord.lat).toFixed(6)) setEvGpsLng(Number(coord.lon).toFixed(6)) } const parsed = parseOwmCurrentWeather(data) setEvWindStrength(parsed.windStrength) setEvWindPressure(parsed.windPressure) if (parsed.windDirection) setEvWindDirection(parsed.windDirection) if (parsed.weatherIcon) setEvWeatherIcon(parsed.weatherIcon) showAlert(t('settings.weather_success')) } catch (err) { if (err instanceof WeatherApiError && err.code === 'NO_KEY') { showAlert(t('settings.no_key')) return } console.error('Weather prefilling failed:', err) showAlert(t('settings.weather_error')) } finally { setWeatherLoading(false) } } const defaultSails = i18n.language === 'de' ? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker'] : ['Mainsail', 'Genoa', 'Jib', 'Spinnaker', 'Gennaker'] const eventSailOptions = yachtSails.length > 0 ? yachtSails : defaultSails const showSailsPickerToggle = eventSailOptions.length + 1 > 6 const toggleSailOrMotor = (item: string) => { let currentItems = evSailsOrMotor .split(/\s*(?:\+|\bplus\b|,)\s*/i) .map(s => s.trim()) .filter(Boolean) if (currentItems.some(s => s.toLowerCase() === item.toLowerCase())) { currentItems = currentItems.filter(s => s.toLowerCase() !== item.toLowerCase()) } else { currentItems.push(item) } setEvSailsOrMotor(currentItems.join(' + ')) } const isItemActive = (item: string) => { const currentItems = evSailsOrMotor .split(/\s*(?:\+|\bplus\b|,)\s*/i) .map(s => s.trim().toLowerCase()) .filter(Boolean) return currentItems.includes(item.toLowerCase()) } const motorPropulsionLabel = t('logs.motor_propulsion') const sortedEventSailOptions = [...eventSailOptions].sort((a, b) => { const aActive = isItemActive(a) const bActive = isItemActive(b) if (aActive === bActive) return 0 return aActive ? -1 : 1 }) const isMotorActive = isItemActive(motorPropulsionLabel) const clearEventForm = () => { setEvTime(currentLocalTimeHHMM()) setEvMgk('') setEvRwk('') setEvWindPressure('') setEvWindDirection('') setEvWindStrength('') setEvSeaState('') setEvWeatherIcon('') setEvCurrent('') setEvHeel('') setEvSailsOrMotor('') setEvLogReading('') setEvDistance('') setEvGpsLat('') setEvGpsLng('') setEvRemarks('') setEvLocationName('') setEditingEventIndex(null) setSailsPickerExpanded(false) } const fillEventForm = (ev: LogEvent) => { const normalized = normalizeLogEvent(ev) setEvTime(normalized.time) setEvMgk(normalized.mgk) setEvRwk(normalized.rwk) setEvWindPressure(normalized.windPressure) setEvWindDirection(normalized.windDirection) setEvWindStrength(normalized.windStrength) setEvSeaState(normalized.seaState) setEvWeatherIcon(normalized.weatherIcon) setEvCurrent(normalized.current) setEvHeel(normalized.heel) setEvSailsOrMotor(normalized.sailsOrMotor) setEvLogReading(normalized.logReading) setEvDistance(normalized.distance) setEvGpsLat(normalized.gpsLat) setEvGpsLng(normalized.gpsLng) setEvRemarks(normalized.remarks) setEvLocationName('') } const resolveSignaturesAfterContentChange = (skipperOnly = false) => { const hadSkipper = !!signSkipper const hadCrew = !!signCrew const cleared = hadSkipper || (hadCrew && !skipperOnly) skipCrewSignClearRef.current = skipperOnly const nextSkipper: SignatureValue | '' = hadSkipper ? '' : signSkipper const nextCrew: SignatureValue | '' = hadCrew && !skipperOnly ? '' : signCrew if (cleared) { if (hadSkipper) setSignSkipper('') if (hadCrew && !skipperOnly) setSignCrew('') lockedContentHashRef.current = null } return { signSkipper: nextSkipper, signCrew: nextCrew, cleared } } const markSkipperSignatureClearedForEventChange = () => { resolveSignaturesAfterContentChange(true) } const handleEditEvent = (index: number) => { if (readOnly) return const ev = events[index] if (!ev) return fillEventForm(ev) setEditingEventIndex(index) } const handleCancelEventEdit = () => { clearEventForm() } const handleSaveEvent = async (e: React.FormEvent) => { e.preventDefault() if (readOnly || !isValidTimeHHMM(evTime)) return const eventData = buildEventFromForm() const isEdit = editingEventIndex !== null const hadSkipperSignature = isEdit && !!signSkipper if (hadSkipperSignature) { markSkipperSignatureClearedForEventChange() } const nextEvents = applyEventFormToEvents(eventData) try { await persistEntryToDb(nextEvents) setEvents(nextEvents) clearEventForm() if (hadSkipperSignature) { void showAlertRef.current( t('logs.sign_cleared_skipper_re_sign'), t('logs.sign_cleared_skipper_re_sign_title') ) } } catch (err: any) { console.error('Failed to auto-save event:', err) setError(err.message || 'Failed to save event.') } } const handleDeleteEvent = async (index: number) => { if (readOnly) return const hadSkipperSignature = !!signSkipper markSkipperSignatureClearedForEventChange() const nextEvents = events.filter((_, idx) => idx !== index) setEvents(nextEvents) if (hadSkipperSignature) { void showAlertRef.current( t('logs.sign_cleared_skipper_re_sign'), t('logs.sign_cleared_skipper_re_sign_title') ) } if (editingEventIndex === index) { clearEventForm() } else if (editingEventIndex !== null && index < editingEventIndex) { setEditingEventIndex(editingEventIndex - 1) } try { await persistEntryToDb(nextEvents) } catch (err: any) { console.error('Failed to auto-save after event delete:', err) setError(err.message || 'Failed to save event deletion.') } } const handleDownloadPdf = async () => { setExporting(true) setError(null) try { await downloadLogbookPagePdf(logbookId, entryId, date) trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' }) } catch (err: any) { console.error('Failed to download PDF:', err) setError(err.message || 'Failed to generate PDF export.') } finally { setExporting(false) } } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() if (readOnly) return let eventsToSave = events let signaturesForSave: { signSkipper: SignatureValue | ''; signCrew: SignatureValue | '' } | undefined if (hasPendingEventForm) { const isEdit = editingEventIndex !== null const resolved = resolveSignaturesAfterContentChange(isEdit) signaturesForSave = { signSkipper: resolved.signSkipper, signCrew: resolved.signCrew } if (resolved.cleared) { void showAlertRef.current( isEdit ? t('logs.sign_cleared_skipper_re_sign') : t('logs.sign_cleared_re_sign'), isEdit ? t('logs.sign_cleared_skipper_re_sign_title') : t('logs.sign_cleared_re_sign_title') ) } eventsToSave = applyEventFormToEvents(buildEventFromForm()) setEvents(eventsToSave) clearEventForm() } else if (!isDirty) { return } setSaving(true) setError(null) setSuccess(false) try { await persistEntryToDb({ eventsOverride: eventsToSave, ...signaturesForSave }) await clearEntryDraft(logbookId, entryId) setSuccess(true) trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED) setTimeout(() => { setSuccess(false) onBack() }, 1500) } catch (err: unknown) { console.error('Failed to save entry details:', err) setError(getErrorMessage(err, t('errors.save_failed'))) } finally { setSaving(false) } } if (loading) { return (

{t('logs.loading')}

) } return (
{/* Top Header Controls */}

{t('logs.route')}: {departure || '...'} → {destination || '...'} (Tag {dayOfTravel})

{error &&
{error}
} {/* Main Journal Data Forms */}
{/* Section 1: Travel Day Headers */}

{t('logs.travel_details')}

setDate(e.target.value)} disabled={saving || readOnly} required />
setDayOfTravel(e.target.value)} disabled={saving || readOnly} required />
setDeparture(e.target.value)} disabled={saving || readOnly} />
setDestination(e.target.value)} disabled={saving || readOnly} />
setMotorHours(e.target.value)} disabled={saving || readOnly} min="0" step="0.1" placeholder="0" />
{/* Section 2: Freshwater and Fuel Consumption */}
{/* Freshwater card */}

{t('logs.freshwater')}

{/* Fuel card */}

{t('logs.fuel')}

{/* Greywater card */}

{t('logs.greywater')}

{/* Section 3: Event Journal Entries */}

{t('logs.event_title')}

{/* List existing events */} {events.length === 0 ? (
{t('logs.no_events')}
) : (
{!readOnly && } {events.map((ev, idx) => ( {!readOnly && ( )} ))}
{t('logs.event_time')} {t('logs.event_mgk')} {t('logs.event_rwk')} {t('logs.event_wind_direction')} {t('logs.event_wind_strength')} {t('logs.event_sea_state')} {t('logs.event_weather')} {t('logs.event_log')} {t('logs.event_gps')} {t('logs.event_remarks')}
{ev.time} {ev.mgk ? `${ev.mgk}°` : '—'} {ev.rwk ? `${ev.rwk}°` : '—'} {ev.windDirection || '—'} {ev.windStrength || '—'} {ev.seaState || '—'} {ev.weatherIcon ? ( Weather ) : ( '—' )} {ev.logReading ? `${ev.logReading} nm` : '—'} {ev.gpsLat && ev.gpsLng ? `${ev.gpsLat}, ${ev.gpsLng}` : '—'} {ev.remarks}
)} {/* Add New Event Form Sub-Card */} {!readOnly && (

{editingEventIndex !== null ? t('logs.edit_event') : t('logs.add_event')}

setEvLogReading(e.target.value)} disabled={saving} />
setEvLocationName(e.target.value)} disabled={saving} />
setEvGpsLat(e.target.value)} disabled={saving} /> setEvGpsLng(e.target.value)} disabled={saving} />
setEvWindStrength(e.target.value)} disabled={saving || weatherLoading} />
setEvWindPressure(e.target.value)} disabled={saving || weatherLoading} />
setEvSeaState(e.target.value)} disabled={saving} />
setEvHeel(e.target.value)} disabled={saving} />
setEvSailsOrMotor(e.target.value)} disabled={saving} />
setEvDistance(e.target.value)} disabled={saving} />
{isMotorActive && ( toggleSailOrMotor(motorPropulsionLabel)} > {motorPropulsionLabel} )} {sortedEventSailOptions.map((sail) => ( toggleSailOrMotor(sail)} > {sail} ))} {!isMotorActive && ( toggleSailOrMotor(motorPropulsionLabel)} > {motorPropulsionLabel} )}
{showSailsPickerToggle && ( )}
setEvRemarks(e.target.value)} disabled={saving} />
{editingEventIndex !== null && ( )}
)}
{/* Track file upload */}

{t('logs.track_upload_title')}

{uploadError &&
{uploadError}
} {!savedTrack ? (
fileInputRef.current?.click()} >
{t('logs.gps_track_upload_btn')}
{t('logs.gps_track_upload_help')}
) : ( <>
{savedTrack.filename || 'track'} {(savedTrack.fileType ?? 'gpx').toUpperCase()} {savedTrack.waypoints.length > 0 && ( <> · {savedTrack.waypoints.length} {t('logs.track_upload_points')} )} {trackDistanceNm && ( <> · {trackDistanceNm} sm )} {trackSpeedMaxKn && ( <> · max {trackSpeedMaxKn} kn )} {trackSpeedAvgKn && ( <> · Ø {trackSpeedAvgKn} kn )}
{!readOnly && ( )}
{savedTrack.waypoints.length > 0 && ( )} )} {!readOnly && (
{nmeaArchive && (
{t('logs.nmea_archive_stored', { name: nmeaArchive.filename })}
)}
)} {(savedTrack || trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) && (
setTrackDistanceNm(e.target.value)} disabled={saving || readOnly} />
setTrackSpeedMaxKn(e.target.value)} disabled={saving || readOnly} />
setTrackSpeedAvgKn(e.target.value)} disabled={saving || readOnly} />
)}
{ if (canSignSkipper && !readOnly) setSignSkipper(value) }} onSignCrewChange={(value) => { if (!canSignCrew || readOnly) return if (!value) { setSignCrew('') return } if (isPasskeySignature(value) || isClassicSignature(value)) { setSignCrew(value) return } if (!canSignSkipper) { const userId = localStorage.getItem('active_userid') || '' const username = localStorage.getItem('active_username') || '' if (userId && username) { setSignCrew(createClassicSignature({ role: 'crew', userId, username, signedAt: new Date().toISOString(), payload: value })) return } } setSignCrew(value) }} onPasskeySignSkipper={handlePasskeySignSkipper} onPasskeySignCrew={handlePasskeySignCrew} onBeforeSign={confirmSignWarning} /> {/* Save Controls */} {!readOnly && (
{success && (
{t('logs.saved')}
)}
)} { setNmeaWizardOpen(false) void loadNmeaArchive() }} logbookId={logbookId} entryId={entryId} entryDate={date} nmeaArchive={nmeaArchive} onImport={handleNmeaImport} />
) }