From 4f519e34b4d699bfd6d6e83cc942e72075967ef1 Mon Sep 17 00:00:00 2001 From: elpatron Date: Fri, 12 Jun 2026 11:16:01 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20BSH-Pegelauswahl=20und=20Fix=20f=C3=BCr?= =?UTF-8?q?=20Eintragstag=20beim=20Gezeiten-Abruf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bei fehlgeschlagenem Auto-Abruf nächste BSH-Stationen anbieten; Reisetag korrekt aus dem Eintrag parsen und Vergangenheitshinweis anzeigen. Co-authored-by: Cursor --- client/src/App.css | 39 +++++ client/src/components/LiveLogView.tsx | 80 ++++++++-- client/src/components/LogEntryEditor.tsx | 84 ++++++++-- .../src/components/TideStationPickerModal.tsx | 57 +++++++ client/src/i18n/locales/da.json | 3 + client/src/i18n/locales/de.json | 3 + client/src/i18n/locales/en.json | 3 + client/src/i18n/locales/es.json | 3 + client/src/i18n/locales/fr.json | 3 + client/src/i18n/locales/nb.json | 3 + client/src/i18n/locales/sv.json | 3 + client/src/services/tides.ts | 89 ++++++++++- client/src/utils/tideFetch.test.ts | 113 ++++++++++++++ client/src/utils/tideFetch.ts | 145 ++++++++++++++++++ server/src/routes/tides.ts | 63 +++++++- server/src/utils/bshTides.test.ts | 10 ++ server/src/utils/bshTides.ts | 145 +++++++++++++----- server/src/utils/tideProvider.ts | 36 ++++- 18 files changed, 807 insertions(+), 75 deletions(-) create mode 100644 client/src/components/TideStationPickerModal.tsx create mode 100644 client/src/utils/tideFetch.test.ts create mode 100644 client/src/utils/tideFetch.ts diff --git a/client/src/App.css b/client/src/App.css index d98ec2b..0928d4e 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -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; } diff --git a/client/src/components/LiveLogView.tsx b/client/src/components/LiveLogView.tsx index d5a8beb..f3c5f94 100644 --- a/client/src/components/LiveLogView.tsx +++ b/client/src/components/LiveLogView.tsx @@ -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 } | null>(null) + const [tideStationPicker, setTideStationPicker] = useState(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({ )} + {tideStationPicker ? ( + setTideStationPicker(null)} + onSelect={(station) => handleTideStationPick(tideStationPicker, station)} + /> + ) : null} + {modal === 'tides' && tidePreview && (
({}) const [tidesLoading, setTidesLoading] = useState(false) + const [tideStationPicker, setTideStationPicker] = useState(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 ? ( + setTideStationPicker(null)} + onSelect={(station) => { + void handleTideStationPick(tideStationPicker, station) + }} + /> + ) : null}
) } diff --git a/client/src/components/TideStationPickerModal.tsx b/client/src/components/TideStationPickerModal.tsx new file mode 100644 index 0000000..1e26a01 --- /dev/null +++ b/client/src/components/TideStationPickerModal.tsx @@ -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 ( +
{ + if (e.target === e.currentTarget) onCancel() + }} + > +
e.stopPropagation()}> +

{title}

+

+ {hint} +

+
    + {stations.map((station) => ( +
  • + +
  • + ))} +
