diff --git a/client/src/components/LiveLogView.tsx b/client/src/components/LiveLogView.tsx index 6f754a3..de234c6 100644 --- a/client/src/components/LiveLogView.tsx +++ b/client/src/components/LiveLogView.tsx @@ -60,11 +60,15 @@ import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js' import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js' import { TidesApiError, type TideStation } from '../services/tides.js' import { TideStationPickerModal } from './TideStationPickerModal.tsx' +import { TideLocationPickerModal } from './TideLocationPickerModal.tsx' import { buildTideLocationMeta, formatTideLocationLabel, - resolveTideFetchLocation + getAvailableTideLocations, + type TideLocationOption, + type TideFetchLocation } from '../utils/tideLocation.js' +import type { TideRole } from '../utils/logEntryPayload.js' import { fetchTidesForEntry, fetchTidesForStationChoice, @@ -124,6 +128,7 @@ type LiveModal = | 'stw' | 'position' | 'tides' + | 'tides_picker' | 'photo' | 'voice' @@ -207,6 +212,7 @@ export default function LiveLogView({ const [dayOfTravel, setDayOfTravel] = useState('') const [date, setDate] = useState('') const [departure, setDeparture] = useState('') + const [destination, setDestination] = useState('') const [events, setEvents] = useState([]) const [crewSnapshotsById, setCrewSnapshotsById] = useState>({}) const [selectedSkipperId, setSelectedSkipperId] = useState(null) @@ -222,8 +228,10 @@ export default function LiveLogView({ highWater: string lowWater: string location: ReturnType + role: TideRole } | null>(null) const [tideStationPicker, setTideStationPicker] = useState(null) + const [tideLocationPickerOptions, setTideLocationPickerOptions] = useState(null) const [isOnline, setIsOnline] = useState(navigator.onLine) const [commentText, setCommentText] = useState('') const [valueInput, setValueInput] = useState('') @@ -326,6 +334,7 @@ export default function LiveLogView({ setDayOfTravel(String(loaded.data.dayOfTravel || '')) setDate(String(loaded.data.date || '')) setDeparture(String(loaded.data.departure || '')) + setDestination(String(loaded.data.destination || '')) setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e })))) setCrewSnapshotsById((loaded.data.crewSnapshotsById as Record) || {}) 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) => { setTidesLoading(true) void (async () => { @@ -825,7 +840,8 @@ export default function LiveLogView({ setTidePreview({ highWater: result.highWater, lowWater: result.lowWater, - location: result.location + location: result.location, + role: getRoleForLocationSource(pick.fetchLocation.source) }) setModal('tides') } catch (err) { @@ -841,34 +857,13 @@ export default function LiveLogView({ })() } - const handleFetchTides = () => { - if (!entryId || busy || tidesLoading) return - if (!isOnline) { - void showAlert(t('logs.weather_offline'), t('logs.tides')) - return - } - + const startTideFetchForLocation = (fetchLocation: TideFetchLocation) => { setTidesLoading(true) setError(null) void (async () => { 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({ - fetchLocation: location, + fetchLocation, entryDate: date, analyticsSource: 'live_log' }) @@ -882,7 +877,8 @@ export default function LiveLogView({ setTidePreview({ highWater: result.highWater, lowWater: result.lowWater, - location: result.location + location: result.location, + role: getRoleForLocationSource(fetchLocation.source) }) setModal('tides') } catch (err) { @@ -892,7 +888,8 @@ export default function LiveLogView({ return } 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 } 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 = () => { if (!entryId || !tidePreview || busy) return const preview = tidePreview void runQuickAction(async () => { - await patchEntryTides(logbookId, entryId, { + await patchEntryTides(logbookId, entryId, preview.role, { highWater: preview.highWater, lowWater: preview.lowWater, ...preview.location @@ -1619,6 +1643,24 @@ export default function LiveLogView({ /> ) : null} + {modal === 'tides_picker' && tideLocationPickerOptions ? ( + { + setTideLocationPickerOptions(null) + closeModal() + }} + onSelect={(option) => { + setTideLocationPickerOptions(null) + closeModal() + startTideFetchForLocation(option.fetchLocation) + }} + /> + ) : null} + {modal === 'tides' && tidePreview && (
): string motorHoursRaw != null && motorHoursRaw !== '' ? (parseAppDecimal(String(motorHoursRaw)) ?? undefined) : undefined, - tides: readLogEntryTides(decrypted), + tides: readLogEntryTidesMap(decrypted), events: (decrypted.events as LogEventPayload[]) || [], entryCrew: entryCrewFromPreviousEntry(decrypted as Record) }) @@ -314,9 +327,8 @@ export default function LogEntryEditor({ const [eventsCollapsed, setEventsCollapsed] = useState(true) const [addEventFormCollapsed, setAddEventFormCollapsed] = useState(false) const [tidesCollapsed, setTidesCollapsed] = useState(true) - const [tideHighWater, setTideHighWater] = useState('') - const [tideLowWater, setTideLowWater] = useState('') - const [tideLocation, setTideLocation] = useState({}) + const [tidesMap, setTidesMap] = useState({}) + const [tideLocationPickerOptions, setTideLocationPickerOptions] = useState(null) const [tidesLoading, setTidesLoading] = useState(false) const [tideStationPicker, setTideStationPicker] = useState(null) 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({ date, dayOfTravel, @@ -451,7 +463,7 @@ export default function LogEntryEditor({ consumption: parseAppDecimalOrZero(fuelConsumption) }, greywater: { level: parseAppDecimalOrZero(greywaterLevel) }, - tides: tidesOverride ?? { highWater: tideHighWater, lowWater: tideLowWater, ...tideLocation }, + tides: tidesOverride ?? tidesMap, trackDistanceNm: parseOptionalFormDecimal(trackDistanceNm), trackSpeedMaxKn: parseOptionalFormDecimal(trackSpeedMaxKn), trackSpeedAvgKn: parseOptionalFormDecimal(trackSpeedAvgKn), @@ -464,7 +476,7 @@ export default function LogEntryEditor({ fwMorning, fwRefilled, fwEvening, fwConsumption, fuelMorning, fuelRefilled, fuelEvening, fuelConsumption, greywaterLevel, - tideHighWater, tideLowWater, tideLocation, + tidesMap, trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours, events, entryCrew @@ -515,9 +527,13 @@ export default function LogEntryEditor({ [fuelMorning, fuelRefilled, tankCapacities.fuelCapacityL] ) - const tideLocationLabel = useMemo( - () => formatTideLocationLabel(tideLocation, t), - [tideLocation, t] + const getTideLocationLabel = useCallback( + (role: TideRole) => { + const tideData = tidesMap[role] + if (!tideData) return '' + return formatTideLocationLabel(tideData, t) + }, + [tidesMap, t] ) const currentFingerprint = useMemo(() => { @@ -603,7 +619,7 @@ export default function LogEntryEditor({ signCrew?: SignatureValue | '' aiSummary?: string aiSummaryGeneratedAt?: string - tidesOverride?: LogEntryTides + tidesOverride?: LogEntryTidesMap } ) => { if (readOnly) return @@ -951,10 +967,8 @@ export default function LogEntryEditor({ setGreywaterLevel('0') } - const preloadedTides = readLogEntryTides(preloadedEntry as Record) - setTideHighWater(preloadedTides.highWater) - setTideLowWater(preloadedTides.lowWater) - setTideLocation(pickTideLocationMeta(preloadedTides)) + const preloadedTides = readLogEntryTidesMap(preloadedEntry as Record) + setTidesMap(preloadedTides) setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '') setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '') @@ -997,10 +1011,8 @@ export default function LogEntryEditor({ setGreywaterLevel('0') } - const loadedTides = readLogEntryTides(decrypted as Record) - setTideHighWater(loadedTides.highWater) - setTideLowWater(loadedTides.lowWater) - setTideLocation(pickTideLocationMeta(loadedTides)) + const loadedTides = readLogEntryTidesMap(decrypted as Record) + setTidesMap(loadedTides) setSignSkipper(normalizeSignature(decrypted.signSkipper) || '') 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 lowWater: string location: TideLocationMeta @@ -1321,12 +1339,14 @@ export default function LogEntryEditor({ lowWater: result.lowWater, ...result.location } - setTideHighWater(result.highWater) - setTideLowWater(result.lowWater) - setTideLocation(result.location) + const nextTidesMap = { + ...tidesMap, + [role]: nextTides + } + setTidesMap(nextTidesMap) try { - await persistEntryToDb({ tidesOverride: nextTides }) + await persistEntryToDb({ tidesOverride: nextTidesMap }) trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED) } catch (err) { console.error('Failed to auto-save after tide fetch:', err) @@ -1345,7 +1365,8 @@ export default function LogEntryEditor({ queryLng: pick.queryLng, analyticsSource: 'entry_editor' }) - await applyTideFetchResult(result) + const role = getRoleForLocationSource(pick.fetchLocation.source) + await applyTideFetchResult(role, result) setTideStationPicker(null) } catch (err) { if (err instanceof TidesApiError && err.code === 'NO_DATA_FOR_DATE') { @@ -1359,30 +1380,11 @@ export default function LogEntryEditor({ } } - const handleFetchTides = async () => { - if (!isOnline) { - showAlert(t('logs.weather_offline'), t('logs.tide_fetch_btn')) - return - } - + const startTideFetchForLocation = async (fetchLocation: TideFetchLocation) => { setTidesLoading(true) 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({ - fetchLocation: location, + fetchLocation, entryDate: date, analyticsSource: 'entry_editor' }) @@ -1392,7 +1394,8 @@ export default function LogEntryEditor({ return } - await applyTideFetchResult(outcome as TideFetchResult) + const role = getRoleForLocationSource(fetchLocation.source) + await applyTideFetchResult(role, outcome as TideFetchResult) } catch (err) { if (err instanceof TidesApiError) { if (err.code === 'OFFLINE') { @@ -1400,7 +1403,8 @@ export default function LogEntryEditor({ return } 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 } 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 () => { if (!canSignSkipper || readOnly || aiSummaryLoading) return if (!getAiAuthorized()) { @@ -2285,38 +2314,81 @@ export default function LogEntryEditor({ {!tidesCollapsed && (
-
+

{t('logs.tide_disclaimer')}

- {tideLocationLabel ? ( -

- {tideLocationLabel} -

- ) : null} -
-
-
- - -
-
- - -
+ + {(['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 ( +
+

+ + {roleTitle} +

+ {label ? ( +

+ {label} +

+ ) : null} +
+
+ + { + const nextTidesMap = { + ...tidesMap, + [role]: { ...tideData, highWater: val } + } + setTidesMap(nextTidesMap) + void persistEntryToDb({ tidesOverride: nextTidesMap }) + }} + disabled={readOnly || saving || tidesLoading} + aria-label={`${roleTitle} - ${t('logs.tide_high_water')}`} + fallback="00:00" + /> +
+
+ + { + const nextTidesMap = { + ...tidesMap, + [role]: { ...tideData, lowWater: val } + } + setTidesMap(nextTidesMap) + void persistEntryToDb({ tidesOverride: nextTidesMap }) + }} + disabled={readOnly || saving || tidesLoading} + aria-label={`${roleTitle} - ${t('logs.tide_low_water')}`} + fallback="00:00" + /> +
+
+
+ ) + })} + {!readOnly && (
) } diff --git a/client/src/components/TideLocationPickerModal.tsx b/client/src/components/TideLocationPickerModal.tsx new file mode 100644 index 0000000..f09acb0 --- /dev/null +++ b/client/src/components/TideLocationPickerModal.tsx @@ -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 ( +
{ + if (e.target === e.currentTarget) onCancel() + }} + > +
e.stopPropagation()}> +

{title}

+

+ {hint} +

+
    + {options.map((option) => ( +
  • + +
  • + ))} +
+
+ +
+
+
+ ) +} diff --git a/client/src/i18n/locales/da.json b/client/src/i18n/locales/da.json index c01d9f7..05498d5 100644 --- a/client/src/i18n/locales/da.json +++ b/client/src/i18n/locales/da.json @@ -558,7 +558,13 @@ "revoke": "Fjern", "revoke_confirm": "Er du sikker på, at du vil fjerne dette besætningsmedlems adgang?", "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": { "title": "Dine logbøger", diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 6ab12af..8f7969e 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -211,6 +211,12 @@ "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_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_apply": "Übernehmen", "tanks": "Tanks", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 9c913b8..dda1d3a 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -211,6 +211,12 @@ "tide_data_for_place_and_position": "Query for {{place}} ({{lat}}, {{lng}})", "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_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_apply": "Apply", "tanks": "Tanks", diff --git a/client/src/i18n/locales/es.json b/client/src/i18n/locales/es.json index 7105269..10549f0 100644 --- a/client/src/i18n/locales/es.json +++ b/client/src/i18n/locales/es.json @@ -558,7 +558,13 @@ "revoke": "Eliminar", "revoke_confirm": "¿Estás seguro de que quieres revocar el acceso a este miembro del equipo?", "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": { "title": "Tus diarios de a bordo", diff --git a/client/src/i18n/locales/fr.json b/client/src/i18n/locales/fr.json index ecc68db..31def7c 100644 --- a/client/src/i18n/locales/fr.json +++ b/client/src/i18n/locales/fr.json @@ -558,7 +558,13 @@ "revoke": "Supprimer", "revoke_confirm": "Es-tu sûr de vouloir retirer l'accès à ce membre de l'équipe ?", "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": { "title": "Tes carnets de bord", diff --git a/client/src/i18n/locales/nb.json b/client/src/i18n/locales/nb.json index 87cc647..3e68f12 100644 --- a/client/src/i18n/locales/nb.json +++ b/client/src/i18n/locales/nb.json @@ -558,7 +558,13 @@ "revoke": "Fjern", "revoke_confirm": "Er du sikker på at du vil fjerne tilgangen til dette besetningsmedlemmet?", "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": { "title": "Loggbøkene dine", diff --git a/client/src/i18n/locales/sv.json b/client/src/i18n/locales/sv.json index 65859ad..06cb0aa 100644 --- a/client/src/i18n/locales/sv.json +++ b/client/src/i18n/locales/sv.json @@ -558,7 +558,13 @@ "revoke": "Ta bort", "revoke_confirm": "Är du säker på att du vill ta bort åtkomsten för den här medarbetaren?", "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": { "title": "Dina loggböcker", diff --git a/client/src/services/quickEventLog.ts b/client/src/services/quickEventLog.ts index b9bb4f0..097c035 100644 --- a/client/src/services/quickEventLog.ts +++ b/client/src/services/quickEventLog.ts @@ -7,12 +7,14 @@ import { putEntryRecord } from '../utils/entryListCache.js' import { buildLogEntryPayload, normalizeLogEvent, - readLogEntryTides, + readLogEntryTidesMap, sortLogEventsByTime, currentLocalTimeHHMM, localDateString, type LogEntryTides, - type LogEventPayload + type LogEntryTidesMap, + type LogEventPayload, + type TideRole } from '../utils/logEntryPayload.js' import { carryOverFromPreviousDay, @@ -77,7 +79,7 @@ function buildEncryptedPayload( destination?: string freshwater?: { morning: number; refilled: number; evening: number; consumption: number } fuel?: { morning: number; refilled: number; evening: number; consumption: number } - tides?: LogEntryTides + tides?: LogEntryTidesMap clearSignatures?: boolean } ): Record { @@ -116,7 +118,7 @@ function buildEncryptedPayload( freshwater, fuel: fuelLevels, greywater: gw ? { level: gw.level || 0 } : undefined, - tides: options.tides ?? readLogEntryTides(data), + tides: options.tides ?? readLogEntryTidesMap(data), trackDistanceNm: trackDistance != null && trackDistance !== '' ? parseFloat(String(trackDistance)) @@ -405,6 +407,7 @@ export async function appendQuickEvents( export async function patchEntryTides( logbookId: string, entryId: string, + role: TideRole, tides: LogEntryTides ): Promise { const loaded = await loadEntry(logbookId, entryId) @@ -413,9 +416,15 @@ export async function patchEntryTides( const hadSignature = !!(loaded.data.signSkipper || loaded.data.signCrew) const currentEvents = (loaded.data.events as LogEventPayload[]) || [] + const currentTidesMap = readLogEntryTidesMap(loaded.data) + const nextTidesMap = { + ...currentTidesMap, + [role]: tides + } + await persistEntry(logbookId, entryId, loaded.data, { events: currentEvents, - tides, + tides: nextTidesMap, clearSignatures: hadSignature }) } diff --git a/client/src/utils/logEntryPayload.test.ts b/client/src/utils/logEntryPayload.test.ts index 262f796..8926f61 100644 --- a/client/src/utils/logEntryPayload.test.ts +++ b/client/src/utils/logEntryPayload.test.ts @@ -6,6 +6,7 @@ import { localDateString, normalizeLogEvent, splitTimeHHMM, + readLogEntryTidesMap, type LogEventPayload } from './logEntryPayload.js' @@ -74,7 +75,7 @@ describe('buildLogEntryPayload greywater', () => { }) }) -describe('buildLogEntryPayload tides', () => { +describe('buildLogEntryPayload tides map', () => { const base = { date: '2026-06-11', dayOfTravel: '1', @@ -85,18 +86,35 @@ describe('buildLogEntryPayload tides', () => { events: [] as LogEventPayload[] } - it('persists high and low water times', () => { + it('persists multiple tide roles (departure and destination)', () => { const payload = buildLogEntryPayload({ ...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', () => { const payload = buildLogEntryPayload({ ...base, tides: { + gps: { + highWater: '06:00', + lowWater: '00:04', + locationSource: 'gps', + lat: '53.624526', + lng: '7.155263' + } + } + }) + expect(payload.tides).toEqual({ + gps: { highWater: '06:00', lowWater: '00:04', locationSource: 'gps', @@ -104,13 +122,71 @@ describe('buildLogEntryPayload tides', () => { 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() }) }) diff --git a/client/src/utils/logEntryPayload.ts b/client/src/utils/logEntryPayload.ts index 3a0b489..c7cc96d 100644 --- a/client/src/utils/logEntryPayload.ts +++ b/client/src/utils/logEntryPayload.ts @@ -150,7 +150,9 @@ export function sortLogEventsByTime(events: T[]): T[] 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 { highWater: string @@ -163,6 +165,8 @@ export interface LogEntryTides { tideFallback?: 'open_meteo' } +export type LogEntryTidesMap = Partial> + export interface LogEntryPayloadInput { date: string dayOfTravel: string @@ -171,7 +175,7 @@ export interface LogEntryPayloadInput { freshwater: { morning: number; refilled: number; evening: number; consumption: number } fuel: { morning: number; refilled: number; evening: number; consumption: number } greywater?: { level: number } - tides?: LogEntryTides + tides?: LogEntryTidesMap trackDistanceNm?: number trackSpeedMaxKn?: number trackSpeedAvgKn?: number @@ -182,7 +186,7 @@ export interface LogEntryPayloadInput { function readTideLocationSource(value: unknown): TideLocationSource | undefined { 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 } @@ -209,6 +213,35 @@ export function readLogEntryTides(data: Record): LogEntryTides } } +export function readLogEntryTidesMap(data: Record): LogEntryTidesMap { + const tidesRaw = data.tides as Record | 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 { const payload: Record = { date: input.date, @@ -235,21 +268,31 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record = { highWater, lowWater } - if (input.tides.locationSource) tides.locationSource = input.tides.locationSource - const placeName = input.tides.placeName?.trim() - if (placeName) tides.placeName = placeName - const lat = input.tides.lat?.trim() - if (lat) tides.lat = lat - const lng = input.tides.lng?.trim() - if (lng) tides.lng = lng - const distanceKm = input.tides.distanceKm?.trim() - if (distanceKm) tides.distanceKm = distanceKm - if (input.tides.tideFallback === 'open_meteo') tides.tideFallback = 'open_meteo' - payload.tides = tides + const serializedMap: Record = {} + 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) { + const tidesObj: Record = { highWater, lowWater } + if (tideData.locationSource) tidesObj.locationSource = tideData.locationSource + const placeName = tideData.placeName?.trim() + if (placeName) tidesObj.placeName = placeName + const lat = tideData.lat?.trim() + if (lat) tidesObj.lat = lat + const lng = tideData.lng?.trim() + if (lng) tidesObj.lng = lng + const distanceKm = tideData.distanceKm?.trim() + if (distanceKm) tidesObj.distanceKm = distanceKm + if (tideData.tideFallback === 'open_meteo') tidesObj.tideFallback = 'open_meteo' + serializedMap[role] = tidesObj + } + } + } + if (Object.keys(serializedMap).length > 0) { + payload.tides = serializedMap } } diff --git a/client/src/utils/tideLocation.test.ts b/client/src/utils/tideLocation.test.ts index f88bc27..b68de2f 100644 --- a/client/src/utils/tideLocation.test.ts +++ b/client/src/utils/tideLocation.test.ts @@ -3,7 +3,8 @@ import { LIVE_EVENT_CODES } from './liveEventCodes.js' import { buildTideLocationMeta, formatTideLocationLabel, - resolveTideFetchLocation + resolveTideFetchLocation, + getAvailableTideLocations } from './tideLocation.js' const entryDate = '2026-06-11' @@ -126,7 +127,7 @@ describe('resolveTideFetchLocation', () => { }) it('formats coordinate and place labels', () => { - const t = (key: string, options?: Record) => + const t = (key: string, options?: Record) => `${key}:${JSON.stringify(options ?? {})}` expect( formatTideLocationLabel( @@ -175,3 +176,62 @@ describe('resolveTideFetchLocation', () => { 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' } + }) + }) +}) diff --git a/client/src/utils/tideLocation.ts b/client/src/utils/tideLocation.ts index e986b39..06e8a8a 100644 --- a/client/src/utils/tideLocation.ts +++ b/client/src/utils/tideLocation.ts @@ -3,7 +3,7 @@ import { getLatestLoggedPosition, LIVE_LOG_TIDE_POSITION_MAX_AGE_MS } from './liveEventCodes.js' -import type { LogEntryTides, LogEventPayload, TideLocationSource } from './logEntryPayload.js' +import type { LogEntryTides, LogEventPayload, TideLocationSource, TideRole } from './logEntryPayload.js' export type { TideLocationSource } @@ -14,7 +14,14 @@ export type TideLocationMeta = Pick< export type TideFetchLocation = | { 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' @@ -133,6 +140,9 @@ export function formatTideLocationLabel( if (tides.locationSource === 'departure') { 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 '' @@ -148,3 +158,48 @@ export function pickTideLocationMeta(tides: LogEntryTides): TideLocationMeta { tideFallback: tides.tideFallback } } + +export function getAvailableTideLocations(options: { + departure: string + destination: string + events: Array> + 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 +}