feat(tides): support role-based multi-location tide retrieval, selection, and storage
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user