+
+ +
+
+
+ ) +} diff --git a/client/src/i18n/locales/da.json b/client/src/i18n/locales/da.json index 908a2f5..c01d9f7 100644 --- a/client/src/i18n/locales/da.json +++ b/client/src/i18n/locales/da.json @@ -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.", diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index a042bf6..6ab12af 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -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.", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 1080cb3..9c913b8 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -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.", diff --git a/client/src/i18n/locales/es.json b/client/src/i18n/locales/es.json index 9471480..7105269 100644 --- a/client/src/i18n/locales/es.json +++ b/client/src/i18n/locales/es.json @@ -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.", diff --git a/client/src/i18n/locales/fr.json b/client/src/i18n/locales/fr.json index ffc827f..ecc68db 100644 --- a/client/src/i18n/locales/fr.json +++ b/client/src/i18n/locales/fr.json @@ -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é.", diff --git a/client/src/i18n/locales/nb.json b/client/src/i18n/locales/nb.json index 520c260..87cc647 100644 --- a/client/src/i18n/locales/nb.json +++ b/client/src/i18n/locales/nb.json @@ -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.", diff --git a/client/src/i18n/locales/sv.json b/client/src/i18n/locales/sv.json index 80da3c0..65859ad 100644 --- a/client/src/i18n/locales/sv.json +++ b/client/src/i18n/locales/sv.json @@ -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.", diff --git a/client/src/services/tides.ts b/client/src/services/tides.ts index 6aac5f1..f139651 100644 --- a/client/src/services/tides.ts +++ b/client/src/services/tides.ts @@ -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): 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 + 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> { if (!navigator.onLine) { throw new TidesApiError('Offline', 'OFFLINE') @@ -44,11 +94,12 @@ async function fetchTides(path: string): Promise> { throw new TidesApiError('Invalid tide request parameters', 'BAD_REQUEST') } if (res.status === 404) { + const stations = readStations(data as Record) 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> { return data as Record } +export async function fetchNearbyTideStations( + lat: string, + lon: string, + limit = 8 +): Promise { + 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> { + 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 } diff --git a/client/src/utils/tideFetch.test.ts b/client/src/utils/tideFetch.test.ts new file mode 100644 index 0000000..e3c3078 --- /dev/null +++ b/client/src/utils/tideFetch.test.ts @@ -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' + }) + }) +}) diff --git a/client/src/utils/tideFetch.ts b/client/src/utils/tideFetch.ts new file mode 100644 index 0000000..c76b422 --- /dev/null +++ b/client/src/utils/tideFetch.ts @@ -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 +} + +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, entryDate: string): boolean { + const parsed = parseTideTurtleForDate(data, entryDate) + return Boolean(parsed.highWater || parsed.lowWater) +} + +function toResult( + data: Record, + 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 { + 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 { + 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 { + 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, + entryDate: string +): boolean { + return hasTideTimesForDate(data, entryDate) +} diff --git a/server/src/routes/tides.ts b/server/src/routes/tides.ts index 9021de4..66c0808 100644 --- a/server/src/routes/tides.ts +++ b/server/src/routes/tides.ts @@ -2,7 +2,9 @@ import { Router } from 'express' import { requireUser } from '../middleware/auth.js' import { fetchTidesForCoordinates, - fetchTidesForPlace + fetchTidesForPlace, + fetchTidesForStation, + listNearbyTideStations } from '../utils/tideProvider.js' const router = Router() @@ -15,6 +17,61 @@ function parseLatLon(lat: unknown, lon: unknown): { lat: number; lon: number } | return { lat: latNum, lon: lonNum } } +function parseLimit(value: unknown, fallback = 8): number { + const n = Number(value) + if (Number.isNaN(n)) return fallback + return Math.min(20, Math.max(1, Math.floor(n))) +} + +async function noTideDataResponse(lat: number, lon: number) { + const stations = await listNearbyTideStations(lat, lon, 8) + if (stations.length > 0) { + return { error: 'no_tide_data', stations } + } + return { error: 'no_tide_data' } +} + +router.get('/stations/nearby', requireUser, async (req, res) => { + try { + const coords = parseLatLon(req.query.lat, req.query.lon) + if (!coords) { + return res.status(400).json({ error: 'lat and lon are required' }) + } + + const stations = await listNearbyTideStations(coords.lat, coords.lon, parseLimit(req.query.limit)) + return res.json({ stations }) + } catch (error: unknown) { + console.error('Error listing nearby tide stations:', error) + return res.status(502).json({ error: 'station_list_failed' }) + } +}) + +router.get('/station/:stationId', requireUser, async (req, res) => { + try { + const stationId = String(req.params.stationId ?? '').trim() + if (!stationId) { + return res.status(400).json({ error: 'stationId is required' }) + } + + const coords = parseLatLon(req.query.lat, req.query.lon) + const data = await fetchTidesForStation( + stationId, + coords ? { queryLat: coords.lat, queryLon: coords.lon } : undefined + ) + return res.json(data) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Tide lookup failed' + if (message === 'bsh_invalid_station') { + return res.status(404).json({ error: 'station_not_found' }) + } + if (message === 'no_tide_data') { + return res.status(404).json({ error: 'no_tide_data' }) + } + console.error('Error fetching station tides:', error) + return res.status(502).json({ error: message }) + } +}) + router.get('/nearby', requireUser, async (req, res) => { try { const coords = parseLatLon(req.query.lat, req.query.lon) @@ -27,6 +84,10 @@ router.get('/nearby', requireUser, async (req, res) => { } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Tide lookup failed' if (message === 'no_tide_data') { + const coords = parseLatLon(req.query.lat, req.query.lon) + if (coords) { + return res.status(404).json(await noTideDataResponse(coords.lat, coords.lon)) + } return res.status(404).json({ error: 'no_tide_data' }) } console.error('Error fetching nearby tides:', error) diff --git a/server/src/utils/bshTides.test.ts b/server/src/utils/bshTides.test.ts index 4274a34..310ffd9 100644 --- a/server/src/utils/bshTides.test.ts +++ b/server/src/utils/bshTides.test.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { findNearestBshStation, + findNearestBshStations, haversineKm, parseBshFeatureToExtrema, parseBshHwnwForecast, @@ -25,6 +26,15 @@ describe('haversineKm', () => { }) }) +describe('findNearestBshStations', () => { + it('returns multiple ranked stations', () => { + const nearest = findNearestBshStations(53.624526, 7.155263, stationIndex, 3) + expect(nearest).toHaveLength(3) + expect(nearest[0].id).toBe('norderney_riffgat') + expect(nearest[1].distanceKm).toBeGreaterThanOrEqual(nearest[0].distanceKm) + }) +}) + describe('findNearestBshStation', () => { it('picks Norderney Riffgat for Norddeich coordinates', () => { const nearest = findNearestBshStation(53.624526, 7.155263, stationIndex) diff --git a/server/src/utils/bshTides.ts b/server/src/utils/bshTides.ts index 4682a8a..b615720 100644 --- a/server/src/utils/bshTides.ts +++ b/server/src/utils/bshTides.ts @@ -99,21 +99,52 @@ export function haversineKm(lat1: number, lon1: number, lat2: number, lon2: numb return 2 * R * Math.asin(Math.sqrt(a)) } +export interface BshStationSuggestion { + id: string + name: string + lat: number + lon: number + distanceKm: number + area?: string +} + +export function findNearestBshStations( + lat: number, + lon: number, + stations: BshStation[], + limit = 8 +): BshStationSuggestion[] { + const ranked = stations + .map((station) => ({ + id: station.id, + name: station.name, + lat: station.lat, + lon: station.lon, + area: station.area, + distanceKm: Number(haversineKm(lat, lon, station.lat, station.lon).toFixed(1)) + })) + .sort((a, b) => a.distanceKm - b.distanceKm) + + return ranked.slice(0, Math.max(1, limit)) +} + export function findNearestBshStation( lat: number, lon: number, stations: BshStation[] ): { station: BshStation; distanceKm: number } | null { - if (stations.length === 0) return null - - let best: { station: BshStation; distanceKm: number } | null = null - for (const station of stations) { - const distanceKm = haversineKm(lat, lon, station.lat, station.lon) - if (!best || distanceKm < best.distanceKm) { - best = { station, distanceKm } - } + const nearest = findNearestBshStations(lat, lon, stations, 1)[0] + if (!nearest) return null + return { + station: { + id: nearest.id, + name: nearest.name, + lat: nearest.lat, + lon: nearest.lon, + area: nearest.area + }, + distanceKm: nearest.distanceKm } - return best } export async function loadBshStationIndex(): Promise { @@ -265,6 +296,71 @@ export interface BshTideLookupResult extends TideLookupResult { distanceKm: number } +export async function listNearbyBshStations( + lat: number, + lon: number, + limit = 8 +): Promise { + const stations = await loadBshStationIndex() + return findNearestBshStations(lat, lon, stations, limit) +} + +function buildBshTideResult( + station: BshStation, + distanceKm: number, + feature: OgcFeature +): BshTideLookupResult { + const extrema = parseBshFeatureToExtrema(feature) + if (extrema.length === 0) { + throw new Error('no_tide_data') + } + + const copyright = feature.properties?.copyright + let sourceNote = 'BSH Wasserstandsvorhersage (© BSH, CC BY 4.0)' + if (copyright && typeof copyright === 'object' && copyright !== null) { + const cr = copyright as Record + sourceNote = cr.de || cr.en || sourceNote + } + + return { + distanceKm: Number(distanceKm.toFixed(1)), + location: { + name: station.name, + lat: station.lat, + lon: station.lon, + source: 'bsh_station', + stationId: station.id + }, + tides: { + data: { + timezone: BSH_TIMEZONE, + datum: 'gauge', + source: sourceNote, + extrema + } + } + } +} + +export async function fetchBshTidesForStation( + stationId: string, + options?: { queryLat?: number; queryLon?: number } +): Promise { + const stations = await loadBshStationIndex() + const station = stations.find((item) => item.id === stationId) + if (!station) { + throw new Error('bsh_invalid_station') + } + + const feature = await fetchBshStationFeature(stationId) + const distanceKm = + options?.queryLat != null && options?.queryLon != null + ? haversineKm(options.queryLat, options.queryLon, station.lat, station.lon) + : 0 + + return buildBshTideResult(station, distanceKm, feature) +} + export async function fetchBshTidesForCoordinates( lat: number, lon: number @@ -282,34 +378,5 @@ export async function fetchBshTidesForCoordinates( } const feature = await fetchBshStationFeature(nearest.station.id) - const extrema = parseBshFeatureToExtrema(feature) - if (extrema.length === 0) { - throw new Error('no_tide_data') - } - - const copyright = feature.properties?.copyright - let sourceNote = 'BSH Wasserstandsvorhersage (© BSH, CC BY 4.0)' - if (copyright && typeof copyright === 'object' && copyright !== null) { - const cr = copyright as Record - sourceNote = cr.de || cr.en || sourceNote - } - - return { - distanceKm: Number(nearest.distanceKm.toFixed(1)), - location: { - name: nearest.station.name, - lat: nearest.station.lat, - lon: nearest.station.lon, - source: 'bsh_station', - stationId: nearest.station.id - }, - tides: { - data: { - timezone: BSH_TIMEZONE, - datum: 'gauge', - source: sourceNote, - extrema - } - } - } + return buildBshTideResult(nearest.station, nearest.distanceKm, feature) } diff --git a/server/src/utils/tideProvider.ts b/server/src/utils/tideProvider.ts index 13b9df6..234d5e2 100644 --- a/server/src/utils/tideProvider.ts +++ b/server/src/utils/tideProvider.ts @@ -1,4 +1,10 @@ -import { fetchBshTidesForCoordinates, MAX_BSH_DISTANCE_KM } from './bshTides.js' +import { + fetchBshTidesForCoordinates, + fetchBshTidesForStation, + listNearbyBshStations, + MAX_BSH_DISTANCE_KM, + type BshStationSuggestion +} from './bshTides.js' import { fetchTidesForCoordinates as fetchOpenMeteoTidesForCoordinates, fetchTidesForPlace as fetchOpenMeteoTidesForPlace, @@ -43,6 +49,34 @@ export async function fetchTidesForCoordinates( } } +export async function listNearbyTideStations( + lat: number, + lon: number, + limit = 8 +): Promise { + try { + return await listNearbyBshStations(lat, lon, limit) + } catch { + return [] + } +} + +export async function fetchTidesForStation( + stationId: string, + options?: { queryLat?: number; queryLon?: number } +): Promise { + try { + return await fetchBshTidesForStation(stationId, options) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : '' + if (message === 'bsh_invalid_station' || message === 'no_tide_data') { + throw error + } + console.warn('BSH station tide lookup failed:', error) + throw new Error('no_tide_data') + } +} + export async function fetchTidesForPlace(query: string): Promise { const place = await geocodePlace(query) if (!place) {