feat: BSH-Pegelauswahl und Fix für Eintragstag beim Gezeiten-Abruf

Bei fehlgeschlagenem Auto-Abruf nächste BSH-Stationen anbieten; Reisetag
korrekt aus dem Eintrag parsen und Vergangenheitshinweis anzeigen.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-12 11:16:01 +02:00
parent 7d6c908f65
commit 4f519e34b4
18 changed files with 807 additions and 75 deletions
+39
View File
@@ -3881,6 +3881,45 @@ html.theme-cupertino .events-scroll-container {
line-height: 1.4;
}
.tide-station-picker__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
max-height: min(50vh, 320px);
overflow-y: auto;
}
.tide-station-picker__option {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 12px 14px;
border: 1px solid var(--app-border, rgba(255, 255, 255, 0.12));
border-radius: 10px;
background: var(--app-surface-elevated, rgba(255, 255, 255, 0.04));
color: inherit;
text-align: left;
cursor: pointer;
}
.tide-station-picker__option:hover {
border-color: var(--app-accent, #2dd4bf);
}
.tide-station-picker__name {
font-weight: 600;
}
.tide-station-picker__meta {
font-size: 13px;
color: var(--app-text-muted);
}
.live-log-sail-pills {
margin-bottom: 12px;
}
+65 -15
View File
@@ -58,13 +58,18 @@ const formatSpeedKn = (speedKn: number) =>
formatAppDecimal(speedKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
import { fetchTidesByPlace, fetchTidesNearby, TidesApiError } from '../services/tides.js'
import { TidesApiError, type TideStation } from '../services/tides.js'
import { TideStationPickerModal } from './TideStationPickerModal.tsx'
import {
buildTideLocationMeta,
formatTideLocationLabel,
resolveTideFetchLocation
} from '../utils/tideLocation.js'
import { parseTideTurtleForDate } from '../utils/tideTurtle.js'
import {
fetchTidesForEntry,
fetchTidesForStationChoice,
type TideFetchNeedsStationPick
} from '../utils/tideFetch.js'
import {
geolocationErrorI18nKey,
getCurrentPosition,
@@ -217,6 +222,7 @@ export default function LiveLogView({
lowWater: string
location: ReturnType<typeof buildTideLocationMeta>
} | null>(null)
const [tideStationPicker, setTideStationPicker] = useState<TideFetchNeedsStationPick | null>(null)
const [isOnline, setIsOnline] = useState(navigator.onLine)
const [commentText, setCommentText] = useState('')
const [valueInput, setValueInput] = useState('')
@@ -802,6 +808,38 @@ export default function LiveLogView({
})()
}
const handleTideStationPick = (pick: TideFetchNeedsStationPick, station: TideStation) => {
setTidesLoading(true)
void (async () => {
try {
const result = await fetchTidesForStationChoice({
stationId: station.id,
entryDate: pick.entryDate,
fetchLocation: pick.fetchLocation,
queryLat: pick.queryLat,
queryLng: pick.queryLng,
analyticsSource: 'live_log'
})
setTideStationPicker(null)
setTidePreview({
highWater: result.highWater,
lowWater: result.lowWater,
location: result.location
})
setModal('tides')
} catch (err) {
if (err instanceof TidesApiError && err.code === 'NO_DATA_FOR_DATE') {
void showAlert(t('logs.tide_no_data_for_date', { date: pick.entryDate }), t('logs.tides'))
return
}
console.error('Live log tide station fetch failed:', err)
void showAlert(t('logs.tide_fetch_failed'), t('logs.tides'))
} finally {
setTidesLoading(false)
}
})()
}
const handleFetchTides = () => {
if (!entryId || busy || tidesLoading) return
if (!isOnline) {
@@ -835,24 +873,21 @@ export default function LiveLogView({
return
}
const data =
location.mode === 'nearby'
? await fetchTidesNearby(location.lat, location.lng, {
analyticsSource: 'live_log',
locationSource: location.source
})
: await fetchTidesByPlace(location.query, { analyticsSource: 'live_log' })
const outcome = await fetchTidesForEntry({
fetchLocation: location,
entryDate: entryDateForLocation,
analyticsSource: 'live_log'
})
const parsed = parseTideTurtleForDate(data, date)
if (!parsed.highWater && !parsed.lowWater) {
void showAlert(t('logs.tide_no_data'), t('logs.tides'))
if (outcome.kind === 'pick_station') {
setTideStationPicker(outcome)
return
}
setTidePreview({
highWater: parsed.highWater,
lowWater: parsed.lowWater,
location: buildTideLocationMeta(location, data)
highWater: outcome.highWater,
lowWater: outcome.lowWater,
location: outcome.location
})
setModal('tides')
} catch (err) {
@@ -865,6 +900,10 @@ export default function LiveLogView({
void showAlert(t('logs.tide_place_not_found', { place: departure.trim() }), t('logs.tides'))
return
}
if (err.code === 'NO_DATA_FOR_DATE') {
void showAlert(t('logs.tide_no_data_for_date', { date }), t('logs.tides'))
return
}
if (err.code === 'NOT_FOUND') {
void showAlert(t('logs.tide_no_data'), t('logs.tides'))
return
@@ -1574,6 +1613,17 @@ export default function LiveLogView({
</div>
)}
{tideStationPicker ? (
<TideStationPickerModal
title={t('logs.tide_pick_station_title')}
hint={t('logs.tide_pick_station_hint')}
cancelLabel={t('logs.live_cancel')}
stations={tideStationPicker.stations}
onCancel={() => setTideStationPicker(null)}
onSelect={(station) => handleTideStationPick(tideStationPicker, station)}
/>
) : null}
{modal === 'tides' && tidePreview && (
<div
className="live-log-modal-backdrop"
+68 -16
View File
@@ -43,15 +43,19 @@ import { putEntryRecord } from '../utils/entryListCache.js'
import { getLogbookAccess } from '../services/logbookAccess.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
import { fetchTidesByPlace, fetchTidesNearby, TidesApiError } from '../services/tides.js'
import { TidesApiError, type TideStation } from '../services/tides.js'
import { TideStationPickerModal } from './TideStationPickerModal.tsx'
import {
buildTideLocationMeta,
formatTideLocationLabel,
pickTideLocationMeta,
resolveTideFetchLocation,
type TideLocationMeta
} from '../utils/tideLocation.js'
import { parseTideTurtleForDate } from '../utils/tideTurtle.js'
import {
fetchTidesForEntry,
fetchTidesForStationChoice,
type TideFetchNeedsStationPick
} from '../utils/tideFetch.js'
import {
buildTravelDayContext,
fetchTravelDaySummaryUsage,
@@ -313,6 +317,7 @@ export default function LogEntryEditor({
const [tideLowWater, setTideLowWater] = useState('')
const [tideLocation, setTideLocation] = useState<TideLocationMeta>({})
const [tidesLoading, setTidesLoading] = useState(false)
const [tideStationPicker, setTideStationPicker] = useState<TideFetchNeedsStationPick | null>(null)
const [tanksCollapsed, setTanksCollapsed] = useState(true)
const [columnSelectorOpen, setColumnSelectorOpen] = useState(false)
@@ -1303,6 +1308,41 @@ export default function LogEntryEditor({
}
}
const applyTideFetchResult = (result: {
highWater: string
lowWater: string
location: TideLocationMeta
}) => {
if (result.highWater) setTideHighWater(result.highWater)
if (result.lowWater) setTideLowWater(result.lowWater)
setTideLocation(result.location)
}
const handleTideStationPick = async (pick: TideFetchNeedsStationPick, station: TideStation) => {
setTidesLoading(true)
try {
const result = await fetchTidesForStationChoice({
stationId: station.id,
entryDate: pick.entryDate,
fetchLocation: pick.fetchLocation,
queryLat: pick.queryLat,
queryLng: pick.queryLng,
analyticsSource: 'entry_editor'
})
applyTideFetchResult(result)
setTideStationPicker(null)
} catch (err) {
if (err instanceof TidesApiError && err.code === 'NO_DATA_FOR_DATE') {
showAlert(t('logs.tide_no_data_for_date', { date: pick.entryDate }), t('logs.tide_fetch_btn'))
return
}
console.error('Tide station fetch failed:', err)
showAlert(t('logs.tide_fetch_failed'), t('logs.tide_fetch_btn'))
} finally {
setTidesLoading(false)
}
}
const handleFetchTides = async () => {
if (!isOnline) {
showAlert(t('logs.weather_offline'), t('logs.tide_fetch_btn'))
@@ -1332,23 +1372,18 @@ export default function LogEntryEditor({
return
}
const data =
location.mode === 'nearby'
? await fetchTidesNearby(location.lat, location.lng, {
analyticsSource: 'entry_editor',
locationSource: location.source
})
: await fetchTidesByPlace(location.query, { analyticsSource: 'entry_editor' })
const outcome = await fetchTidesForEntry({
fetchLocation: location,
entryDate: entryDateForLocation,
analyticsSource: 'entry_editor'
})
const parsed = parseTideTurtleForDate(data, date)
if (!parsed.highWater && !parsed.lowWater) {
showAlert(t('logs.tide_no_data'), t('logs.tide_fetch_btn'))
if (outcome.kind === 'pick_station') {
setTideStationPicker(outcome)
return
}
if (parsed.highWater) setTideHighWater(parsed.highWater)
if (parsed.lowWater) setTideLowWater(parsed.lowWater)
setTideLocation(buildTideLocationMeta(location, data))
applyTideFetchResult(outcome)
} catch (err) {
if (err instanceof TidesApiError) {
if (err.code === 'OFFLINE') {
@@ -1359,6 +1394,10 @@ export default function LogEntryEditor({
showAlert(t('logs.tide_place_not_found', { place: departure.trim() }), t('logs.tide_fetch_btn'))
return
}
if (err.code === 'NO_DATA_FOR_DATE') {
showAlert(t('logs.tide_no_data_for_date', { date }), t('logs.tide_fetch_btn'))
return
}
if (err.code === 'NOT_FOUND') {
showAlert(t('logs.tide_no_data'), t('logs.tide_fetch_btn'))
return
@@ -3101,6 +3140,19 @@ export default function LogEntryEditor({
nmeaArchive={nmeaArchive}
onImport={handleNmeaImport}
/>
{tideStationPicker ? (
<TideStationPickerModal
title={t('logs.tide_pick_station_title')}
hint={t('logs.tide_pick_station_hint')}
cancelLabel={t('logs.live_cancel')}
stations={tideStationPicker.stations}
onCancel={() => setTideStationPicker(null)}
onSelect={(station) => {
void handleTideStationPick(tideStationPicker, station)
}}
/>
) : null}
</div>
)
}
@@ -0,0 +1,57 @@
import type { TideStation } from '../services/tides.js'
type TideStationPickerModalProps = {
title: string
hint: string
cancelLabel: string
stations: TideStation[]
onSelect: (station: TideStation) => void
onCancel: () => void
}
export function TideStationPickerModal({
title,
hint,
cancelLabel,
stations,
onSelect,
onCancel
}: TideStationPickerModalProps) {
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">
{stations.map((station) => (
<li key={station.id}>
<button
type="button"
className="tide-station-picker__option"
onClick={() => onSelect(station)}
>
<span className="tide-station-picker__name">{station.name}</span>
<span className="tide-station-picker__meta">
{station.distanceKm} km
{station.area ? ` · ${station.area}` : ''}
</span>
</button>
</li>
))}
</ul>
<div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={onCancel}>
{cancelLabel}
</button>
</div>
</div>
</div>
)
}
+3
View File
@@ -200,6 +200,9 @@
"tide_position_stale": "Den sidste position er ældre end 2 timer. Log position igen eller angiv afgangshavn.",
"tide_fetch_failed": "Tidevand kunne ikke hentes.",
"tide_no_data": "Ingen tidevandsdata for dette sted.",
"tide_no_data_for_date": "Ingen BSH-prognose for rejsedagen {{date}} (kun fremtidige tidspunkter).",
"tide_pick_station_title": "Vælg tidevandsmåler",
"tide_pick_station_hint": "Vælg den nærmeste BSH-måler for tidevandsprognosen.",
"tide_place_not_found": "“{{place}}” kunne ikke findes — angiv en kystby eller havn.",
"tide_fetched_at_position": "Officiel BSH-prognose fra nærmeste tidevandsmåler.",
"tide_open_meteo_fallback": "Modelprognose (Open-Meteo) — ingen BSH-station inden for rækkevidde.",
+3
View File
@@ -200,6 +200,9 @@
"tide_position_stale": "Die letzte Position ist älter als 2 Stunden. Bitte Position erneut setzen oder Abfahrtsort eintragen.",
"tide_fetch_failed": "Gezeiten konnten nicht abgerufen werden.",
"tide_no_data": "Für diesen Ort liegen keine Gezeitendaten vor.",
"tide_no_data_for_date": "Für den Reisetag {{date}} liegt keine BSH-Vorhersage vor (nur zukünftige Termine).",
"tide_pick_station_title": "Pegel auswählen",
"tide_pick_station_hint": "Wähle den nächstgelegenen BSH-Pegel für die Gezeiten-Vorhersage.",
"tide_place_not_found": "„{{place}}“ konnte nicht geortet werden — bitte einen Küstenort oder Hafen angeben.",
"tide_fetched_at_position": "Amtliche BSH-Vorhersage vom nächstgelegenen Pegel.",
"tide_open_meteo_fallback": "Modellprognose (Open-Meteo) — keine BSH-Station in Reichweite.",
+3
View File
@@ -200,6 +200,9 @@
"tide_position_stale": "The last position is older than 2 hours. Log position again or enter a departure port.",
"tide_fetch_failed": "Could not fetch tide data.",
"tide_no_data": "No tide data available for this location.",
"tide_no_data_for_date": "No BSH forecast for travel day {{date}} (future dates only).",
"tide_pick_station_title": "Select tide gauge",
"tide_pick_station_hint": "Choose the nearest BSH gauge for the tide forecast.",
"tide_place_not_found": "“{{place}}” could not be geocoded — please use a coastal place or harbour name.",
"tide_fetched_at_position": "Official BSH forecast from the nearest tide gauge.",
"tide_open_meteo_fallback": "Model forecast (Open-Meteo) — no BSH station within range.",
+3
View File
@@ -200,6 +200,9 @@
"tide_position_stale": "La última posición tiene más de 2 horas. Registra la posición o indica el puerto de salida.",
"tide_fetch_failed": "No se pudieron obtener las mareas.",
"tide_no_data": "No hay datos de marea para este lugar.",
"tide_no_data_for_date": "Sin pronóstico BSH para el día de viaje {{date}} (solo fechas futuras).",
"tide_pick_station_title": "Elegir marégrafo",
"tide_pick_station_hint": "Elige el marégrafo BSH más cercano para el pronóstico de mareas.",
"tide_place_not_found": "«{{place}}» no se encontró — indica un lugar costero o puerto.",
"tide_fetched_at_position": "Pronóstico oficial del BSH desde la marégrafo más cercana.",
"tide_open_meteo_fallback": "Pronóstico modelo (Open-Meteo) — sin estación BSH en el área.",
+3
View File
@@ -200,6 +200,9 @@
"tide_position_stale": "La dernière position date de plus de 2 heures. Enregistrez la position ou indiquez le port de départ.",
"tide_fetch_failed": "Impossible de récupérer les marées.",
"tide_no_data": "Aucune donnée de marée pour cet endroit.",
"tide_no_data_for_date": "Pas de prévision BSH pour le jour de voyage {{date}} (dates futures uniquement).",
"tide_pick_station_title": "Choisir un marégraphe",
"tide_pick_station_hint": "Choisissez le marégraphe BSH le plus proche pour la prévision.",
"tide_place_not_found": "« {{place}} » introuvable — indiquez un lieu côtier ou un port.",
"tide_fetched_at_position": "Prévision officielle BSH depuis la marégraphe la plus proche.",
"tide_open_meteo_fallback": "Prévision modèle (Open-Meteo) — aucune station BSH à proximité.",
+3
View File
@@ -200,6 +200,9 @@
"tide_position_stale": "Siste posisjon er eldre enn 2 timer. Logg posisjon på nytt eller angi avreisehavn.",
"tide_fetch_failed": "Kunne ikke hente tidevann.",
"tide_no_data": "Ingen tidevannsdata for dette stedet.",
"tide_no_data_for_date": "Ingen BSH-prognose for reisedagen {{date}} (kun fremtidige tidspunkter).",
"tide_pick_station_title": "Velg tidevannsmåler",
"tide_pick_station_hint": "Velg nærmeste BSH-måler for tidevannsprognosen.",
"tide_place_not_found": "«{{place}}» ble ikke funnet — oppgi en kyststad eller havn.",
"tide_fetched_at_position": "Offisiell BSH-prognose fra nærmeste tidevannsmåler.",
"tide_open_meteo_fallback": "Modellprognose (Open-Meteo) — ingen BSH-stasjon innen rekkevidde.",
+3
View File
@@ -200,6 +200,9 @@
"tide_position_stale": "Senaste positionen är äldre än 2 timmar. Logga position igen eller ange avgångshamn.",
"tide_fetch_failed": "Kunde inte hämta tidvatten.",
"tide_no_data": "Inga tidvattendata för denna plats.",
"tide_no_data_for_date": "Ingen BSH-prognos för resedagen {{date}} (endast framtida tider).",
"tide_pick_station_title": "Välj tidvattensmätare",
"tide_pick_station_hint": "Välj närmaste BSH-mätare för tidvattenprognosen.",
"tide_place_not_found": "“{{place}}” kunde inte hittas — ange en kustort eller hamn.",
"tide_fetched_at_position": "Officiell BSH-prognos från närmaste tidvattensmätare.",
"tide_open_meteo_fallback": "Modellprognos (Open-Meteo) — ingen BSH-station inom räckhåll.",
+86 -3
View File
@@ -5,21 +5,71 @@ import {
trackPlausibleEvent
} from './analytics.js'
export interface TideStation {
id: string
name: string
lat: number
lon: number
distanceKm: number
area?: string
}
export class TidesApiError extends Error {
code: 'OFFLINE' | 'NOT_FOUND' | 'PLACE_NOT_FOUND' | 'BAD_REQUEST' | 'REQUEST_FAILED'
code:
| 'OFFLINE'
| 'NOT_FOUND'
| 'NO_DATA_FOR_DATE'
| 'PLACE_NOT_FOUND'
| 'BAD_REQUEST'
| 'REQUEST_FAILED'
stations?: TideStation[]
constructor(
message: string,
code: 'OFFLINE' | 'NOT_FOUND' | 'PLACE_NOT_FOUND' | 'BAD_REQUEST' | 'REQUEST_FAILED' = 'REQUEST_FAILED'
code:
| 'OFFLINE'
| 'NOT_FOUND'
| 'NO_DATA_FOR_DATE'
| 'PLACE_NOT_FOUND'
| 'BAD_REQUEST'
| 'REQUEST_FAILED' = 'REQUEST_FAILED',
stations?: TideStation[]
) {
super(message)
this.name = 'TidesApiError'
this.code = code
this.stations = stations
}
}
const TIDES_FETCH_TIMEOUT_MS = 20_000
function readStations(data: Record<string, unknown>): TideStation[] | undefined {
if (!Array.isArray(data.stations)) return undefined
const stations: TideStation[] = []
for (const item of data.stations) {
if (!item || typeof item !== 'object') continue
const row = item as Record<string, unknown>
const id = String(row.id ?? '').trim()
const name = String(row.name ?? '').trim()
const lat = Number(row.lat)
const lon = Number(row.lon)
const distanceKm = Number(row.distanceKm)
if (!id || !name || Number.isNaN(lat) || Number.isNaN(lon) || Number.isNaN(distanceKm)) {
continue
}
stations.push({
id,
name,
lat,
lon,
distanceKm,
area: row.area ? String(row.area) : undefined
})
}
return stations.length > 0 ? stations : undefined
}
async function fetchTides(path: string): Promise<Record<string, unknown>> {
if (!navigator.onLine) {
throw new TidesApiError('Offline', 'OFFLINE')
@@ -44,11 +94,12 @@ async function fetchTides(path: string): Promise<Record<string, unknown>> {
throw new TidesApiError('Invalid tide request parameters', 'BAD_REQUEST')
}
if (res.status === 404) {
const stations = readStations(data as Record<string, unknown>)
const code =
typeof data?.error === 'string' && data.error === 'place_not_found'
? 'PLACE_NOT_FOUND'
: 'NOT_FOUND'
throw new TidesApiError('Tide data not found', code)
throw new TidesApiError('Tide data not found', code, stations)
}
if (!res.ok) {
throw new TidesApiError(
@@ -59,6 +110,16 @@ async function fetchTides(path: string): Promise<Record<string, unknown>> {
return data as Record<string, unknown>
}
export async function fetchNearbyTideStations(
lat: string,
lon: string,
limit = 8
): Promise<TideStation[]> {
const searchParams = new URLSearchParams({ lat, lon, limit: String(limit) })
const data = await fetchTides(`/api/tides/stations/nearby?${searchParams.toString()}`)
return readStations(data) ?? []
}
export async function fetchTidesNearby(
lat: string,
lon: string,
@@ -75,6 +136,28 @@ export async function fetchTidesNearby(
return data
}
export async function fetchTidesByStation(
stationId: string,
options?: {
queryLat?: string
queryLng?: string
analyticsSource?: TideAnalyticsSource
}
): Promise<Record<string, unknown>> {
const searchParams = new URLSearchParams()
if (options?.queryLat) searchParams.set('lat', options.queryLat)
if (options?.queryLng) searchParams.set('lon', options.queryLng)
const suffix = searchParams.toString() ? `?${searchParams.toString()}` : ''
const data = await fetchTides(`/api/tides/station/${encodeURIComponent(stationId)}${suffix}`)
if (options?.analyticsSource) {
trackPlausibleEvent(PlausibleEvents.TIDE_FETCHED, {
source: options.analyticsSource,
location_source: 'gps'
})
}
return data
}
export async function fetchTidesByPlace(
placeQuery: string,
options?: { analyticsSource?: TideAnalyticsSource }
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import * as tidesService from '../services/tides.js'
import { fetchTidesForEntry } from './tideFetch.js'
describe('fetchTidesForEntry', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('returns tide times when nearby fetch succeeds for entry date', async () => {
vi.spyOn(tidesService, 'fetchTidesNearby').mockResolvedValue({
distanceKm: 8,
location: { name: 'Norderney, Riffgat', source: 'bsh_station' },
tides: {
data: {
timezone: 'Europe/Berlin',
extrema: [
{
time: '2026-06-12T07:20:00.000Z',
date: '2026-06-12',
height: 6.16,
isHigh: true
},
{
time: '2026-06-12T13:39:00.000Z',
date: '2026-06-12',
height: 4.03,
isHigh: false
}
]
}
}
})
const outcome = await fetchTidesForEntry({
fetchLocation: { mode: 'nearby', lat: '53.624526', lng: '7.155263', source: 'gps' },
entryDate: '2026-06-12',
analyticsSource: 'entry_editor'
})
expect(outcome).toMatchObject({
highWater: '09:20',
lowWater: '15:39'
})
})
it('offers station picker when fetch succeeds but entry date has no extrema', async () => {
vi.spyOn(tidesService, 'fetchTidesNearby').mockResolvedValue({
tides: {
data: {
timezone: 'Europe/Berlin',
extrema: [
{
time: '2026-06-12T07:20:00.000Z',
date: '2026-06-12',
height: 6.16,
isHigh: true
}
]
}
}
})
await expect(
fetchTidesForEntry({
fetchLocation: { mode: 'nearby', lat: '53.62', lng: '7.15', source: 'gps' },
entryDate: '2026-06-01',
analyticsSource: 'entry_editor'
})
).rejects.toMatchObject({ code: 'NO_DATA_FOR_DATE' })
})
it('offers station picker when nearby fetch returns not found', async () => {
vi.spyOn(tidesService, 'fetchTidesNearby').mockRejectedValue(
new tidesService.TidesApiError('Tide data not found', 'NOT_FOUND', [
{
id: 'norderney_riffgat',
name: 'Norderney, Riffgat',
lat: 53.69,
lon: 7.15,
distanceKm: 8
}
])
)
const outcome = await fetchTidesForEntry({
fetchLocation: { mode: 'nearby', lat: '53.624526', lng: '7.155263', source: 'gps' },
entryDate: '2026-06-12',
analyticsSource: 'entry_editor'
})
expect(outcome).toEqual({
kind: 'pick_station',
entryDate: '2026-06-12',
fetchLocation: { mode: 'nearby', lat: '53.624526', lng: '7.155263', source: 'gps' },
stations: [
{
id: 'norderney_riffgat',
name: 'Norderney, Riffgat',
lat: 53.69,
lon: 7.15,
distanceKm: 8
}
],
lat: '53.624526',
lng: '7.155263'
})
})
})
+145
View File
@@ -0,0 +1,145 @@
import {
fetchNearbyTideStations,
fetchTidesByPlace,
fetchTidesByStation,
fetchTidesNearby,
type TideStation,
TidesApiError
} from '../services/tides.js'
import type { TideFetchLocation } from './tideLocation.js'
import { buildTideLocationMeta, type TideLocationMeta } from './tideLocation.js'
import { extractTideTurtlePayload, parseTideTurtleForDate } from './tideTurtle.js'
export type TideFetchResult = {
highWater: string
lowWater: string
location: TideLocationMeta
apiData: Record<string, unknown>
}
export type TideFetchNeedsStationPick = {
kind: 'pick_station'
entryDate: string
fetchLocation: TideFetchLocation
stations: TideStation[]
queryLat?: string
queryLng?: string
}
export type TideFetchOutcome = TideFetchResult | TideFetchNeedsStationPick
function readQueryCoords(fetchLocation: TideFetchLocation): { lat?: string; lng?: string } {
if (fetchLocation.mode === 'nearby') {
return { lat: fetchLocation.lat, lng: fetchLocation.lng }
}
return {}
}
function hasTideTimesForDate(data: Record<string, unknown>, entryDate: string): boolean {
const parsed = parseTideTurtleForDate(data, entryDate)
return Boolean(parsed.highWater || parsed.lowWater)
}
function toResult(
data: Record<string, unknown>,
entryDate: string,
fetchLocation: TideFetchLocation
): TideFetchResult | null {
const parsed = parseTideTurtleForDate(data, entryDate)
if (!parsed.highWater && !parsed.lowWater) return null
return {
highWater: parsed.highWater,
lowWater: parsed.lowWater,
location: buildTideLocationMeta(fetchLocation, data),
apiData: data
}
}
async function loadNearbyStations(
fetchLocation: TideFetchLocation,
stationsFromError?: TideStation[]
): Promise<TideStation[]> {
if (stationsFromError && stationsFromError.length > 0) {
return stationsFromError
}
if (fetchLocation.mode !== 'nearby') return []
return fetchNearbyTideStations(fetchLocation.lat, fetchLocation.lng)
}
export async function fetchTidesForEntry(options: {
fetchLocation: TideFetchLocation
entryDate: string
analyticsSource: 'entry_editor' | 'live_log'
}): Promise<TideFetchOutcome> {
const { fetchLocation, entryDate, analyticsSource } = options
const queryCoords = readQueryCoords(fetchLocation)
let stationsFromError: TideStation[] | undefined
try {
const data =
fetchLocation.mode === 'nearby'
? await fetchTidesNearby(fetchLocation.lat, fetchLocation.lng, {
analyticsSource,
locationSource: fetchLocation.source
})
: await fetchTidesByPlace(fetchLocation.query, { analyticsSource })
const result = toResult(data, entryDate, fetchLocation)
if (result) return result
const { extrema } = extractTideTurtlePayload(data)
if (extrema.length > 0) {
throw new TidesApiError('No tide data for entry date', 'NO_DATA_FOR_DATE')
}
} catch (error) {
if (error instanceof TidesApiError && error.code === 'NO_DATA_FOR_DATE') {
throw error
}
if (error instanceof TidesApiError && error.stations?.length) {
stationsFromError = error.stations
} else if (!(error instanceof TidesApiError) || error.code !== 'NOT_FOUND') {
throw error
}
}
const stations = await loadNearbyStations(fetchLocation, stationsFromError)
if (stations.length > 0) {
return {
kind: 'pick_station',
entryDate,
fetchLocation,
stations,
...queryCoords
}
}
throw new TidesApiError('Tide data not found', 'NOT_FOUND')
}
export async function fetchTidesForStationChoice(options: {
stationId: string
entryDate: string
fetchLocation: TideFetchLocation
queryLat?: string
queryLng?: string
analyticsSource: 'entry_editor' | 'live_log'
}): Promise<TideFetchResult> {
const data = await fetchTidesByStation(options.stationId, {
queryLat: options.queryLat,
queryLng: options.queryLng,
analyticsSource: options.analyticsSource
})
const result = toResult(data, options.entryDate, options.fetchLocation)
if (!result) {
throw new TidesApiError('Tide data not found', 'NOT_FOUND')
}
return result
}
export function tideDataHasForecastForDate(
data: Record<string, unknown>,
entryDate: string
): boolean {
return hasTideTimesForDate(data, entryDate)
}