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

This commit is contained in:
2026-06-12 13:58:38 +02:00
parent abd5fe1ac8
commit 5b9c1e3220
15 changed files with 623 additions and 151 deletions
+70 -28
View File
@@ -60,11 +60,15 @@ import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
import { 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<LogEventPayload[]>([])
const [crewSnapshotsById, setCrewSnapshotsById] = useState<Record<string, any>>({})
const [selectedSkipperId, setSelectedSkipperId] = useState<string | null>(null)
@@ -222,8 +228,10 @@ export default function LiveLogView({
highWater: string
lowWater: string
location: ReturnType<typeof buildTideLocationMeta>
role: TideRole
} | null>(null)
const [tideStationPicker, setTideStationPicker] = useState<TideFetchNeedsStationPick | null>(null)
const [tideLocationPickerOptions, setTideLocationPickerOptions] = useState<TideLocationOption[] | null>(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<string, any>) || {})
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 ? (
<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 && (
<div
className="live-log-modal-backdrop"
+167 -81
View File
@@ -13,7 +13,7 @@ import PhotoCapture from './PhotoCapture.tsx'
import EventRemarksCell from './EventRemarksCell.tsx'
import CreatorAvatar from './CreatorAvatar.tsx'
import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js'
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
import { parseLiveVoiceRemark, getLastLoggedPositionWithin } from '../utils/liveEventCodes.js'
import { deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
import type { PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx'
import SignatureSection from './SignatureSection.tsx'
@@ -33,7 +33,18 @@ import {
hasAnySignature
} from '../utils/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 CourseDialInput from './CourseDialInput.tsx'
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 { TidesApiError, type TideStation } from '../services/tides.js'
import { TideStationPickerModal } from './TideStationPickerModal.tsx'
import { TideLocationPickerModal } from './TideLocationPickerModal.tsx'
import {
formatTideLocationLabel,
pickTideLocationMeta,
resolveTideFetchLocation,
type TideLocationMeta
getAvailableTideLocations,
type TideLocationMeta,
type TideLocationOption,
type TideFetchLocation
} from '../utils/tideLocation.js'
import {
fetchTidesForEntry,
@@ -178,7 +191,7 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): 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<string, unknown>)
})
@@ -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<TideLocationMeta>({})
const [tidesMap, setTidesMap] = useState<LogEntryTidesMap>({})
const [tideLocationPickerOptions, setTideLocationPickerOptions] = useState<TideLocationOption[] | null>(null)
const [tidesLoading, setTidesLoading] = useState(false)
const [tideStationPicker, setTideStationPicker] = useState<TideFetchNeedsStationPick | null>(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<string, unknown>)
setTideHighWater(preloadedTides.highWater)
setTideLowWater(preloadedTides.lowWater)
setTideLocation(pickTideLocationMeta(preloadedTides))
const preloadedTides = readLogEntryTidesMap(preloadedEntry as Record<string, unknown>)
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<string, unknown>)
setTideHighWater(loadedTides.highWater)
setTideLowWater(loadedTides.lowWater)
setTideLocation(pickTideLocationMeta(loadedTides))
const loadedTides = readLogEntryTidesMap(decrypted as Record<string, unknown>)
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 && (
<div className="tides-panel">
<div className="tides-panel__hints">
<div className="tides-panel__hints" style={{ marginBottom: '16px' }}>
<p className="form-hint" role="note">
{t('logs.tide_disclaimer')}
</p>
{tideLocationLabel ? (
<p className="tides-panel__location" role="status">
{tideLocationLabel}
</p>
) : null}
</div>
<div className="form-grid tides-panel__fields">
<div className="input-group">
<label>{t('logs.tide_high_water')}</label>
<EventTimeInput24h
value={tideHighWater}
onChange={setTideHighWater}
disabled={readOnly || saving || tidesLoading}
aria-label={t('logs.tide_high_water')}
fallback="00:00"
/>
</div>
<div className="input-group">
<label>{t('logs.tide_low_water')}</label>
<EventTimeInput24h
value={tideLowWater}
onChange={setTideLowWater}
disabled={readOnly || saving || tidesLoading}
aria-label={t('logs.tide_low_water')}
fallback="00:00"
/>
</div>
</div>
{(['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>
) : null}
<div className="form-grid tides-panel__fields">
<div className="input-group">
<label>{t('logs.tide_high_water')}</label>
<EventTimeInput24h
value={tideData.highWater}
onChange={(val) => {
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"
/>
</div>
<div className="input-group">
<label>{t('logs.tide_low_water')}</label>
<EventTimeInput24h
value={tideData.lowWater}
onChange={(val) => {
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"
/>
</div>
</div>
</div>
)
})}
{!readOnly && (
<div className="tides-panel__actions">
<button
@@ -3164,6 +3236,20 @@ export default function LogEntryEditor({
}}
/>
) : 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>
)
}
@@ -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>
)
}