feat(tides): support role-based multi-location tide retrieval, selection, and storage

This commit is contained in:
2026-06-12 13:58:38 +02:00
parent abd5fe1ac8
commit 5b9c1e3220
15 changed files with 623 additions and 151 deletions
+70 -28
View File
@@ -60,11 +60,15 @@ import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js' import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
import { TidesApiError, type TideStation } from '../services/tides.js' import { TidesApiError, type TideStation } from '../services/tides.js'
import { TideStationPickerModal } from './TideStationPickerModal.tsx' import { TideStationPickerModal } from './TideStationPickerModal.tsx'
import { TideLocationPickerModal } from './TideLocationPickerModal.tsx'
import { import {
buildTideLocationMeta, buildTideLocationMeta,
formatTideLocationLabel, formatTideLocationLabel,
resolveTideFetchLocation getAvailableTideLocations,
type TideLocationOption,
type TideFetchLocation
} from '../utils/tideLocation.js' } from '../utils/tideLocation.js'
import type { TideRole } from '../utils/logEntryPayload.js'
import { import {
fetchTidesForEntry, fetchTidesForEntry,
fetchTidesForStationChoice, fetchTidesForStationChoice,
@@ -124,6 +128,7 @@ type LiveModal =
| 'stw' | 'stw'
| 'position' | 'position'
| 'tides' | 'tides'
| 'tides_picker'
| 'photo' | 'photo'
| 'voice' | 'voice'
@@ -207,6 +212,7 @@ export default function LiveLogView({
const [dayOfTravel, setDayOfTravel] = useState('') const [dayOfTravel, setDayOfTravel] = useState('')
const [date, setDate] = useState('') const [date, setDate] = useState('')
const [departure, setDeparture] = useState('') const [departure, setDeparture] = useState('')
const [destination, setDestination] = useState('')
const [events, setEvents] = useState<LogEventPayload[]>([]) const [events, setEvents] = useState<LogEventPayload[]>([])
const [crewSnapshotsById, setCrewSnapshotsById] = useState<Record<string, any>>({}) const [crewSnapshotsById, setCrewSnapshotsById] = useState<Record<string, any>>({})
const [selectedSkipperId, setSelectedSkipperId] = useState<string | null>(null) const [selectedSkipperId, setSelectedSkipperId] = useState<string | null>(null)
@@ -222,8 +228,10 @@ export default function LiveLogView({
highWater: string highWater: string
lowWater: string lowWater: string
location: ReturnType<typeof buildTideLocationMeta> location: ReturnType<typeof buildTideLocationMeta>
role: TideRole
} | null>(null) } | null>(null)
const [tideStationPicker, setTideStationPicker] = useState<TideFetchNeedsStationPick | null>(null) const [tideStationPicker, setTideStationPicker] = useState<TideFetchNeedsStationPick | null>(null)
const [tideLocationPickerOptions, setTideLocationPickerOptions] = useState<TideLocationOption[] | null>(null)
const [isOnline, setIsOnline] = useState(navigator.onLine) const [isOnline, setIsOnline] = useState(navigator.onLine)
const [commentText, setCommentText] = useState('') const [commentText, setCommentText] = useState('')
const [valueInput, setValueInput] = useState('') const [valueInput, setValueInput] = useState('')
@@ -326,6 +334,7 @@ export default function LiveLogView({
setDayOfTravel(String(loaded.data.dayOfTravel || '')) setDayOfTravel(String(loaded.data.dayOfTravel || ''))
setDate(String(loaded.data.date || '')) setDate(String(loaded.data.date || ''))
setDeparture(String(loaded.data.departure || '')) setDeparture(String(loaded.data.departure || ''))
setDestination(String(loaded.data.destination || ''))
setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e })))) setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e }))))
setCrewSnapshotsById((loaded.data.crewSnapshotsById as Record<string, any>) || {}) setCrewSnapshotsById((loaded.data.crewSnapshotsById as Record<string, any>) || {})
setSelectedSkipperId(typeof loaded.data.selectedSkipperId === 'string' ? loaded.data.selectedSkipperId : null) setSelectedSkipperId(typeof loaded.data.selectedSkipperId === 'string' ? loaded.data.selectedSkipperId : null)
@@ -809,6 +818,12 @@ export default function LiveLogView({
})() })()
} }
const getRoleForLocationSource = (source: string): TideRole => {
if (source === 'gps') return 'gps'
if (source === 'destination') return 'destination'
return 'departure'
}
const handleTideStationPick = (pick: TideFetchNeedsStationPick, station: TideStation) => { const handleTideStationPick = (pick: TideFetchNeedsStationPick, station: TideStation) => {
setTidesLoading(true) setTidesLoading(true)
void (async () => { void (async () => {
@@ -825,7 +840,8 @@ export default function LiveLogView({
setTidePreview({ setTidePreview({
highWater: result.highWater, highWater: result.highWater,
lowWater: result.lowWater, lowWater: result.lowWater,
location: result.location location: result.location,
role: getRoleForLocationSource(pick.fetchLocation.source)
}) })
setModal('tides') setModal('tides')
} catch (err) { } catch (err) {
@@ -841,34 +857,13 @@ export default function LiveLogView({
})() })()
} }
const handleFetchTides = () => { const startTideFetchForLocation = (fetchLocation: TideFetchLocation) => {
if (!entryId || busy || tidesLoading) return
if (!isOnline) {
void showAlert(t('logs.weather_offline'), t('logs.tides'))
return
}
setTidesLoading(true) setTidesLoading(true)
setError(null) setError(null)
void (async () => { void (async () => {
try { try {
const location = resolveTideFetchLocation({
events,
entryDate: date,
departure
})
if ('error' in location) {
void showAlert(
location.error === 'stale'
? t('logs.tide_position_stale')
: t('logs.tide_location_required'),
t('logs.tides')
)
return
}
const outcome = await fetchTidesForEntry({ const outcome = await fetchTidesForEntry({
fetchLocation: location, fetchLocation,
entryDate: date, entryDate: date,
analyticsSource: 'live_log' analyticsSource: 'live_log'
}) })
@@ -882,7 +877,8 @@ export default function LiveLogView({
setTidePreview({ setTidePreview({
highWater: result.highWater, highWater: result.highWater,
lowWater: result.lowWater, lowWater: result.lowWater,
location: result.location location: result.location,
role: getRoleForLocationSource(fetchLocation.source)
}) })
setModal('tides') setModal('tides')
} catch (err) { } catch (err) {
@@ -892,7 +888,8 @@ export default function LiveLogView({
return return
} }
if (err.code === 'PLACE_NOT_FOUND') { if (err.code === 'PLACE_NOT_FOUND') {
void showAlert(t('logs.tide_place_not_found', { place: departure.trim() }), t('logs.tides')) const query = fetchLocation.mode === 'by-place' ? fetchLocation.query : ''
void showAlert(t('logs.tide_place_not_found', { place: query.trim() }), t('logs.tides'))
return return
} }
if (err.code === 'NO_DATA_FOR_DATE') { if (err.code === 'NO_DATA_FOR_DATE') {
@@ -912,11 +909,38 @@ export default function LiveLogView({
})() })()
} }
const handleFetchTides = () => {
if (!entryId || busy || tidesLoading) return
if (!isOnline) {
void showAlert(t('logs.weather_offline'), t('logs.tides'))
return
}
const available = getAvailableTideLocations({
departure,
destination,
events,
entryDate: date
})
if (available.length === 0) {
void showAlert(t('logs.tide_location_required'), t('logs.tides'))
return
}
if (available.length === 1) {
startTideFetchForLocation(available[0].fetchLocation)
} else {
setTideLocationPickerOptions(available)
setModal('tides_picker')
}
}
const confirmTides = () => { const confirmTides = () => {
if (!entryId || !tidePreview || busy) return if (!entryId || !tidePreview || busy) return
const preview = tidePreview const preview = tidePreview
void runQuickAction(async () => { void runQuickAction(async () => {
await patchEntryTides(logbookId, entryId, { await patchEntryTides(logbookId, entryId, preview.role, {
highWater: preview.highWater, highWater: preview.highWater,
lowWater: preview.lowWater, lowWater: preview.lowWater,
...preview.location ...preview.location
@@ -1619,6 +1643,24 @@ export default function LiveLogView({
/> />
) : null} ) : null}
{modal === 'tides_picker' && tideLocationPickerOptions ? (
<TideLocationPickerModal
title={t('logs.tide_location_picker_title')}
hint={t('logs.tide_location_picker_hint')}
cancelLabel={t('logs.live_cancel')}
options={tideLocationPickerOptions}
onCancel={() => {
setTideLocationPickerOptions(null)
closeModal()
}}
onSelect={(option) => {
setTideLocationPickerOptions(null)
closeModal()
startTideFetchForLocation(option.fetchLocation)
}}
/>
) : null}
{modal === 'tides' && tidePreview && ( {modal === 'tides' && tidePreview && (
<div <div
className="live-log-modal-backdrop" className="live-log-modal-backdrop"
+150 -64
View File
@@ -13,7 +13,7 @@ import PhotoCapture from './PhotoCapture.tsx'
import EventRemarksCell from './EventRemarksCell.tsx' import EventRemarksCell from './EventRemarksCell.tsx'
import CreatorAvatar from './CreatorAvatar.tsx' import CreatorAvatar from './CreatorAvatar.tsx'
import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js' import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js'
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js' import { parseLiveVoiceRemark, getLastLoggedPositionWithin } from '../utils/liveEventCodes.js'
import { deleteEntryVoiceMemo } from '../services/voiceAttachments.js' import { deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
import type { PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx' import type { PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx'
import SignatureSection from './SignatureSection.tsx' import SignatureSection from './SignatureSection.tsx'
@@ -33,7 +33,18 @@ import {
hasAnySignature hasAnySignature
} from '../utils/signatures.js' } from '../utils/signatures.js'
import type { SignatureValue } from '../types/signatures.js' import type { SignatureValue } from '../types/signatures.js'
import { buildLogEntryPayload, readLogEntryTides, sortLogEventsByTime, normalizeLogEvent, hasUnsavedEventDraft, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload, type LogEntryTides } from '../utils/logEntryPayload.js' import {
buildLogEntryPayload,
readLogEntryTidesMap,
sortLogEventsByTime,
normalizeLogEvent,
hasUnsavedEventDraft,
currentLocalTimeHHMM,
isValidTimeHHMM,
type LogEventPayload,
type LogEntryTidesMap,
type TideRole
} from '../utils/logEntryPayload.js'
import EventTimeInput24h from './EventTimeInput24h.tsx' import EventTimeInput24h from './EventTimeInput24h.tsx'
import CourseDialInput from './CourseDialInput.tsx' import CourseDialInput from './CourseDialInput.tsx'
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js' import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
@@ -45,11 +56,13 @@ import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js' import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
import { TidesApiError, type TideStation } from '../services/tides.js' import { TidesApiError, type TideStation } from '../services/tides.js'
import { TideStationPickerModal } from './TideStationPickerModal.tsx' import { TideStationPickerModal } from './TideStationPickerModal.tsx'
import { TideLocationPickerModal } from './TideLocationPickerModal.tsx'
import { import {
formatTideLocationLabel, formatTideLocationLabel,
pickTideLocationMeta, getAvailableTideLocations,
resolveTideFetchLocation, type TideLocationMeta,
type TideLocationMeta type TideLocationOption,
type TideFetchLocation
} from '../utils/tideLocation.js' } from '../utils/tideLocation.js'
import { import {
fetchTidesForEntry, fetchTidesForEntry,
@@ -178,7 +191,7 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
motorHoursRaw != null && motorHoursRaw !== '' motorHoursRaw != null && motorHoursRaw !== ''
? (parseAppDecimal(String(motorHoursRaw)) ?? undefined) ? (parseAppDecimal(String(motorHoursRaw)) ?? undefined)
: undefined, : undefined,
tides: readLogEntryTides(decrypted), tides: readLogEntryTidesMap(decrypted),
events: (decrypted.events as LogEventPayload[]) || [], events: (decrypted.events as LogEventPayload[]) || [],
entryCrew: entryCrewFromPreviousEntry(decrypted as Record<string, unknown>) entryCrew: entryCrewFromPreviousEntry(decrypted as Record<string, unknown>)
}) })
@@ -314,9 +327,8 @@ export default function LogEntryEditor({
const [eventsCollapsed, setEventsCollapsed] = useState(true) const [eventsCollapsed, setEventsCollapsed] = useState(true)
const [addEventFormCollapsed, setAddEventFormCollapsed] = useState(false) const [addEventFormCollapsed, setAddEventFormCollapsed] = useState(false)
const [tidesCollapsed, setTidesCollapsed] = useState(true) const [tidesCollapsed, setTidesCollapsed] = useState(true)
const [tideHighWater, setTideHighWater] = useState('') const [tidesMap, setTidesMap] = useState<LogEntryTidesMap>({})
const [tideLowWater, setTideLowWater] = useState('') const [tideLocationPickerOptions, setTideLocationPickerOptions] = useState<TideLocationOption[] | null>(null)
const [tideLocation, setTideLocation] = useState<TideLocationMeta>({})
const [tidesLoading, setTidesLoading] = useState(false) const [tidesLoading, setTidesLoading] = useState(false)
const [tideStationPicker, setTideStationPicker] = useState<TideFetchNeedsStationPick | null>(null) const [tideStationPicker, setTideStationPicker] = useState<TideFetchNeedsStationPick | null>(null)
const [tanksCollapsed, setTanksCollapsed] = useState(true) const [tanksCollapsed, setTanksCollapsed] = useState(true)
@@ -432,7 +444,7 @@ export default function LogEntryEditor({
} }
} }
const buildPayloadForSigning = useCallback((eventsOverride?: LogEvent[], tidesOverride?: LogEntryTides) => { const buildPayloadForSigning = useCallback((eventsOverride?: LogEvent[], tidesOverride?: LogEntryTidesMap) => {
return buildLogEntryPayload({ return buildLogEntryPayload({
date, date,
dayOfTravel, dayOfTravel,
@@ -451,7 +463,7 @@ export default function LogEntryEditor({
consumption: parseAppDecimalOrZero(fuelConsumption) consumption: parseAppDecimalOrZero(fuelConsumption)
}, },
greywater: { level: parseAppDecimalOrZero(greywaterLevel) }, greywater: { level: parseAppDecimalOrZero(greywaterLevel) },
tides: tidesOverride ?? { highWater: tideHighWater, lowWater: tideLowWater, ...tideLocation }, tides: tidesOverride ?? tidesMap,
trackDistanceNm: parseOptionalFormDecimal(trackDistanceNm), trackDistanceNm: parseOptionalFormDecimal(trackDistanceNm),
trackSpeedMaxKn: parseOptionalFormDecimal(trackSpeedMaxKn), trackSpeedMaxKn: parseOptionalFormDecimal(trackSpeedMaxKn),
trackSpeedAvgKn: parseOptionalFormDecimal(trackSpeedAvgKn), trackSpeedAvgKn: parseOptionalFormDecimal(trackSpeedAvgKn),
@@ -464,7 +476,7 @@ export default function LogEntryEditor({
fwMorning, fwRefilled, fwEvening, fwConsumption, fwMorning, fwRefilled, fwEvening, fwConsumption,
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption, fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
greywaterLevel, greywaterLevel,
tideHighWater, tideLowWater, tideLocation, tidesMap,
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours, trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
events, events,
entryCrew entryCrew
@@ -515,9 +527,13 @@ export default function LogEntryEditor({
[fuelMorning, fuelRefilled, tankCapacities.fuelCapacityL] [fuelMorning, fuelRefilled, tankCapacities.fuelCapacityL]
) )
const tideLocationLabel = useMemo( const getTideLocationLabel = useCallback(
() => formatTideLocationLabel(tideLocation, t), (role: TideRole) => {
[tideLocation, t] const tideData = tidesMap[role]
if (!tideData) return ''
return formatTideLocationLabel(tideData, t)
},
[tidesMap, t]
) )
const currentFingerprint = useMemo(() => { const currentFingerprint = useMemo(() => {
@@ -603,7 +619,7 @@ export default function LogEntryEditor({
signCrew?: SignatureValue | '' signCrew?: SignatureValue | ''
aiSummary?: string aiSummary?: string
aiSummaryGeneratedAt?: string aiSummaryGeneratedAt?: string
tidesOverride?: LogEntryTides tidesOverride?: LogEntryTidesMap
} }
) => { ) => {
if (readOnly) return if (readOnly) return
@@ -951,10 +967,8 @@ export default function LogEntryEditor({
setGreywaterLevel('0') setGreywaterLevel('0')
} }
const preloadedTides = readLogEntryTides(preloadedEntry as Record<string, unknown>) const preloadedTides = readLogEntryTidesMap(preloadedEntry as Record<string, unknown>)
setTideHighWater(preloadedTides.highWater) setTidesMap(preloadedTides)
setTideLowWater(preloadedTides.lowWater)
setTideLocation(pickTideLocationMeta(preloadedTides))
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '') setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '') setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
@@ -997,10 +1011,8 @@ export default function LogEntryEditor({
setGreywaterLevel('0') setGreywaterLevel('0')
} }
const loadedTides = readLogEntryTides(decrypted as Record<string, unknown>) const loadedTides = readLogEntryTidesMap(decrypted as Record<string, unknown>)
setTideHighWater(loadedTides.highWater) setTidesMap(loadedTides)
setTideLowWater(loadedTides.lowWater)
setTideLocation(pickTideLocationMeta(loadedTides))
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '') setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
setSignCrew(normalizeSignature(decrypted.signCrew) || '') setSignCrew(normalizeSignature(decrypted.signCrew) || '')
@@ -1311,7 +1323,13 @@ export default function LogEntryEditor({
} }
} }
const applyTideFetchResult = async (result: { const getRoleForLocationSource = (source: string): TideRole => {
if (source === 'gps') return 'gps'
if (source === 'destination') return 'destination'
return 'departure'
}
const applyTideFetchResult = async (role: TideRole, result: {
highWater: string highWater: string
lowWater: string lowWater: string
location: TideLocationMeta location: TideLocationMeta
@@ -1321,12 +1339,14 @@ export default function LogEntryEditor({
lowWater: result.lowWater, lowWater: result.lowWater,
...result.location ...result.location
} }
setTideHighWater(result.highWater) const nextTidesMap = {
setTideLowWater(result.lowWater) ...tidesMap,
setTideLocation(result.location) [role]: nextTides
}
setTidesMap(nextTidesMap)
try { try {
await persistEntryToDb({ tidesOverride: nextTides }) await persistEntryToDb({ tidesOverride: nextTidesMap })
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED) trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
} catch (err) { } catch (err) {
console.error('Failed to auto-save after tide fetch:', err) console.error('Failed to auto-save after tide fetch:', err)
@@ -1345,7 +1365,8 @@ export default function LogEntryEditor({
queryLng: pick.queryLng, queryLng: pick.queryLng,
analyticsSource: 'entry_editor' analyticsSource: 'entry_editor'
}) })
await applyTideFetchResult(result) const role = getRoleForLocationSource(pick.fetchLocation.source)
await applyTideFetchResult(role, result)
setTideStationPicker(null) setTideStationPicker(null)
} catch (err) { } catch (err) {
if (err instanceof TidesApiError && err.code === 'NO_DATA_FOR_DATE') { if (err instanceof TidesApiError && err.code === 'NO_DATA_FOR_DATE') {
@@ -1359,30 +1380,11 @@ export default function LogEntryEditor({
} }
} }
const handleFetchTides = async () => { const startTideFetchForLocation = async (fetchLocation: TideFetchLocation) => {
if (!isOnline) {
showAlert(t('logs.weather_offline'), t('logs.tide_fetch_btn'))
return
}
setTidesLoading(true) setTidesLoading(true)
try { try {
const location = resolveTideFetchLocation({
events,
entryDate: date,
departure
})
if ('error' in location) {
if (location.error === 'stale') {
showAlert(t('logs.tide_position_stale'), t('logs.tide_fetch_btn'))
} else {
showAlert(t('logs.tide_location_required'), t('logs.tide_fetch_btn'))
}
return
}
const outcome = await fetchTidesForEntry({ const outcome = await fetchTidesForEntry({
fetchLocation: location, fetchLocation,
entryDate: date, entryDate: date,
analyticsSource: 'entry_editor' analyticsSource: 'entry_editor'
}) })
@@ -1392,7 +1394,8 @@ export default function LogEntryEditor({
return return
} }
await applyTideFetchResult(outcome as TideFetchResult) const role = getRoleForLocationSource(fetchLocation.source)
await applyTideFetchResult(role, outcome as TideFetchResult)
} catch (err) { } catch (err) {
if (err instanceof TidesApiError) { if (err instanceof TidesApiError) {
if (err.code === 'OFFLINE') { if (err.code === 'OFFLINE') {
@@ -1400,7 +1403,8 @@ export default function LogEntryEditor({
return return
} }
if (err.code === 'PLACE_NOT_FOUND') { if (err.code === 'PLACE_NOT_FOUND') {
showAlert(t('logs.tide_place_not_found', { place: departure.trim() }), t('logs.tide_fetch_btn')) const query = fetchLocation.mode === 'by-place' ? fetchLocation.query : ''
showAlert(t('logs.tide_place_not_found', { place: query.trim() }), t('logs.tide_fetch_btn'))
return return
} }
if (err.code === 'NO_DATA_FOR_DATE') { if (err.code === 'NO_DATA_FOR_DATE') {
@@ -1419,6 +1423,31 @@ export default function LogEntryEditor({
} }
} }
const handleFetchTides = async () => {
if (!isOnline) {
showAlert(t('logs.weather_offline'), t('logs.tide_fetch_btn'))
return
}
const available = getAvailableTideLocations({
departure,
destination,
events,
entryDate: date
})
if (available.length === 0) {
showAlert(t('logs.tide_location_required'), t('logs.tide_fetch_btn'))
return
}
if (available.length === 1) {
await startTideFetchForLocation(available[0].fetchLocation)
} else {
setTideLocationPickerOptions(available)
}
}
const handleGenerateAiSummary = async () => { const handleGenerateAiSummary = async () => {
if (!canSignSkipper || readOnly || aiSummaryLoading) return if (!canSignSkipper || readOnly || aiSummaryLoading) return
if (!getAiAuthorized()) { if (!getAiAuthorized()) {
@@ -2285,38 +2314,81 @@ export default function LogEntryEditor({
{!tidesCollapsed && ( {!tidesCollapsed && (
<div className="tides-panel"> <div className="tides-panel">
<div className="tides-panel__hints"> <div className="tides-panel__hints" style={{ marginBottom: '16px' }}>
<p className="form-hint" role="note"> <p className="form-hint" role="note">
{t('logs.tide_disclaimer')} {t('logs.tide_disclaimer')}
</p> </p>
{tideLocationLabel ? ( </div>
<p className="tides-panel__location" role="status">
{tideLocationLabel} {(['departure', 'destination', 'gps'] as TideRole[]).map((role) => {
const tideData = tidesMap[role] || { highWater: '', lowWater: '' }
const label = getTideLocationLabel(role)
const isAvailable = (role === 'departure' && departure.trim()) ||
(role === 'destination' && destination.trim()) ||
(role === 'gps' && getLastLoggedPositionWithin(events, date) != null)
const hasData = Boolean(tidesMap[role]?.highWater || tidesMap[role]?.lowWater)
if (!isAvailable && !hasData) return null
const roleTitle = role === 'departure'
? t('logs.tide_role_departure')
: role === 'destination'
? t('logs.tide_role_destination')
: t('logs.tide_role_gps')
return (
<div key={role} className="tide-role-section mb-6" style={{ borderBottom: '1px solid var(--border-color, #eee)', paddingBottom: '16px', marginBottom: '16px' }}>
<h4 style={{ margin: '0 0 8px 0', display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--text-color-primary, #333)' }}>
<Waves size={16} />
{roleTitle}
</h4>
{label ? (
<p className="tides-panel__location" role="status" style={{ fontSize: '0.85em', color: 'var(--text-color-secondary, #666)', margin: '0 0 12px 0' }}>
{label}
</p> </p>
) : null} ) : null}
</div>
<div className="form-grid tides-panel__fields"> <div className="form-grid tides-panel__fields">
<div className="input-group"> <div className="input-group">
<label>{t('logs.tide_high_water')}</label> <label>{t('logs.tide_high_water')}</label>
<EventTimeInput24h <EventTimeInput24h
value={tideHighWater} value={tideData.highWater}
onChange={setTideHighWater} onChange={(val) => {
const nextTidesMap = {
...tidesMap,
[role]: { ...tideData, highWater: val }
}
setTidesMap(nextTidesMap)
void persistEntryToDb({ tidesOverride: nextTidesMap })
}}
disabled={readOnly || saving || tidesLoading} disabled={readOnly || saving || tidesLoading}
aria-label={t('logs.tide_high_water')} aria-label={`${roleTitle} - ${t('logs.tide_high_water')}`}
fallback="00:00" fallback="00:00"
/> />
</div> </div>
<div className="input-group"> <div className="input-group">
<label>{t('logs.tide_low_water')}</label> <label>{t('logs.tide_low_water')}</label>
<EventTimeInput24h <EventTimeInput24h
value={tideLowWater} value={tideData.lowWater}
onChange={setTideLowWater} onChange={(val) => {
const nextTidesMap = {
...tidesMap,
[role]: { ...tideData, lowWater: val }
}
setTidesMap(nextTidesMap)
void persistEntryToDb({ tidesOverride: nextTidesMap })
}}
disabled={readOnly || saving || tidesLoading} disabled={readOnly || saving || tidesLoading}
aria-label={t('logs.tide_low_water')} aria-label={`${roleTitle} - ${t('logs.tide_low_water')}`}
fallback="00:00" fallback="00:00"
/> />
</div> </div>
</div> </div>
</div>
)
})}
{!readOnly && ( {!readOnly && (
<div className="tides-panel__actions"> <div className="tides-panel__actions">
<button <button
@@ -3164,6 +3236,20 @@ export default function LogEntryEditor({
}} }}
/> />
) : null} ) : null}
{tideLocationPickerOptions ? (
<TideLocationPickerModal
title={t('logs.tide_location_picker_title')}
hint={t('logs.tide_location_picker_hint')}
cancelLabel={t('logs.live_cancel')}
options={tideLocationPickerOptions}
onCancel={() => setTideLocationPickerOptions(null)}
onSelect={async (option) => {
setTideLocationPickerOptions(null)
await startTideFetchForLocation(option.fetchLocation)
}}
/>
) : null}
</div> </div>
) )
} }
@@ -0,0 +1,59 @@
import { useTranslation } from 'react-i18next'
import type { TideLocationOption } from '../utils/tideLocation.js'
type TideLocationPickerModalProps = {
title: string
hint: string
cancelLabel: string
options: TideLocationOption[]
onSelect: (option: TideLocationOption) => void
onCancel: () => void
}
export function TideLocationPickerModal({
title,
hint,
cancelLabel,
options,
onSelect,
onCancel
}: TideLocationPickerModalProps) {
const { t } = useTranslation()
return (
<div
className="live-log-modal-backdrop"
onClick={(e) => {
if (e.target === e.currentTarget) onCancel()
}}
>
<div className="live-log-modal tide-station-picker" onClick={(e) => e.stopPropagation()}>
<h3>{title}</h3>
<p className="live-log-modal-hint" role="note">
{hint}
</p>
<ul className="tide-station-picker__list">
{options.map((option) => (
<li key={option.role}>
<button
type="button"
className="tide-station-picker__option"
onClick={() => onSelect(option)}
>
<span className="tide-station-picker__name">{option.displayLabel}</span>
<span className="tide-station-picker__meta">
{t(option.labelKey)}
</span>
</button>
</li>
))}
</ul>
<div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={onCancel}>
{cancelLabel}
</button>
</div>
</div>
</div>
)
}
+7 -1
View File
@@ -558,7 +558,13 @@
"revoke": "Fjern", "revoke": "Fjern",
"revoke_confirm": "Er du sikker på, at du vil fjerne dette besætningsmedlems adgang?", "revoke_confirm": "Er du sikker på, at du vil fjerne dette besætningsmedlems adgang?",
"invite_role": "Rolle", "invite_role": "Rolle",
"invite_expires": "Linket er gyldigt i 48 timer" "invite_expires": "Linket er gyldigt i 48 timer",
"tide_fetched_from_destination": "Tides based on destination “{{place}}”.",
"tide_role_departure": "Departure Port",
"tide_role_destination": "Destination Port",
"tide_role_gps": "GPS Position",
"tide_location_picker_title": "Select Tide Position",
"tide_location_picker_hint": "Select the position to fetch tides for:"
}, },
"dashboard": { "dashboard": {
"title": "Dine logbøger", "title": "Dine logbøger",
+6
View File
@@ -211,6 +211,12 @@
"tide_data_for_place_and_position": "Abfrage für {{place}} ({{lat}}, {{lng}})", "tide_data_for_place_and_position": "Abfrage für {{place}} ({{lat}}, {{lng}})",
"tide_fetched_from": "Daten von {{place}} (ca. {{distance}} km entfernt)", "tide_fetched_from": "Daten von {{place}} (ca. {{distance}} km entfernt)",
"tide_fetched_from_departure": "Gezeiten basierend auf Abfahrtsort „{{place}}“ (keine aktuelle GPS-Position).", "tide_fetched_from_departure": "Gezeiten basierend auf Abfahrtsort „{{place}}“ (keine aktuelle GPS-Position).",
"tide_fetched_from_destination": "Gezeiten basierend auf Zielort „{{place}}“.",
"tide_role_departure": "Abfahrthafen",
"tide_role_destination": "Ankunftshafen",
"tide_role_gps": "GPS-Position",
"tide_location_picker_title": "Gezeiten-Position auswählen",
"tide_location_picker_hint": "Wähle die Position aus, für die die Gezeiten ermittelt werden sollen:",
"tide_applied_success": "Gezeiten übernommen: Hochwasser {{highWater}}, Niedrigwasser {{lowWater}}. Im Reisetag-Editor unter „Tiden“ sichtbar.", "tide_applied_success": "Gezeiten übernommen: Hochwasser {{highWater}}, Niedrigwasser {{lowWater}}. Im Reisetag-Editor unter „Tiden“ sichtbar.",
"tide_apply": "Übernehmen", "tide_apply": "Übernehmen",
"tanks": "Tanks", "tanks": "Tanks",
+6
View File
@@ -211,6 +211,12 @@
"tide_data_for_place_and_position": "Query for {{place}} ({{lat}}, {{lng}})", "tide_data_for_place_and_position": "Query for {{place}} ({{lat}}, {{lng}})",
"tide_fetched_from": "Data from {{place}} (about {{distance}} km away)", "tide_fetched_from": "Data from {{place}} (about {{distance}} km away)",
"tide_fetched_from_departure": "Tides based on departure “{{place}}” (no current GPS position).", "tide_fetched_from_departure": "Tides based on departure “{{place}}” (no current GPS position).",
"tide_fetched_from_destination": "Tides based on destination “{{place}}”.",
"tide_role_departure": "Departure Port",
"tide_role_destination": "Destination Port",
"tide_role_gps": "GPS Position",
"tide_location_picker_title": "Select Tide Position",
"tide_location_picker_hint": "Select the position to fetch tides for:",
"tide_applied_success": "Tides applied: high water {{highWater}}, low water {{lowWater}}. Visible in the travel day editor under “Tides”.", "tide_applied_success": "Tides applied: high water {{highWater}}, low water {{lowWater}}. Visible in the travel day editor under “Tides”.",
"tide_apply": "Apply", "tide_apply": "Apply",
"tanks": "Tanks", "tanks": "Tanks",
+7 -1
View File
@@ -558,7 +558,13 @@
"revoke": "Eliminar", "revoke": "Eliminar",
"revoke_confirm": "¿Estás seguro de que quieres revocar el acceso a este miembro del equipo?", "revoke_confirm": "¿Estás seguro de que quieres revocar el acceso a este miembro del equipo?",
"invite_role": "Papel", "invite_role": "Papel",
"invite_expires": "El enlace es válido durante 48 horas" "invite_expires": "El enlace es válido durante 48 horas",
"tide_fetched_from_destination": "Tides based on destination “{{place}}”.",
"tide_role_departure": "Departure Port",
"tide_role_destination": "Destination Port",
"tide_role_gps": "GPS Position",
"tide_location_picker_title": "Select Tide Position",
"tide_location_picker_hint": "Select the position to fetch tides for:"
}, },
"dashboard": { "dashboard": {
"title": "Tus diarios de a bordo", "title": "Tus diarios de a bordo",
+7 -1
View File
@@ -558,7 +558,13 @@
"revoke": "Supprimer", "revoke": "Supprimer",
"revoke_confirm": "Es-tu sûr de vouloir retirer l'accès à ce membre de l'équipe ?", "revoke_confirm": "Es-tu sûr de vouloir retirer l'accès à ce membre de l'équipe ?",
"invite_role": "rôle", "invite_role": "rôle",
"invite_expires": "Le lien est valable pendant 48 heures" "invite_expires": "Le lien est valable pendant 48 heures",
"tide_fetched_from_destination": "Tides based on destination “{{place}}”.",
"tide_role_departure": "Departure Port",
"tide_role_destination": "Destination Port",
"tide_role_gps": "GPS Position",
"tide_location_picker_title": "Select Tide Position",
"tide_location_picker_hint": "Select the position to fetch tides for:"
}, },
"dashboard": { "dashboard": {
"title": "Tes carnets de bord", "title": "Tes carnets de bord",
+7 -1
View File
@@ -558,7 +558,13 @@
"revoke": "Fjern", "revoke": "Fjern",
"revoke_confirm": "Er du sikker på at du vil fjerne tilgangen til dette besetningsmedlemmet?", "revoke_confirm": "Er du sikker på at du vil fjerne tilgangen til dette besetningsmedlemmet?",
"invite_role": "Rolle", "invite_role": "Rolle",
"invite_expires": "Koblingen er gyldig i 48 timer" "invite_expires": "Koblingen er gyldig i 48 timer",
"tide_fetched_from_destination": "Tides based on destination “{{place}}”.",
"tide_role_departure": "Departure Port",
"tide_role_destination": "Destination Port",
"tide_role_gps": "GPS Position",
"tide_location_picker_title": "Select Tide Position",
"tide_location_picker_hint": "Select the position to fetch tides for:"
}, },
"dashboard": { "dashboard": {
"title": "Loggbøkene dine", "title": "Loggbøkene dine",
+7 -1
View File
@@ -558,7 +558,13 @@
"revoke": "Ta bort", "revoke": "Ta bort",
"revoke_confirm": "Är du säker på att du vill ta bort åtkomsten för den här medarbetaren?", "revoke_confirm": "Är du säker på att du vill ta bort åtkomsten för den här medarbetaren?",
"invite_role": "Roll", "invite_role": "Roll",
"invite_expires": "Länken är giltig i 48 timmar" "invite_expires": "Länken är giltig i 48 timmar",
"tide_fetched_from_destination": "Tides based on destination “{{place}}”.",
"tide_role_departure": "Departure Port",
"tide_role_destination": "Destination Port",
"tide_role_gps": "GPS Position",
"tide_location_picker_title": "Select Tide Position",
"tide_location_picker_hint": "Select the position to fetch tides for:"
}, },
"dashboard": { "dashboard": {
"title": "Dina loggböcker", "title": "Dina loggböcker",
+14 -5
View File
@@ -7,12 +7,14 @@ import { putEntryRecord } from '../utils/entryListCache.js'
import { import {
buildLogEntryPayload, buildLogEntryPayload,
normalizeLogEvent, normalizeLogEvent,
readLogEntryTides, readLogEntryTidesMap,
sortLogEventsByTime, sortLogEventsByTime,
currentLocalTimeHHMM, currentLocalTimeHHMM,
localDateString, localDateString,
type LogEntryTides, type LogEntryTides,
type LogEventPayload type LogEntryTidesMap,
type LogEventPayload,
type TideRole
} from '../utils/logEntryPayload.js' } from '../utils/logEntryPayload.js'
import { import {
carryOverFromPreviousDay, carryOverFromPreviousDay,
@@ -77,7 +79,7 @@ function buildEncryptedPayload(
destination?: string destination?: string
freshwater?: { morning: number; refilled: number; evening: number; consumption: number } freshwater?: { morning: number; refilled: number; evening: number; consumption: number }
fuel?: { morning: number; refilled: number; evening: number; consumption: number } fuel?: { morning: number; refilled: number; evening: number; consumption: number }
tides?: LogEntryTides tides?: LogEntryTidesMap
clearSignatures?: boolean clearSignatures?: boolean
} }
): Record<string, unknown> { ): Record<string, unknown> {
@@ -116,7 +118,7 @@ function buildEncryptedPayload(
freshwater, freshwater,
fuel: fuelLevels, fuel: fuelLevels,
greywater: gw ? { level: gw.level || 0 } : undefined, greywater: gw ? { level: gw.level || 0 } : undefined,
tides: options.tides ?? readLogEntryTides(data), tides: options.tides ?? readLogEntryTidesMap(data),
trackDistanceNm: trackDistanceNm:
trackDistance != null && trackDistance !== '' trackDistance != null && trackDistance !== ''
? parseFloat(String(trackDistance)) ? parseFloat(String(trackDistance))
@@ -405,6 +407,7 @@ export async function appendQuickEvents(
export async function patchEntryTides( export async function patchEntryTides(
logbookId: string, logbookId: string,
entryId: string, entryId: string,
role: TideRole,
tides: LogEntryTides tides: LogEntryTides
): Promise<void> { ): Promise<void> {
const loaded = await loadEntry(logbookId, entryId) const loaded = await loadEntry(logbookId, entryId)
@@ -413,9 +416,15 @@ export async function patchEntryTides(
const hadSignature = !!(loaded.data.signSkipper || loaded.data.signCrew) const hadSignature = !!(loaded.data.signSkipper || loaded.data.signCrew)
const currentEvents = (loaded.data.events as LogEventPayload[]) || [] const currentEvents = (loaded.data.events as LogEventPayload[]) || []
const currentTidesMap = readLogEntryTidesMap(loaded.data)
const nextTidesMap = {
...currentTidesMap,
[role]: tides
}
await persistEntry(logbookId, entryId, loaded.data, { await persistEntry(logbookId, entryId, loaded.data, {
events: currentEvents, events: currentEvents,
tides, tides: nextTidesMap,
clearSignatures: hadSignature clearSignatures: hadSignature
}) })
} }
+86 -10
View File
@@ -6,6 +6,7 @@ import {
localDateString, localDateString,
normalizeLogEvent, normalizeLogEvent,
splitTimeHHMM, splitTimeHHMM,
readLogEntryTidesMap,
type LogEventPayload type LogEventPayload
} from './logEntryPayload.js' } from './logEntryPayload.js'
@@ -74,7 +75,7 @@ describe('buildLogEntryPayload greywater', () => {
}) })
}) })
describe('buildLogEntryPayload tides', () => { describe('buildLogEntryPayload tides map', () => {
const base = { const base = {
date: '2026-06-11', date: '2026-06-11',
dayOfTravel: '1', dayOfTravel: '1',
@@ -85,18 +86,35 @@ describe('buildLogEntryPayload tides', () => {
events: [] as LogEventPayload[] events: [] as LogEventPayload[]
} }
it('persists high and low water times', () => { it('persists multiple tide roles (departure and destination)', () => {
const payload = buildLogEntryPayload({ const payload = buildLogEntryPayload({
...base, ...base,
tides: { highWater: '18:34', lowWater: '12:05' } tides: {
departure: { highWater: '18:34', lowWater: '12:05' },
destination: { highWater: '19:00', lowWater: '12:30' }
}
})
expect(payload.tides).toEqual({
departure: { highWater: '18:34', lowWater: '12:05' },
destination: { highWater: '19:00', lowWater: '12:30' }
}) })
expect(payload.tides).toEqual({ highWater: '18:34', lowWater: '12:05' })
}) })
it('persists tide location metadata', () => { it('persists tide location metadata', () => {
const payload = buildLogEntryPayload({ const payload = buildLogEntryPayload({
...base, ...base,
tides: { tides: {
gps: {
highWater: '06:00',
lowWater: '00:04',
locationSource: 'gps',
lat: '53.624526',
lng: '7.155263'
}
}
})
expect(payload.tides).toEqual({
gps: {
highWater: '06:00', highWater: '06:00',
lowWater: '00:04', lowWater: '00:04',
locationSource: 'gps', locationSource: 'gps',
@@ -104,14 +122,72 @@ describe('buildLogEntryPayload tides', () => {
lng: '7.155263' lng: '7.155263'
} }
}) })
expect(payload.tides).toEqual({
highWater: '06:00',
lowWater: '00:04',
locationSource: 'gps',
lat: '53.624526',
lng: '7.155263'
}) })
}) })
describe('readLogEntryTidesMap backward compatibility', () => {
it('reads old flat schema as departure role', () => {
const oldData = {
tides: {
highWater: '12:30',
lowWater: '06:15',
locationSource: 'departure',
placeName: 'Kiel'
}
}
const map = readLogEntryTidesMap(oldData)
expect(map.departure).toEqual({
highWater: '12:30',
lowWater: '06:15',
locationSource: 'departure',
placeName: 'Kiel'
})
expect(map.gps).toBeUndefined()
expect(map.destination).toBeUndefined()
})
it('reads old flat schema with gps locationSource as gps role', () => {
const oldData = {
tides: {
highWater: '12:30',
lowWater: '06:15',
locationSource: 'gps',
lat: '54.3',
lng: '10.1'
}
}
const map = readLogEntryTidesMap(oldData)
expect(map.gps).toEqual({
highWater: '12:30',
lowWater: '06:15',
locationSource: 'gps',
lat: '54.3',
lng: '10.1'
})
expect(map.departure).toBeUndefined()
expect(map.destination).toBeUndefined()
})
it('reads new nested schema correctly', () => {
const newData = {
tides: {
departure: { highWater: '12:00', lowWater: '06:00', placeName: 'Kiel' },
gps: { highWater: '13:00', lowWater: '07:00', lat: '54.3' }
}
}
const map = readLogEntryTidesMap(newData)
expect(map.departure).toEqual({
highWater: '12:00',
lowWater: '06:00',
placeName: 'Kiel'
})
expect(map.gps).toEqual({
highWater: '13:00',
lowWater: '07:00',
lat: '54.3'
})
expect(map.destination).toBeUndefined()
})
}) })
describe('splitTimeHHMM', () => { describe('splitTimeHHMM', () => {
+60 -17
View File
@@ -150,7 +150,9 @@ export function sortLogEventsByTime<T extends LogEventPayload>(events: T[]): T[]
return [...events].sort((a, b) => (a.time || '').localeCompare(b.time || '')) return [...events].sort((a, b) => (a.time || '').localeCompare(b.time || ''))
} }
export type TideLocationSource = 'gps' | 'departure' | 'geocoded' export type TideRole = 'departure' | 'destination' | 'gps'
export type TideLocationSource = 'gps' | 'departure' | 'geocoded' | 'destination'
export interface LogEntryTides { export interface LogEntryTides {
highWater: string highWater: string
@@ -163,6 +165,8 @@ export interface LogEntryTides {
tideFallback?: 'open_meteo' tideFallback?: 'open_meteo'
} }
export type LogEntryTidesMap = Partial<Record<TideRole, LogEntryTides>>
export interface LogEntryPayloadInput { export interface LogEntryPayloadInput {
date: string date: string
dayOfTravel: string dayOfTravel: string
@@ -171,7 +175,7 @@ export interface LogEntryPayloadInput {
freshwater: { morning: number; refilled: number; evening: number; consumption: number } freshwater: { morning: number; refilled: number; evening: number; consumption: number }
fuel: { morning: number; refilled: number; evening: number; consumption: number } fuel: { morning: number; refilled: number; evening: number; consumption: number }
greywater?: { level: number } greywater?: { level: number }
tides?: LogEntryTides tides?: LogEntryTidesMap
trackDistanceNm?: number trackDistanceNm?: number
trackSpeedMaxKn?: number trackSpeedMaxKn?: number
trackSpeedAvgKn?: number trackSpeedAvgKn?: number
@@ -182,7 +186,7 @@ export interface LogEntryPayloadInput {
function readTideLocationSource(value: unknown): TideLocationSource | undefined { function readTideLocationSource(value: unknown): TideLocationSource | undefined {
const source = String(value ?? '').trim() const source = String(value ?? '').trim()
if (source === 'gps' || source === 'departure' || source === 'geocoded') return source if (source === 'gps' || source === 'departure' || source === 'geocoded' || source === 'destination') return source
return undefined return undefined
} }
@@ -209,6 +213,35 @@ export function readLogEntryTides(data: Record<string, unknown>): LogEntryTides
} }
} }
export function readLogEntryTidesMap(data: Record<string, unknown>): LogEntryTidesMap {
const tidesRaw = data.tides as Record<string, unknown> | undefined
if (!tidesRaw) return {}
// Check if it's the old schema (flat object with highWater/lowWater)
const isOldSchema = ('highWater' in tidesRaw || 'lowWater' in tidesRaw)
if (isOldSchema) {
const parsedOld = readLogEntryTides({ tides: tidesRaw })
let role: TideRole = 'departure'
if (parsedOld.locationSource === 'gps') {
role = 'gps'
} else if (parsedOld.locationSource === 'destination') {
role = 'destination'
}
return { [role]: parsedOld }
}
// Otherwise, it's the new schema mapping roles to tide values
const map: LogEntryTidesMap = {}
const roles: TideRole[] = ['departure', 'destination', 'gps']
for (const role of roles) {
if (tidesRaw[role] && typeof tidesRaw[role] === 'object') {
map[role] = readLogEntryTides({ tides: tidesRaw[role] })
}
}
return map
}
export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string, unknown> { export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string, unknown> {
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
date: input.date, date: input.date,
@@ -235,21 +268,31 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string
} }
if (input.tides) { if (input.tides) {
const highWater = parseTimeToHHMM(input.tides.highWater) ?? '' const serializedMap: Record<string, unknown> = {}
const lowWater = parseTimeToHHMM(input.tides.lowWater) ?? '' const roles: TideRole[] = ['departure', 'destination', 'gps']
for (const role of roles) {
const tideData = input.tides[role]
if (tideData) {
const highWater = parseTimeToHHMM(tideData.highWater) ?? ''
const lowWater = parseTimeToHHMM(tideData.lowWater) ?? ''
if (highWater || lowWater) { if (highWater || lowWater) {
const tides: Record<string, string> = { highWater, lowWater } const tidesObj: Record<string, string> = { highWater, lowWater }
if (input.tides.locationSource) tides.locationSource = input.tides.locationSource if (tideData.locationSource) tidesObj.locationSource = tideData.locationSource
const placeName = input.tides.placeName?.trim() const placeName = tideData.placeName?.trim()
if (placeName) tides.placeName = placeName if (placeName) tidesObj.placeName = placeName
const lat = input.tides.lat?.trim() const lat = tideData.lat?.trim()
if (lat) tides.lat = lat if (lat) tidesObj.lat = lat
const lng = input.tides.lng?.trim() const lng = tideData.lng?.trim()
if (lng) tides.lng = lng if (lng) tidesObj.lng = lng
const distanceKm = input.tides.distanceKm?.trim() const distanceKm = tideData.distanceKm?.trim()
if (distanceKm) tides.distanceKm = distanceKm if (distanceKm) tidesObj.distanceKm = distanceKm
if (input.tides.tideFallback === 'open_meteo') tides.tideFallback = 'open_meteo' if (tideData.tideFallback === 'open_meteo') tidesObj.tideFallback = 'open_meteo'
payload.tides = tides serializedMap[role] = tidesObj
}
}
}
if (Object.keys(serializedMap).length > 0) {
payload.tides = serializedMap
} }
} }
+62 -2
View File
@@ -3,7 +3,8 @@ import { LIVE_EVENT_CODES } from './liveEventCodes.js'
import { import {
buildTideLocationMeta, buildTideLocationMeta,
formatTideLocationLabel, formatTideLocationLabel,
resolveTideFetchLocation resolveTideFetchLocation,
getAvailableTideLocations
} from './tideLocation.js' } from './tideLocation.js'
const entryDate = '2026-06-11' const entryDate = '2026-06-11'
@@ -126,7 +127,7 @@ describe('resolveTideFetchLocation', () => {
}) })
it('formats coordinate and place labels', () => { it('formats coordinate and place labels', () => {
const t = (key: string, options?: Record<string, string>) => const t = (key: string, options?: Record<string, string | undefined>) =>
`${key}:${JSON.stringify(options ?? {})}` `${key}:${JSON.stringify(options ?? {})}`
expect( expect(
formatTideLocationLabel( formatTideLocationLabel(
@@ -175,3 +176,62 @@ describe('resolveTideFetchLocation', () => {
expect(result).toEqual({ error: 'missing' }) expect(result).toEqual({ error: 'missing' })
}) })
}) })
describe('getAvailableTideLocations', () => {
it('returns empty list when no locations are available', () => {
const list = getAvailableTideLocations({
departure: '',
destination: '',
events: [],
entryDate
})
expect(list).toEqual([])
})
it('returns departure and destination when they are non-empty', () => {
const list = getAvailableTideLocations({
departure: 'Büsum',
destination: 'Helgoland',
events: [],
entryDate
})
expect(list).toHaveLength(2)
expect(list[0]).toEqual({
role: 'departure',
labelKey: 'logs.tide_role_departure',
displayLabel: 'Büsum',
fetchLocation: { mode: 'by-place', query: 'Büsum', source: 'departure' }
})
expect(list[1]).toEqual({
role: 'destination',
labelKey: 'logs.tide_role_destination',
displayLabel: 'Helgoland',
fetchLocation: { mode: 'by-place', query: 'Helgoland', source: 'destination' }
})
})
it('returns gps when fresh position is present in events', () => {
const list = getAvailableTideLocations({
departure: 'Büsum',
destination: '',
events: [
{
time: '11:30',
remarks: LIVE_EVENT_CODES.POSITION,
gpsLat: '54.1',
gpsLng: '8.8'
}
],
entryDate,
nowMs
})
expect(list).toHaveLength(2)
expect(list[0].role).toBe('departure')
expect(list[1]).toEqual({
role: 'gps',
labelKey: 'logs.tide_role_gps',
displayLabel: '54.1, 8.8',
fetchLocation: { mode: 'nearby', lat: '54.1', lng: '8.8', source: 'gps' }
})
})
})
+57 -2
View File
@@ -3,7 +3,7 @@ import {
getLatestLoggedPosition, getLatestLoggedPosition,
LIVE_LOG_TIDE_POSITION_MAX_AGE_MS LIVE_LOG_TIDE_POSITION_MAX_AGE_MS
} from './liveEventCodes.js' } from './liveEventCodes.js'
import type { LogEntryTides, LogEventPayload, TideLocationSource } from './logEntryPayload.js' import type { LogEntryTides, LogEventPayload, TideLocationSource, TideRole } from './logEntryPayload.js'
export type { TideLocationSource } export type { TideLocationSource }
@@ -14,7 +14,14 @@ export type TideLocationMeta = Pick<
export type TideFetchLocation = export type TideFetchLocation =
| { mode: 'nearby'; lat: string; lng: string; source: 'gps' } | { mode: 'nearby'; lat: string; lng: string; source: 'gps' }
| { mode: 'by-place'; query: string; source: 'departure' } | { mode: 'by-place'; query: string; source: 'departure' | 'destination' }
export interface TideLocationOption {
role: TideRole
labelKey: string
displayLabel: string
fetchLocation: TideFetchLocation
}
export type TideLocationError = 'stale' | 'missing' export type TideLocationError = 'stale' | 'missing'
@@ -133,6 +140,9 @@ export function formatTideLocationLabel(
if (tides.locationSource === 'departure') { if (tides.locationSource === 'departure') {
return t('logs.tide_fetched_from_departure', { place: placeName }) return t('logs.tide_fetched_from_departure', { place: placeName })
} }
if (tides.locationSource === 'destination') {
return t('logs.tide_fetched_from_destination', { place: placeName })
}
return t('logs.tide_data_for_place', { place: placeName }) return t('logs.tide_data_for_place', { place: placeName })
} }
return '' return ''
@@ -148,3 +158,48 @@ export function pickTideLocationMeta(tides: LogEntryTides): TideLocationMeta {
tideFallback: tides.tideFallback tideFallback: tides.tideFallback
} }
} }
export function getAvailableTideLocations(options: {
departure: string
destination: string
events: Array<Pick<LogEventPayload, 'remarks' | 'time' | 'gpsLat' | 'gpsLng'>>
entryDate: string
maxAgeMs?: number
nowMs?: number
}): TideLocationOption[] {
const optionsList: TideLocationOption[] = []
const departure = options.departure.trim()
if (departure) {
optionsList.push({
role: 'departure',
labelKey: 'logs.tide_role_departure',
displayLabel: departure,
fetchLocation: { mode: 'by-place', query: departure, source: 'departure' }
})
}
const destination = options.destination.trim()
if (destination) {
optionsList.push({
role: 'destination',
labelKey: 'logs.tide_role_destination',
displayLabel: destination,
fetchLocation: { mode: 'by-place', query: destination, source: 'destination' }
})
}
const maxAgeMs = options.maxAgeMs ?? LIVE_LOG_TIDE_POSITION_MAX_AGE_MS
const nowMs = options.nowMs ?? Date.now()
const freshGps = getLastLoggedPositionWithin(options.events, options.entryDate, maxAgeMs, nowMs)
if (freshGps) {
optionsList.push({
role: 'gps',
labelKey: 'logs.tide_role_gps',
displayLabel: `${freshGps.lat}, ${freshGps.lng}`,
fetchLocation: { mode: 'nearby', lat: freshGps.lat, lng: freshGps.lng, source: 'gps' }
})
}
return optionsList
}