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, Sparkles } from 'lucide-react' import PhotoCapture from './PhotoCapture.tsx' import EventRemarksCell from './EventRemarksCell.tsx' import CreatorAvatar from './CreatorAvatar.tsx' import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js' import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js' import { deleteEntryVoiceMemo } from '../services/voiceAttachments.js' import type { PreloadedVoiceMemo } from './VoiceMemoPlayer.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 { putEntryRecord } from '../utils/entryListCache.js' import { getLogbookAccess } from '../services/logbookAccess.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js' import { buildTravelDayContext, fetchTravelDaySummaryUsage, generateTravelDaySummary, TravelDaySummaryApiError } from '../services/aiSummary.js' import { tryDecryptEntryPayload } from '../services/quickEventLog.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 GpsSignalHint from './GpsSignalHint.tsx' import { geolocationErrorI18nKey, getCurrentPosition, getGeolocationErrorReason, queryGeolocationPermission, type GpsSignalQuality } from '../utils/geolocation.js' import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx' import TankLiterInput from './TankLiterInput.tsx' import MetricRangeInput from './MetricRangeInput.tsx' import { formatHeelDeg, formatPressureHpa, formatSeaState, formatVisibilityMeters, HEEL_MAX_DEG, HEEL_MIN_DEG, parseHeelDeg, parsePressureHpa, parseSeaState, parseVisibilityMeters, PRESSURE_DEFAULT_HPA, PRESSURE_MAX_HPA, PRESSURE_MIN_HPA, SEA_STATE_MAX, SEA_STATE_MIN, VISIBILITY_STEPS_M } from '../utils/weatherMetrics.js' import { computeEveningTankMaxLiters, computeRefilledTankMaxLiters, extractTankCapacitiesFromYacht, formatTankLitersForInput, type VesselTankCapacities } from '../utils/tankCapacity.js' import { formatAppCoordinate, parseAppDecimal, parseAppDecimalOrZero } from '../utils/numberFormat.js' function parseOptionalFormDecimal(input: string): number | undefined { const trimmed = input.trim() if (!trimmed) return undefined return parseAppDecimal(trimmed) ?? undefined } 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 !== '' ? (parseAppDecimal(String(trackDistance)) ?? undefined) : undefined, trackSpeedMaxKn: trackSpeedMax != null && trackSpeedMax !== '' ? (parseAppDecimal(String(trackSpeedMax)) ?? undefined) : undefined, trackSpeedAvgKn: trackSpeedAvg != null && trackSpeedAvg !== '' ? (parseAppDecimal(String(trackSpeedAvg)) ?? undefined) : undefined, motorHours: motorHoursRaw != null && motorHoursRaw !== '' ? (parseAppDecimal(String(motorHoursRaw)) ?? undefined) : undefined, events: (decrypted.events as LogEventPayload[]) || [], entryCrew: entryCrewFromPreviousEntry(decrypted as Record) }) return JSON.stringify({ ...payload, signSkipper: fingerprintSignature(decrypted.signSkipper), signCrew: fingerprintSignature(decrypted.signCrew) }) } function findActiveCreatorId( activeUsername: string | null, crewSnapshotsById: Record, selectedSkipperId: string | null ): string { const username = (activeUsername || '').trim() if (username) { const matchEntry = Object.entries(crewSnapshotsById).find( ([_, snap]) => (snap?.name || '').trim().toLowerCase() === username.toLowerCase() ) if (matchEntry) { return matchEntry[0] } return username } return selectedSkipperId || 'skipper' } interface LogEntryEditorProps { entryId: string logbookId: string onBack: () => void readOnly?: boolean preloadedEntry?: any preloadedPhotos?: any[] preloadedVoiceMemos?: PreloadedVoiceMemo[] preloadedTrack?: any preloadedYacht?: any } interface LogEvent extends LogEventPayload {} export default function LogEntryEditor({ entryId, logbookId, onBack, readOnly = false, preloadedEntry, preloadedPhotos, preloadedVoiceMemos, 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 [aiSummary, setAiSummary] = useState('') const [aiSummaryGeneratedAt, setAiSummaryGeneratedAt] = useState('') const [aiSummaryLoading, setAiSummaryLoading] = useState(false) const [aiSummaryError, setAiSummaryError] = useState(null) const [aiSummaryRemaining, setAiSummaryRemaining] = useState(null) const [aiSummaryMaxAttempts, setAiSummaryMaxAttempts] = useState(3) 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([]) const voiceMemoLookup = useEntryVoiceMemos(logbookId, entryId, preloadedVoiceMemos) // 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 [evVisibility, setEvVisibility] = 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 [gpsSignal, setGpsSignal] = useState<{ quality: GpsSignalQuality accuracyM: number | null } | null>(null) 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: parseAppDecimalOrZero(fwMorning), refilled: parseAppDecimalOrZero(fwRefilled), evening: parseAppDecimalOrZero(fwEvening), consumption: parseAppDecimalOrZero(fwConsumption) }, fuel: { morning: parseAppDecimalOrZero(fuelMorning), refilled: parseAppDecimalOrZero(fuelRefilled), evening: parseAppDecimalOrZero(fuelEvening), consumption: parseAppDecimalOrZero(fuelConsumption) }, greywater: { level: parseAppDecimalOrZero(greywaterLevel) }, trackDistanceNm: parseOptionalFormDecimal(trackDistanceNm), trackSpeedMaxKn: parseOptionalFormDecimal(trackSpeedMaxKn), trackSpeedAvgKn: parseOptionalFormDecimal(trackSpeedAvgKn), motorHours: parseOptionalFormDecimal(motorHours), 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(parseAppDecimalOrZero(fuelConsumption), parseAppDecimalOrZero(motorHours)), [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 => { let creatorId: string | undefined = undefined if (editingEventIndex !== null && events[editingEventIndex]) { creatorId = events[editingEventIndex].creatorId } if (!creatorId) { const activeUsername = localStorage.getItem('active_username') creatorId = findActiveCreatorId(activeUsername, entryCrew.crewSnapshotsById, entryCrew.selectedSkipperId) } return normalizeLogEvent({ time: evTime, mgk: evMgk, rwk: evRwk, windPressure: evWindPressure, windDirection: evWindDirection, windStrength: evWindStrength, seaState: evSeaState, visibility: evVisibility, weatherIcon: evWeatherIcon, current: evCurrent, heel: evHeel, sailsOrMotor: evSailsOrMotor, logReading: evLogReading, distance: evDistance, gpsLat: evGpsLat, gpsLng: evGpsLng, remarks: evRemarks, creatorId }) } 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, evVisibility, evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance, evGpsLat, evGpsLng, evRemarks, editingEventIndex, events ]) const isDirty = savedFingerprint !== null && ( currentFingerprint !== savedFingerprint || hasPendingEventForm ) const saveBeforeLeaveRef = useRef<(() => Promise) | null>(null) const invokeSaveBeforeLeave = useCallback(async () => { if (saveBeforeLeaveRef.current) await saveBeforeLeaveRef.current() }, []) const { confirmLeave } = useRegisterUnsavedChanges( `log-entry-${entryId}`, !readOnly && !loading && isDirty, invokeSaveBeforeLeave ) const handleBack = async () => { if (!(await confirmLeave())) return onBack() } const persistEntryToDb = useCallback(async ( options?: LogEvent[] | { eventsOverride?: LogEvent[] signSkipper?: SignatureValue | '' signCrew?: SignatureValue | '' aiSummary?: string aiSummaryGeneratedAt?: string } ) => { 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 let summaryToSave = normalized.aiSummary !== undefined ? normalized.aiSummary : aiSummary let summaryAtToSave = normalized.aiSummaryGeneratedAt !== undefined ? normalized.aiSummaryGeneratedAt : aiSummaryGeneratedAt const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() if (!masterKey) throw new Error('Encryption key not found. Please log in.') // Crew edits must not drop the skipper's AI summary when it is not loaded into editor state. if (!summaryToSave.trim()) { const local = await db.entries.get(entryId) if (local) { const decrypted = await tryDecryptEntryPayload(local, masterKey) if (decrypted) { const existing = typeof decrypted.aiSummary === 'string' ? decrypted.aiSummary.trim() : '' if (existing) { summaryToSave = existing summaryAtToSave = typeof decrypted.aiSummaryGeneratedAt === 'string' ? decrypted.aiSummaryGeneratedAt : '' } } } } const entryData: Record = { ...buildPayloadForSigning(eventsOverride), signSkipper: normalizedSerializedSignature(skipperToSave), signCrew: normalizedSerializedSignature(crewToSave) } if (summaryToSave.trim()) { entryData.aiSummary = summaryToSave.trim() entryData.aiSummaryGeneratedAt = summaryAtToSave || new Date().toISOString() } const encrypted = await encryptJson(entryData, masterKey) const now = new Date().toISOString() await putEntryRecord( { payloadId: entryId, logbookId, encryptedData: encrypted.ciphertext, iv: encrypted.iv, tag: encrypted.tag, updatedAt: now }, entryData ) 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, aiSummary, aiSummaryGeneratedAt ]) 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(() => { setCanSignSkipper(false) setCanSignCrew(false) getLogbookAccess(logbookId).then((access) => { if (!access) return setCanSignSkipper(access.isOwner) setCanSignCrew( access.role === 'WRITE' || (access.isOwner && access.writeCollaboratorCount === 0) ) }) }, [logbookId]) useEffect(() => { if (!canSignSkipper || readOnly || !isOnline) { setAiSummaryRemaining(null) return } let cancelled = false fetchTravelDaySummaryUsage(logbookId, entryId) .then((usage) => { if (cancelled) return setAiSummaryRemaining(usage.remainingAttempts) setAiSummaryMaxAttempts(usage.maxAttempts) }) .catch((err) => { console.warn('Failed to load AI summary usage:', err) }) return () => { cancelled = true } }, [canSignSkipper, readOnly, isOnline, logbookId, entryId]) 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 = parseAppDecimalOrZero(fwMorning) const refilled = parseAppDecimalOrZero(fwRefilled) const evening = parseAppDecimalOrZero(fwEvening) const cons = morning + refilled - evening setFwConsumption(cons >= 0 ? String(cons) : '0') }, [fwMorning, fwRefilled, fwEvening]) // Auto-calculate Fuel Consumption useEffect(() => { const morning = parseAppDecimalOrZero(fuelMorning) const refilled = parseAppDecimalOrZero(fuelRefilled) const evening = parseAppDecimalOrZero(fuelEvening) 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 = parseAppDecimalOrZero(fwRefilled) 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 = parseAppDecimalOrZero(fwEvening) if (evening > fwEveningMax) { setFwEvening(formatTankLitersForInput(fwEveningMax)) } }, [fwEveningMax, fwEvening]) useEffect(() => { const refilled = parseAppDecimalOrZero(fuelRefilled) 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 = parseAppDecimalOrZero(fuelEvening) if (evening > fuelEveningMax) { setFuelEvening(formatTankLitersForInput(fuelEveningMax)) } }, [fuelEveningMax, fuelEvening]) // Load vessel sails and tank capacities useEffect(() => { async function loadYachtMeta() { try { const { resolveVesselForLogbook } = await import('../services/resolveVessel.js') const vessel = readOnly && preloadedYacht ? (preloadedYacht as Record) : await resolveVesselForLogbook(logbookId, { preloadedYacht: preloadedYacht ?? undefined }) if (!vessel) return if (vessel.sails && Array.isArray(vessel.sails)) { setYachtSails(vessel.sails) } setTankCapacities(extractTankCapacitiesFromYacht(vessel)) } catch (err) { console.error('Failed to load vessel meta in editor:', err) } } void 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))) setAiSummary(String(preloadedEntry.aiSummary || '')) setAiSummaryGeneratedAt(String(preloadedEntry.aiSummaryGeneratedAt || '')) 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))) setAiSummary(String(decrypted.aiSummary || '')) setAiSummaryGeneratedAt(String(decrypted.aiSummaryGeneratedAt || '')) 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 clearGpsSignal = () => setGpsSignal(null) const handleGetGps = async () => { if (readOnly) return const lookupFallback = async () => { clearGpsSignal() const locationQuery = evLocationName.trim() || departure.trim() || destination.trim() if (!locationQuery) { showAlert(t('logs.gps_fallback_no_location')) return } if (!isOnline) { showAlert(t('logs.weather_offline')) 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(formatAppCoordinate(Number(coord.lat))) setEvGpsLng(formatAppCoordinate(Number(coord.lon))) showAlert(t('logs.gps_fallback_success', { location: locationQuery })) } else { showAlert(t('logs.gps_fallback_failed')) } } catch (e) { if (e instanceof WeatherApiError && e.code === 'OFFLINE') { showAlert(t('logs.weather_offline')) return } if (e instanceof WeatherApiError && e.code === 'NO_KEY') { showAlert(t('settings.no_key')) return } showAlert(t('logs.gps_fallback_failed')) } } try { const permission = await queryGeolocationPermission() if (permission === 'denied' || permission === 'unsupported') { const reason = permission === 'denied' ? 'permission_denied' : 'unavailable' showAlert( `${t(geolocationErrorI18nKey(reason))}\n\n${t('logs.live_position_manual_hint')}` ) await lookupFallback() return } const coords = await getCurrentPosition({ timeoutMs: 15_000, enableHighAccuracy: false, maximumAge: 60_000 }) setEvGpsLat(coords.lat) setEvGpsLng(coords.lng) setGpsSignal({ quality: coords.signalQuality, accuracyM: coords.accuracyM }) } catch (err) { console.warn('GPS capture failed:', err) const reason = getGeolocationErrorReason(err) showAlert( `${t(geolocationErrorI18nKey(reason))}\n\n${t('logs.live_position_manual_hint')}` ) await lookupFallback() } } const handleFetchWeather = async () => { if (!isOnline) { showAlert(t('logs.weather_offline')) return } 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(formatAppCoordinate(Number(coord.lat))) setEvGpsLng(formatAppCoordinate(Number(coord.lon))) } const parsed = parseOwmCurrentWeather(data) setEvWindStrength(parsed.windStrength) setEvWindPressure(parsed.windPressure) if (parsed.windDirection) setEvWindDirection(parsed.windDirection) if (parsed.visibility) setEvVisibility(parsed.visibility) if (parsed.weatherIcon) setEvWeatherIcon(parsed.weatherIcon) showAlert(t('settings.weather_success')) } catch (err) { if (err instanceof WeatherApiError) { if (err.code === 'OFFLINE') { showAlert(t('logs.weather_offline')) return } if (err.code === 'NO_KEY') { showAlert(t('settings.no_key')) return } if (err.code === 'UNAUTHORIZED') { showAlert(t('settings.weather_unauthorized')) return } if (err.code === 'NOT_FOUND') { showAlert(t('settings.weather_not_found')) return } if (err.code === 'BAD_REQUEST') { showAlert(t('settings.weather_bad_request')) return } } console.error('Weather prefilling failed:', err) showAlert(t('settings.weather_error')) } finally { setWeatherLoading(false) } } const handleGenerateAiSummary = async () => { if (!canSignSkipper || readOnly || aiSummaryLoading) return if (!isOnline) { setAiSummaryError(t('logs.ai_summary_offline')) return } if (aiSummaryRemaining === 0) { setAiSummaryError(t('logs.ai_summary_error_rate_limited')) return } setAiSummaryLoading(true) setAiSummaryError(null) try { const context = buildTravelDayContext( { date, dayOfTravel, departure, destination, trackDistanceNm: parseOptionalFormDecimal(trackDistanceNm), trackSpeedMaxKn: parseOptionalFormDecimal(trackSpeedMaxKn), trackSpeedAvgKn: parseOptionalFormDecimal(trackSpeedAvgKn), motorHours: parseOptionalFormDecimal(motorHours), freshwater: { morning: parseAppDecimalOrZero(fwMorning), refilled: parseAppDecimalOrZero(fwRefilled), evening: parseAppDecimalOrZero(fwEvening), consumption: parseAppDecimalOrZero(fwConsumption) }, fuel: { morning: parseAppDecimalOrZero(fuelMorning), refilled: parseAppDecimalOrZero(fuelRefilled), evening: parseAppDecimalOrZero(fuelEvening), consumption: parseAppDecimalOrZero(fuelConsumption) }, greywaterLevel: parseAppDecimalOrZero(greywaterLevel), events }, t ) const language = i18n.language.split('-')[0] || 'en' const result = await generateTravelDaySummary({ logbookId, entryId, language, context }) const generatedAt = new Date().toISOString() setAiSummary(result.summary) setAiSummaryGeneratedAt(generatedAt) setAiSummaryRemaining(result.remainingAttempts) setAiSummaryMaxAttempts(result.maxAttempts) await persistEntryToDb({ aiSummary: result.summary, aiSummaryGeneratedAt: generatedAt }) } catch (err) { if (err instanceof TravelDaySummaryApiError) { if (err.code === 'OFFLINE') { setAiSummaryError(t('logs.ai_summary_offline')) } else if (err.code === 'NO_KEY') { setAiSummaryError(t('logs.ai_summary_error_no_key')) } else if (err.code === 'RATE_LIMITED') { setAiSummaryError(t('logs.ai_summary_error_rate_limited')) setAiSummaryRemaining(0) } else if (err.code === 'FORBIDDEN') { setAiSummaryError(t('logs.ai_summary_error_forbidden')) } else { setAiSummaryError(err.message || t('logs.ai_summary_error')) } } else { console.error('AI summary generation failed:', err) setAiSummaryError(getErrorMessage(err, t('logs.ai_summary_error'))) } } finally { setAiSummaryLoading(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('') setEvVisibility('') 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) setEvVisibility(normalized.visibility) 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 voiceId = parseLiveVoiceRemark(events[index]?.remarks?.trim() ?? '') 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 { if (voiceId && !readOnly) { await deleteEntryVoiceMemo(logbookId, voiceId) } 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 saveEntryChanges = useCallback(async () => { 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) try { await persistEntryToDb({ eventsOverride: eventsToSave, ...signaturesForSave }) await clearEntryDraft(logbookId, entryId) trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED) } finally { setSaving(false) } }, [ readOnly, events, hasPendingEventForm, editingEventIndex, isDirty, resolveSignaturesAfterContentChange, applyEventFormToEvents, buildEventFromForm, clearEventForm, persistEntryToDb, logbookId, entryId, t ]) useEffect(() => { saveBeforeLeaveRef.current = readOnly ? null : saveEntryChanges }, [readOnly, saveEntryChanges]) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() if (readOnly) return setSuccess(false) try { await saveEntryChanges() setSuccess(true) setTimeout(() => { setSuccess(false) onBack() }, 1500) } catch (err: unknown) { console.error('Failed to save entry details:', err) setError(getErrorMessage(err, t('errors.save_failed'))) } } 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" />
{(aiSummary.trim() || canSignSkipper) && (

{t('logs.ai_summary_title')}

{aiSummary.trim() && !canSignSkipper && (

{t('logs.ai_summary_read_only')}

)} {aiSummary.trim() ? (

{aiSummary}

) : (

{t('logs.ai_summary_empty')}

)} {canSignSkipper && !readOnly && (
{aiSummaryRemaining !== null && ( {t('logs.ai_summary_attempts_remaining', { remaining: aiSummaryRemaining, max: aiSummaryMaxAttempts })} )}
)} {aiSummaryError &&
{aiSummaryError}
}
)} {/* 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_creator')} {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}` : '—'}
)} {/* 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} />
{ clearGpsSignal(); setEvGpsLat(e.target.value) }} disabled={saving} /> { clearGpsSignal(); setEvGpsLng(e.target.value) }} disabled={saving} />
{gpsSignal && ( )}
setEvWindStrength(e.target.value)} disabled={saving || weatherLoading} />
t('logs.weather_slider_pressure', { value: hpa, defaultValue: `${hpa} hPa` })} numberMin={PRESSURE_MIN_HPA} numberMax={PRESSURE_MAX_HPA} numberStep={1} numberPlaceholder="1013" /> t('logs.weather_slider_sea_state', { value: level, defaultValue: `${level}` })} numberMin={SEA_STATE_MIN} numberMax={SEA_STATE_MAX} numberStep={1} numberPlaceholder="3" allowLegacyText /> formatVisibilityMeters(m)} hideNumberInput /> t('logs.weather_slider_heel', { value: deg, defaultValue: `${deg}°` })} numberMin={HEEL_MIN_DEG} numberMax={HEEL_MAX_DEG} numberStep={1} numberPlaceholder="5" />
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} />
) }