Files
kapteins-daagbok/client/src/components/LogEntryEditor.tsx
T

2525 lines
90 KiB
TypeScript

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, unknown>): string {
const fw = (decrypted.freshwater as Record<string, number> | undefined) ?? emptyTankLevels()
const fuel = (decrypted.fuel as Record<string, number> | 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<string, unknown>)
})
return JSON.stringify({
...payload,
signSkipper: fingerprintSignature(decrypted.signSkipper),
signCrew: fingerprintSignature(decrypted.signCrew)
})
}
function findActiveCreatorId(
activeUsername: string | null,
crewSnapshotsById: Record<string, any>,
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<string[]>([])
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<VesselTankCapacities>({})
const [entryCrew, setEntryCrew] = useState<EntryCrewFields>(emptyEntryCrewFields())
// Signatures
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
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<string | null>(null)
const [aiSummaryRemaining, setAiSummaryRemaining] = useState<number | null>(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<LogEvent[]>([])
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<string | null>(null)
const [weatherLoading, setWeatherLoading] = useState(false)
const [gpsSignal, setGpsSignal] = useState<{
quality: GpsSignalQuality
accuracyM: number | null
} | null>(null)
const [savedFingerprint, setSavedFingerprint] = useState<string | null>(null)
// Track file upload
const [savedTrack, setSavedTrack] = useState<SavedTrack | null>(null)
const [dragOver, setDragOver] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const [nmeaWizardOpen, setNmeaWizardOpen] = useState(false)
const [nmeaArchive, setNmeaArchive] = useState<NmeaArchiveRecord | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const lockedContentHashRef = useRef<string | null>(null)
const contentReadyRef = useRef(false)
const lastSignatureAlertHashRef = useRef<string | null>(null)
const skipCrewSignClearRef = useRef(false)
const entryHashSeqRef = useRef(0)
const [editingEventIndex, setEditingEventIndex] = useState<number | null>(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<void>) | 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<string, unknown> = {
...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<boolean> => {
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<string, unknown>)
: 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<string, unknown>))
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<string, unknown>))
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) => ` <trkpt lat="${wp.lat}" lon="${wp.lng}"><time>${new Date(wp.timestamp).toISOString()}</time></trkpt>`)
.join('\n')
const content = `<?xml version="1.0"?><gpx><trk><trkseg>\n${gpxLike}\n</trkseg></trk></gpx>`
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<HTMLInputElement>) => {
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 && err.code === 'OFFLINE') {
showAlert(t('logs.weather_offline'))
return
}
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 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 (
<div className="tab-placeholder">
<FileText className="header-logo spin" size={48} />
<p>{t('logs.loading')}</p>
</div>
)
}
return (
<div className="crew-dashboard-layout">
{/* Top Header Controls */}
<div className="form-card" style={{ paddingBottom: '20px' }}>
<div className="section-title-bar">
<div className="section-title-left">
<button className="btn-back" onClick={() => void handleBack()} style={{ padding: '6px 12px' }}>
<ChevronLeft size={16} />
{t('logs.back_to_list')}
</button>
<div className="form-header" style={{ margin: 0 }}>
<FileText size={24} className="form-icon" />
<h2>
{t('logs.route')}: {departure || '...'} {destination || '...'} (Tag {dayOfTravel})
</h2>
</div>
</div>
<button
type="button"
className="btn secondary"
onClick={handleDownloadPdf}
disabled={saving || exporting}
style={{ width: 'auto', padding: '8px 16px' }}
>
<Download size={16} />
<span className="hide-mobile">{exporting ? t('logs.exporting_pdf') : t('logs.export_pdf')}</span>
</button>
</div>
</div>
{error && <div className="auth-error">{error}</div>}
{/* Main Journal Data Forms */}
<form onSubmit={handleSubmit} className="vessel-form">
{/* Section 1: Travel Day Headers */}
<div className="form-card">
<div className="form-header">
<FileText size={20} className="form-icon" />
<h3>{t('logs.travel_details')}</h3>
</div>
<div className="form-grid">
<div className="input-group">
<label>{t('logs.date')}</label>
<input
type="date"
className="input-text"
value={date}
onChange={(e) => setDate(e.target.value)}
disabled={saving || readOnly}
required
/>
</div>
<div className="input-group">
<label>{t('logs.day_of_travel')}</label>
<input
type="text"
className="input-text"
value={dayOfTravel}
onChange={(e) => setDayOfTravel(e.target.value)}
disabled={saving || readOnly}
required
/>
</div>
<div className="input-group">
<label>{t('logs.departure')}</label>
<input
type="text"
className="input-text"
value={departure}
onChange={(e) => setDeparture(e.target.value)}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('logs.destination')}</label>
<input
type="text"
className="input-text"
value={destination}
onChange={(e) => setDestination(e.target.value)}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('logs.motor_hours')}</label>
<input
type="number"
className="input-text"
value={motorHours}
onChange={(e) => setMotorHours(e.target.value)}
disabled={saving || readOnly}
min="0"
step="0.1"
placeholder="0"
/>
</div>
</div>
</div>
{(aiSummary.trim() || canSignSkipper) && (
<div className="form-card">
<div className="form-header">
<Sparkles size={20} className="form-icon" />
<h3>{t('logs.ai_summary_title')}</h3>
</div>
{aiSummary.trim() && !canSignSkipper && (
<p style={{ margin: '0 0 12px', fontSize: '0.9rem', opacity: 0.8 }}>
{t('logs.ai_summary_read_only')}
</p>
)}
{aiSummary.trim() ? (
<p style={{ whiteSpace: 'pre-wrap', margin: '0 0 16px', lineHeight: 1.5 }}>{aiSummary}</p>
) : (
<p style={{ margin: '0 0 16px', opacity: 0.75 }}>{t('logs.ai_summary_empty')}</p>
)}
{canSignSkipper && !readOnly && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px', alignItems: 'center' }}>
<button
type="button"
className="btn secondary"
onClick={() => void handleGenerateAiSummary()}
disabled={saving || aiSummaryLoading || aiSummaryRemaining === 0}
style={{ width: 'auto' }}
>
<Sparkles size={16} />
{aiSummaryLoading
? t('logs.ai_summary_generating')
: aiSummary.trim()
? t('logs.ai_summary_regenerate')
: t('logs.ai_summary_generate')}
</button>
{aiSummaryRemaining !== null && (
<span style={{ fontSize: '0.9rem', opacity: 0.8 }}>
{t('logs.ai_summary_attempts_remaining', {
remaining: aiSummaryRemaining,
max: aiSummaryMaxAttempts
})}
</span>
)}
</div>
)}
{aiSummaryError && <div className="auth-error" style={{ marginTop: '12px' }}>{aiSummaryError}</div>}
</div>
)}
{/* Section 2: Freshwater and Fuel Consumption */}
<div className="form-grid">
{/* Freshwater card */}
<div className="form-card">
<div className="form-header">
<Compass size={20} className="form-icon" />
<h3>{t('logs.freshwater')}</h3>
</div>
<div className="consumption-grid">
<TankLiterInput
id="fw-morning"
label={t('logs.morning')}
value={fwMorning}
onChange={setFwMorning}
maxLiters={tankCapacities.freshwaterCapacityL}
disabled={saving || readOnly}
titleTooltip={tankCapacityTooltip}
/>
<TankLiterInput
id="fw-refilled"
label={t('logs.refilled')}
value={fwRefilled}
onChange={setFwRefilled}
maxLiters={fwRefilledMax}
disabled={saving || readOnly || fwRefilledNoCapacity}
titleTooltip={tankCapacityTooltip}
/>
<TankLiterInput
id="fw-evening"
label={t('logs.evening')}
value={fwEvening}
onChange={setFwEvening}
maxLiters={fwEveningMax}
disabled={saving || readOnly}
titleTooltip={tankCapacityTooltip}
/>
<div className="input-group">
<label title={tankCapacityTooltip}>{t('logs.consumption')} (L)</label>
<input
type="number"
className="input-text consumption-value"
value={fwConsumption}
readOnly
tabIndex={-1}
aria-readonly="true"
title={tankCapacityTooltip}
/>
</div>
</div>
</div>
{/* Fuel card */}
<div className="form-card">
<div className="form-header">
<Compass size={20} className="form-icon" />
<h3>{t('logs.fuel')}</h3>
</div>
<div className="consumption-grid">
<TankLiterInput
id="fuel-morning"
label={t('logs.morning')}
value={fuelMorning}
onChange={setFuelMorning}
maxLiters={tankCapacities.fuelCapacityL}
disabled={saving || readOnly}
titleTooltip={tankCapacityTooltip}
/>
<TankLiterInput
id="fuel-refilled"
label={t('logs.refilled')}
value={fuelRefilled}
onChange={setFuelRefilled}
maxLiters={fuelRefilledMax}
disabled={saving || readOnly || fuelRefilledNoCapacity}
titleTooltip={tankCapacityTooltip}
/>
<TankLiterInput
id="fuel-evening"
label={t('logs.evening')}
value={fuelEvening}
onChange={setFuelEvening}
maxLiters={fuelEveningMax}
disabled={saving || readOnly}
titleTooltip={tankCapacityTooltip}
/>
<div className="input-group">
<label title={tankCapacityTooltip}>{t('logs.consumption')} (L)</label>
<input
type="number"
className="input-text consumption-value"
value={fuelConsumption}
readOnly
tabIndex={-1}
aria-readonly="true"
title={tankCapacityTooltip}
/>
</div>
<div className="input-group">
<label title={tankCapacityTooltip}>{t('logs.fuel_per_motor_hour')}</label>
<input
type="text"
className="input-text consumption-value"
value={
fuelPerMotorHour != null
? `${formatFuelPerMotorHour(fuelPerMotorHour)} L/h`
: '—'
}
readOnly
tabIndex={-1}
aria-readonly="true"
title={tankCapacityTooltip}
/>
</div>
</div>
</div>
{/* Greywater card */}
<div className="form-card">
<div className="form-header">
<Compass size={20} className="form-icon" />
<h3>{t('logs.greywater')}</h3>
</div>
<div className="consumption-grid">
<TankLiterInput
id="greywater-level"
label={t('logs.greywater_level')}
value={greywaterLevel}
onChange={setGreywaterLevel}
maxLiters={tankCapacities.greywaterCapacityL}
disabled={saving || readOnly}
titleTooltip={tankCapacityTooltip}
/>
</div>
</div>
</div>
{/* Section 3: Event Journal Entries */}
<div className="form-card">
<div className="form-header mb-4">
<Compass size={20} className="form-icon" />
<h3>{t('logs.event_title')}</h3>
</div>
{/* List existing events */}
{events.length === 0 ? (
<div className="dashboard-status-msg mb-6">{t('logs.no_events')}</div>
) : (
<div className="events-scroll-container mb-6">
<table className="events-table">
<thead>
<tr>
<th>{t('logs.event_time')}</th>
<th>{t('logs.event_creator')}</th>
<th>{t('logs.event_mgk')}</th>
<th>{t('logs.event_rwk')}</th>
<th>{t('logs.event_wind_direction')}</th>
<th>{t('logs.event_wind_strength')}</th>
<th>{t('logs.event_sea_state')}</th>
<th>{t('logs.event_weather')}</th>
<th>{t('logs.event_log')}</th>
<th>{t('logs.event_gps')}</th>
<th>{t('logs.event_remarks')}</th>
{!readOnly && <th></th>}
</tr>
</thead>
<tbody>
{events.map((ev, idx) => (
<tr key={idx}>
<td className="font-mono">{ev.time}</td>
<td style={{ textAlign: 'center', width: '40px', verticalAlign: 'middle' }}>
<CreatorAvatar
creatorId={ev.creatorId}
crewSnapshotsById={entryCrew.crewSnapshotsById}
size={24}
/>
</td>
<td>{ev.mgk ? `${ev.mgk}°` : '—'}</td>
<td>{ev.rwk ? `${ev.rwk}°` : '—'}</td>
<td>{ev.windDirection || '—'}</td>
<td>{ev.windStrength || '—'}</td>
<td>{ev.seaState || '—'}</td>
<td>
{ev.weatherIcon ? (
<img
src={`https://openweathermap.org/img/wn/${ev.weatherIcon}.png`}
alt="Weather"
title="Weather Icon"
className="table-weather-img"
/>
) : (
'—'
)}
</td>
<td>{ev.logReading ? `${ev.logReading} nm` : '—'}</td>
<td className="font-mono text-sm">
{ev.gpsLat && ev.gpsLng ? `${ev.gpsLat}, ${ev.gpsLng}` : '—'}
</td>
<td className="remarks-td">
<EventRemarksCell
event={ev}
logbookId={logbookId}
voiceMemoLookup={voiceMemoLookup}
/>
</td>
{!readOnly && (
<td className="events-actions-td">
<div className="events-actions-cell">
<button
type="button"
className="btn-icon"
onClick={() => handleEditEvent(idx)}
title={t('logs.edit_event')}
disabled={editingEventIndex !== null && editingEventIndex !== idx}
>
<Pencil size={14} />
</button>
<button
type="button"
className="btn-icon danger"
onClick={() => handleDeleteEvent(idx)}
title={t('logs.delete_event')}
>
<Trash2 size={14} />
</button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Add New Event Form Sub-Card */}
{!readOnly && (
<div className="member-editor-card glass">
<h4 style={{ margin: '0 0 16px 0', color: '#fbbf24' }}>
{editingEventIndex !== null ? t('logs.edit_event') : t('logs.add_event')}
</h4>
<div className="form-grid mb-4">
<div className="input-group">
<label>
<Clock size={12} style={{ display: 'inline', marginRight: 4 }} />
{t('logs.event_time')} *
</label>
<EventTimeInput24h
value={evTime}
onChange={setEvTime}
disabled={saving}
aria-label={t('logs.event_time')}
/>
</div>
<div className="input-group course-dial-section">
<label>
<Compass size={12} style={{ display: 'inline', marginRight: 4 }} />
{t('logs.event_course_section')}
</label>
<div className="course-dial-tabs" role="tablist" aria-label={t('logs.event_course_section')}>
<button
type="button"
role="tab"
aria-selected={activeCourseTab === 'mgk'}
className={`course-dial-tab${activeCourseTab === 'mgk' ? ' is-active' : ''}`}
onClick={() => setActiveCourseTab('mgk')}
disabled={saving}
>
{t('logs.course_tab_mgk')}
</button>
<button
type="button"
role="tab"
aria-selected={activeCourseTab === 'rwk'}
className={`course-dial-tab${activeCourseTab === 'rwk' ? ' is-active' : ''}`}
onClick={() => setActiveCourseTab('rwk')}
disabled={saving}
>
{t('logs.course_tab_rwk')}
</button>
</div>
<CourseDialInput
value={activeCourseTab === 'mgk' ? evMgk : evRwk}
onChange={activeCourseTab === 'mgk' ? setEvMgk : setEvRwk}
disabled={saving}
aria-label={activeCourseTab === 'mgk' ? t('logs.event_mgk') : t('logs.event_rwk')}
/>
</div>
<div className="input-group">
<label>{t('logs.event_log')}</label>
<input
type="text"
placeholder="e.g. 124.5"
className="input-text"
value={evLogReading}
onChange={(e) => setEvLogReading(e.target.value)}
disabled={saving}
/>
</div>
</div>
<div className="form-grid mb-4">
<div className="input-group">
<label>{t('logs.event_location')}</label>
<input
type="text"
placeholder={t('logs.event_location_placeholder')}
className="input-text"
value={evLocationName}
onChange={(e) => setEvLocationName(e.target.value)}
disabled={saving}
/>
</div>
<div className="input-group">
<label>{t('logs.event_gps')} (Lat, Lng)</label>
<div className="gps-input-row">
<input
type="text"
placeholder="Lat"
className="input-text"
value={evGpsLat}
onChange={(e) => { clearGpsSignal(); setEvGpsLat(e.target.value) }}
disabled={saving}
/>
<input
type="text"
placeholder="Lng"
className="input-text"
value={evGpsLng}
onChange={(e) => { clearGpsSignal(); setEvGpsLng(e.target.value) }}
disabled={saving}
/>
<button
type="button"
className="btn secondary"
onClick={() => void handleGetGps()}
title={t('logs.gps_btn')}
style={{ width: 'auto', padding: '12px' }}
disabled={saving}
>
<MapPin size={16} />
</button>
<button
type="button"
className="btn secondary"
onClick={handleFetchWeather}
title={t('logs.weather_btn')}
style={{ width: 'auto', padding: '12px' }}
disabled={
saving ||
weatherLoading ||
(!evGpsLat && !evLocationName.trim() && !departure.trim() && !destination.trim()) ||
(!!evGpsLat && !evGpsLng)
}
>
<CloudSun size={16} />
</button>
</div>
{gpsSignal && (
<GpsSignalHint
quality={gpsSignal.quality}
accuracyM={gpsSignal.accuracyM}
className="gps-signal-hint-editor"
/>
)}
</div>
</div>
<div className="form-grid weather-metrics-grid mb-4">
<div className="input-group course-dial-section weather-metrics-span-2">
<label>{t('logs.event_wind_direction')}</label>
<CourseDialInput
value={evWindDirection}
onChange={setEvWindDirection}
disabled={saving || weatherLoading}
allowCardinal
displayMode="auto"
aria-label={t('logs.event_wind_direction')}
/>
</div>
<div className="input-group">
<label>{t('logs.event_wind_strength')}</label>
<input
type="text"
placeholder="e.g. 4 Bft"
className="input-text"
value={evWindStrength}
onChange={(e) => setEvWindStrength(e.target.value)}
disabled={saving || weatherLoading}
/>
</div>
<MetricRangeInput
label={t('logs.event_wind_pressure')}
value={evWindPressure}
onChange={setEvWindPressure}
disabled={saving || weatherLoading}
min={PRESSURE_MIN_HPA}
max={PRESSURE_MAX_HPA}
step={1}
defaultNumeric={PRESSURE_DEFAULT_HPA}
parse={parsePressureHpa}
format={formatPressureHpa}
formatDisplay={(hpa) =>
t('logs.weather_slider_pressure', { value: hpa, defaultValue: `${hpa} hPa` })}
numberMin={PRESSURE_MIN_HPA}
numberMax={PRESSURE_MAX_HPA}
numberStep={1}
numberPlaceholder="1013"
/>
<MetricRangeInput
label={t('logs.event_sea_state')}
value={evSeaState}
onChange={setEvSeaState}
disabled={saving}
min={SEA_STATE_MIN}
max={SEA_STATE_MAX}
step={1}
defaultNumeric={0}
parse={parseSeaState}
format={formatSeaState}
formatDisplay={(level) =>
t('logs.weather_slider_sea_state', { value: level, defaultValue: `${level}` })}
numberMin={SEA_STATE_MIN}
numberMax={SEA_STATE_MAX}
numberStep={1}
numberPlaceholder="3"
allowLegacyText
/>
<MetricRangeInput
label={t('logs.event_visibility')}
value={evVisibility}
onChange={setEvVisibility}
disabled={saving || weatherLoading}
discreteValues={VISIBILITY_STEPS_M}
defaultNumeric={10000}
parse={parseVisibilityMeters}
format={formatVisibilityMeters}
formatDisplay={(m) => formatVisibilityMeters(m)}
hideNumberInput
/>
<MetricRangeInput
label={t('logs.event_heel')}
value={evHeel}
onChange={setEvHeel}
disabled={saving}
min={HEEL_MIN_DEG}
max={HEEL_MAX_DEG}
step={1}
defaultNumeric={0}
parse={parseHeelDeg}
format={formatHeelDeg}
formatDisplay={(deg) =>
t('logs.weather_slider_heel', { value: deg, defaultValue: `${deg}°` })}
numberMin={HEEL_MIN_DEG}
numberMax={HEEL_MAX_DEG}
numberStep={1}
numberPlaceholder="5"
/>
</div>
<div className="form-grid mb-4">
<div className="input-group">
<label>{t('logs.event_sails')}</label>
<input
type="text"
placeholder="e.g. Mainsail + Jib"
className="input-text"
value={evSailsOrMotor}
onChange={(e) => setEvSailsOrMotor(e.target.value)}
disabled={saving}
/>
</div>
<div className="input-group">
<label>{t('logs.event_distance')}</label>
<input
type="text"
placeholder="e.g. 12 nm"
className="input-text"
value={evDistance}
onChange={(e) => setEvDistance(e.target.value)}
disabled={saving}
/>
</div>
<div
className={[
'sails-picker-container grid-span-2',
showSailsPickerToggle ? 'is-collapsible' : '',
showSailsPickerToggle && !sailsPickerExpanded ? 'is-collapsed' : '',
].filter(Boolean).join(' ')}
>
<div className="sails-picker-pills">
{isMotorActive && (
<span
className={`sail-pill motor-pill active`}
onClick={() => toggleSailOrMotor(motorPropulsionLabel)}
>
{motorPropulsionLabel}
</span>
)}
{sortedEventSailOptions.map((sail) => (
<span
key={sail}
className={`sail-pill ${isItemActive(sail) ? 'active' : ''}`}
onClick={() => toggleSailOrMotor(sail)}
>
{sail}
</span>
))}
{!isMotorActive && (
<span
className="sail-pill motor-pill"
onClick={() => toggleSailOrMotor(motorPropulsionLabel)}
>
{motorPropulsionLabel}
</span>
)}
</div>
{showSailsPickerToggle && (
<button
type="button"
className="sails-picker-toggle"
onClick={() => setSailsPickerExpanded((prev) => !prev)}
aria-expanded={sailsPickerExpanded}
>
{sailsPickerExpanded ? (
<>
<ChevronUp size={14} aria-hidden="true" />
{t('logs.sails_picker_show_less')}
</>
) : (
<>
<ChevronDown size={14} aria-hidden="true" />
{t('logs.sails_picker_show_more')}
</>
)}
</button>
)}
</div>
<div className="input-group grid-span-2">
<label>{t('logs.event_remarks')}</label>
<input
type="text"
placeholder="Remarks"
className="input-text"
value={evRemarks}
onChange={(e) => setEvRemarks(e.target.value)}
disabled={saving}
/>
</div>
</div>
<div style={{ display: 'flex', gap: '8px', marginLeft: 'auto', flexWrap: 'wrap' }}>
{editingEventIndex !== null && (
<button
type="button"
className="btn secondary"
onClick={handleCancelEventEdit}
disabled={saving}
style={{ width: 'auto', padding: '10px 20px', display: 'flex' }}
>
<X size={16} />
{t('logs.cancel_event_edit')}
</button>
)}
<button
type="button"
className="btn secondary"
onClick={handleSaveEvent}
disabled={saving || !isValidTimeHHMM(evTime)}
style={{ width: 'auto', padding: '10px 20px', display: 'flex' }}
>
{editingEventIndex !== null ? <Save size={16} /> : <Plus size={16} />}
{editingEventIndex !== null ? t('logs.save_event_btn') : t('logs.add_event_btn')}
</button>
</div>
</div>
)}
</div>
{/* Track file upload */}
<div className="form-card" data-tour="entry-track">
<div className="form-header">
<Upload size={20} className="form-icon" />
<h3>{t('logs.track_upload_title')}</h3>
</div>
{uploadError && <div className="track-error-msg">{uploadError}</div>}
{!savedTrack ? (
<div
className={`track-upload-zone ${dragOver ? 'dragover' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
accept=".gpx,.kml,.json,.geojson"
onChange={handleFileChange}
disabled={saving}
/>
<Upload size={36} className="track-upload-icon" />
<div className="track-upload-text">{t('logs.gps_track_upload_btn')}</div>
<div className="track-upload-subtext">{t('logs.gps_track_upload_help')}</div>
</div>
) : (
<>
<div className="track-info-header">
<div className="track-info-left">
<Upload size={16} style={{ color: '#fbbf24' }} />
<span className="track-info-name">{savedTrack.filename || 'track'}</span>
<span className="track-info-stats">
{(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</>
)}
</span>
</div>
<div className="track-actions">
<button
type="button"
className="btn secondary"
onClick={() => downloadTrackFile(savedTrack)}
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px' }}
title={t('logs.gps_tracking_btn_gpx')}
>
<Download size={14} />
<span className="hide-mobile">{t('logs.gps_tracking_btn_gpx')}</span>
</button>
{!readOnly && (
<button
type="button"
className="btn danger btn-sm btn-inline-icon"
onClick={handleDeleteTrack}
title={t('logs.gps_track_delete')}
>
<Trash2 size={14} />
<span className="hide-mobile">{t('logs.gps_track_delete')}</span>
</button>
)}
</div>
</div>
{savedTrack.waypoints.length > 0 && (
<TrackMap waypoints={savedTrack.waypoints} />
)}
</>
)}
{!readOnly && (
<div className="nmea-import-section" style={{ marginTop: '12px' }}>
<button
type="button"
className="btn secondary"
onClick={() => setNmeaWizardOpen(true)}
style={{ width: 'auto', padding: '8px 14px', display: 'inline-flex', alignItems: 'center', gap: '6px' }}
>
<FileText size={16} />
{t('logs.nmea_import_btn')}
</button>
{nmeaArchive && (
<div className="nmea-archive-info" style={{ marginTop: '8px', display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
<span>{t('logs.nmea_archive_stored', { name: nmeaArchive.filename })}</span>
<button type="button" className="btn secondary" style={{ width: 'auto', padding: '4px 10px', fontSize: '13px' }} onClick={() => downloadNmeaArchive(nmeaArchive)}>
<Download size={14} />
</button>
<button
type="button"
className="btn danger btn-sm btn-inline-icon"
onClick={handleDeleteNmeaArchive}
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
)}
{(savedTrack || trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) && (
<div className="form-grid track-stats-grid">
<div className="input-group">
<label>{t('logs.track_distance')}</label>
<input
type="text"
inputMode="decimal"
placeholder="e.g. 5.0"
className="input-text"
value={trackDistanceNm}
onChange={(e) => setTrackDistanceNm(e.target.value)}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('logs.track_speed_max')}</label>
<input
type="text"
inputMode="decimal"
placeholder="e.g. 7.8"
className="input-text"
value={trackSpeedMaxKn}
onChange={(e) => setTrackSpeedMaxKn(e.target.value)}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('logs.track_speed_avg')}</label>
<input
type="text"
inputMode="decimal"
placeholder="e.g. 4.6"
className="input-text"
value={trackSpeedAvgKn}
onChange={(e) => setTrackSpeedAvgKn(e.target.value)}
disabled={saving || readOnly}
/>
</div>
</div>
)}
</div>
<PhotoCapture entryId={entryId} logbookId={logbookId} readOnly={readOnly} preloadedPhotos={preloadedPhotos} />
<EntryCrewSection
logbookId={logbookId}
readOnly={readOnly}
value={entryCrew}
onChange={setEntryCrew}
/>
<SignatureSection
readOnly={readOnly}
disabled={saving}
isOnline={isOnline}
canSignSkipper={canSignSkipper}
canSignCrew={canSignCrew}
signSkipper={signSkipper}
signCrew={signCrew}
skipperSignatureValid={skipperSignatureValid}
crewSignatureValid={crewSignatureValid}
onSignSkipperChange={(value) => {
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 && (
<div className="form-actions mt-4">
{success && (
<div className="success-toast">
<Check size={16} />
<span>{t('logs.saved')}</span>
</div>
)}
<button type="submit" className="btn primary" disabled={saving || !date || !dayOfTravel.trim() || !isDirty}>
<Save size={18} />
{saving ? t('logs.saving') : t('logs.save')}
</button>
</div>
)}
</form>
<NmeaImportWizard
open={nmeaWizardOpen}
onClose={() => {
setNmeaWizardOpen(false)
void loadNmeaArchive()
}}
logbookId={logbookId}
entryId={entryId}
entryDate={date}
nmeaArchive={nmeaArchive}
onImport={handleNmeaImport}
/>
</div>
)
}