From 7d6c908f6522af3239dbe471242fc6b3388cbd50 Mon Sep 17 00:00:00 2001 From: elpatron Date: Fri, 12 Jun 2026 11:00:41 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Gezeiten=20=C3=BCber=20BSH-OGC-API=20mi?= =?UTF-8?q?t=20Stations-Suche?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amtliche BSH-Wasserstandsvorhersage ersetzt Open-Meteo als Primärquelle; nächster Pegel per Haversine, Open-Meteo nur außerhalb 75 km Reichweite. Co-authored-by: Cursor --- client/src/i18n/locales/da.json | 5 +- client/src/i18n/locales/de.json | 5 +- client/src/i18n/locales/en.json | 5 +- client/src/i18n/locales/es.json | 5 +- client/src/i18n/locales/fr.json | 5 +- client/src/i18n/locales/nb.json | 5 +- client/src/i18n/locales/sv.json | 5 +- client/src/utils/logEntryPayload.ts | 11 +- client/src/utils/tideLocation.test.ts | 30 +- client/src/utils/tideLocation.ts | 39 ++- client/src/utils/tideTurtle.test.ts | 14 +- client/src/utils/tideTurtle.ts | 8 +- .../src/fixtures/bsh-norderney_riffgat.json | 1 + server/src/fixtures/bsh-station-index.json | 42 +++ server/src/routes/tides.ts | 2 +- server/src/utils/bshTides.test.ts | 75 +++++ server/src/utils/bshTides.ts | 315 ++++++++++++++++++ server/src/utils/openMeteoTides.ts | 7 +- server/src/utils/tideProvider.test.ts | 75 +++++ server/src/utils/tideProvider.ts | 58 ++++ 20 files changed, 680 insertions(+), 32 deletions(-) create mode 100644 server/src/fixtures/bsh-norderney_riffgat.json create mode 100644 server/src/fixtures/bsh-station-index.json create mode 100644 server/src/utils/bshTides.test.ts create mode 100644 server/src/utils/bshTides.ts create mode 100644 server/src/utils/tideProvider.test.ts create mode 100644 server/src/utils/tideProvider.ts diff --git a/client/src/i18n/locales/da.json b/client/src/i18n/locales/da.json index b3a3e85..908a2f5 100644 --- a/client/src/i18n/locales/da.json +++ b/client/src/i18n/locales/da.json @@ -195,13 +195,14 @@ "tide_low_water": "Lavvande", "tide_fetch_btn": "Hent tidevand", "tide_fetch_loading": "Henter tidevand…", - "tide_disclaimer": "Ingen garanti for rigtighed — kontrollér oplysningerne mod officielle kilder!", + "tide_disclaimer": "BSH-vandstandprognose — kontrollér tidskritiske manøvrer mod officielle kilder!", "tide_location_required": "Tidevandsopslag kræver en aktuel position (max. 2 timer) eller en afgangshavn.", "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_place_not_found": "“{{place}}” kunne ikke findes — angiv en kystby eller havn.", - "tide_fetched_at_position": "Modelprognose ved aktuel position (Open-Meteo Marine).", + "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.", "tide_data_for_position": "Forespørgsel for position {{lat}}, {{lng}}", "tide_data_for_place": "Forespørgsel for {{place}}", "tide_data_for_place_and_position": "Forespørgsel for {{place}} ({{lat}}, {{lng}})", diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 8a0466a..a042bf6 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -195,13 +195,14 @@ "tide_low_water": "Niedrigwasser", "tide_fetch_btn": "Gezeiten abrufen", "tide_fetch_loading": "Gezeiten werden geladen…", - "tide_disclaimer": "Keine Gewähr auf Richtigkeit — überprüfe die Informationen anhand offizieller Quellen!", + "tide_disclaimer": "BSH-Wasserstandsvorhersage — überprüfe zeitkritische Manöver anhand offizieller Quellen!", "tide_location_required": "Für den Gezeiten-Abruf wird eine aktuelle Position (max. 2 Stunden alt) oder ein Abfahrtsort benötigt.", "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_place_not_found": "„{{place}}“ konnte nicht geortet werden — bitte einen Küstenort oder Hafen angeben.", - "tide_fetched_at_position": "Modellprognose am aktuellen Standort (Open-Meteo Marine).", + "tide_fetched_at_position": "Amtliche BSH-Vorhersage vom nächstgelegenen Pegel.", + "tide_open_meteo_fallback": "Modellprognose (Open-Meteo) — keine BSH-Station in Reichweite.", "tide_data_for_position": "Abfrage für Position {{lat}}, {{lng}}", "tide_data_for_place": "Abfrage für {{place}}", "tide_data_for_place_and_position": "Abfrage für {{place}} ({{lat}}, {{lng}})", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index ca17aa0..1080cb3 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -195,13 +195,14 @@ "tide_low_water": "Low water", "tide_fetch_btn": "Fetch tides", "tide_fetch_loading": "Loading tides…", - "tide_disclaimer": "No guarantee of accuracy — verify against official sources!", + "tide_disclaimer": "BSH water level forecast — verify time-critical manoeuvres against official sources!", "tide_location_required": "Tide lookup needs a current position (max. 2 hours old) or a departure port.", "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_place_not_found": "“{{place}}” could not be geocoded — please use a coastal place or harbour name.", - "tide_fetched_at_position": "Model forecast at current position (Open-Meteo Marine).", + "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.", "tide_data_for_position": "Query for position {{lat}}, {{lng}}", "tide_data_for_place": "Query for {{place}}", "tide_data_for_place_and_position": "Query for {{place}} ({{lat}}, {{lng}})", diff --git a/client/src/i18n/locales/es.json b/client/src/i18n/locales/es.json index 9e5f5b1..9471480 100644 --- a/client/src/i18n/locales/es.json +++ b/client/src/i18n/locales/es.json @@ -195,13 +195,14 @@ "tide_low_water": "Bajamar", "tide_fetch_btn": "Obtener mareas", "tide_fetch_loading": "Cargando mareas…", - "tide_disclaimer": "Sin garantía de exactitud — comprueba con fuentes oficiales.", + "tide_disclaimer": "Pronóstico BSH — comprueba maniobras críticas con fuentes oficiales.", "tide_location_required": "Las mareas requieren una posición actual (máx. 2 h) o un puerto de salida.", "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_place_not_found": "«{{place}}» no se encontró — indica un lugar costero o puerto.", - "tide_fetched_at_position": "Pronóstico modelo en la posición actual (Open-Meteo Marine).", + "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.", "tide_data_for_position": "Consulta para la posición {{lat}}, {{lng}}", "tide_data_for_place": "Consulta para {{place}}", "tide_data_for_place_and_position": "Consulta para {{place}} ({{lat}}, {{lng}})", diff --git a/client/src/i18n/locales/fr.json b/client/src/i18n/locales/fr.json index 309809f..ffc827f 100644 --- a/client/src/i18n/locales/fr.json +++ b/client/src/i18n/locales/fr.json @@ -195,13 +195,14 @@ "tide_low_water": "Basse mer", "tide_fetch_btn": "Récupérer les marées", "tide_fetch_loading": "Chargement des marées…", - "tide_disclaimer": "Aucune garantie d'exactitude — vérifiez auprès de sources officielles !", + "tide_disclaimer": "Prévision BSH — vérifiez les manœuvres critiques auprès de sources officielles !", "tide_location_required": "Les marées nécessitent une position actuelle (max. 2 h) ou un port de départ.", "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_place_not_found": "« {{place}} » introuvable — indiquez un lieu côtier ou un port.", - "tide_fetched_at_position": "Prévision modèle à la position actuelle (Open-Meteo Marine).", + "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é.", "tide_data_for_position": "Requête pour la position {{lat}}, {{lng}}", "tide_data_for_place": "Requête pour {{place}}", "tide_data_for_place_and_position": "Requête pour {{place}} ({{lat}}, {{lng}})", diff --git a/client/src/i18n/locales/nb.json b/client/src/i18n/locales/nb.json index 00b3a43..520c260 100644 --- a/client/src/i18n/locales/nb.json +++ b/client/src/i18n/locales/nb.json @@ -195,13 +195,14 @@ "tide_low_water": "Lavvann", "tide_fetch_btn": "Hent tidevann", "tide_fetch_loading": "Henter tidevann…", - "tide_disclaimer": "Ingen garanti for riktighet — kontroller opplysningene mot offisielle kilder!", + "tide_disclaimer": "BSH vannstandprognose — kontroller tidskritiske manøvrer mot offisielle kilder!", "tide_location_required": "Tidevann krever aktuell posisjon (maks 2 timer) eller avreisehavn.", "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_place_not_found": "«{{place}}» ble ikke funnet — oppgi en kyststad eller havn.", - "tide_fetched_at_position": "Modellprognose ved gjeldende posisjon (Open-Meteo Marine).", + "tide_fetched_at_position": "Offisiell BSH-prognose fra nærmeste tidevannsmåler.", + "tide_open_meteo_fallback": "Modellprognose (Open-Meteo) — ingen BSH-stasjon innen rekkevidde.", "tide_data_for_position": "Forespørsel for posisjon {{lat}}, {{lng}}", "tide_data_for_place": "Forespørsel for {{place}}", "tide_data_for_place_and_position": "Forespørsel for {{place}} ({{lat}}, {{lng}})", diff --git a/client/src/i18n/locales/sv.json b/client/src/i18n/locales/sv.json index 49aec33..80da3c0 100644 --- a/client/src/i18n/locales/sv.json +++ b/client/src/i18n/locales/sv.json @@ -195,13 +195,14 @@ "tide_low_water": "Lågvatten", "tide_fetch_btn": "Hämta tidvatten", "tide_fetch_loading": "Hämtar tidvatten…", - "tide_disclaimer": "Ingen garanti för riktighet — verifiera mot officiella källor!", + "tide_disclaimer": "BSH vattenståndsprognos — verifiera tidskritiska manövrer mot officiella källor!", "tide_location_required": "Tidvatten kräver aktuell position (max 2 timmar) eller avgångshamn.", "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_place_not_found": "“{{place}}” kunde inte hittas — ange en kustort eller hamn.", - "tide_fetched_at_position": "Modellprognos vid aktuell position (Open-Meteo Marine).", + "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.", "tide_data_for_position": "Förfrågan för position {{lat}}, {{lng}}", "tide_data_for_place": "Förfrågan för {{place}}", "tide_data_for_place_and_position": "Förfrågan för {{place}} ({{lat}}, {{lng}})", diff --git a/client/src/utils/logEntryPayload.ts b/client/src/utils/logEntryPayload.ts index 85b71f5..12dc11e 100644 --- a/client/src/utils/logEntryPayload.ts +++ b/client/src/utils/logEntryPayload.ts @@ -159,6 +159,8 @@ export interface LogEntryTides { placeName?: string lat?: string lng?: string + distanceKm?: string + tideFallback?: 'open_meteo' } export interface LogEntryPayloadInput { @@ -191,7 +193,9 @@ export function readLogEntryTides(data: Record): LogEntryTides const placeName = String(tides?.placeName ?? '').trim() const lat = String(tides?.lat ?? '').trim() const lng = String(tides?.lng ?? '').trim() + const distanceKm = String(tides?.distanceKm ?? '').trim() const locationSource = readTideLocationSource(tides?.locationSource) + const tideFallback = tides?.tideFallback === 'open_meteo' ? 'open_meteo' as const : undefined return { highWater: parseTimeToHHMM(highRaw) ?? '', @@ -199,7 +203,9 @@ export function readLogEntryTides(data: Record): LogEntryTides ...(locationSource ? { locationSource } : {}), ...(placeName ? { placeName } : {}), ...(lat ? { lat } : {}), - ...(lng ? { lng } : {}) + ...(lng ? { lng } : {}), + ...(distanceKm ? { distanceKm } : {}), + ...(tideFallback ? { tideFallback } : {}) } } @@ -240,6 +246,9 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record { `${key}:${JSON.stringify(options ?? {})}` expect( formatTideLocationLabel( - { locationSource: 'gps', lat: '53.62', lng: '7.15', placeName: 'Norddeich' }, + { + locationSource: 'gps', + lat: '53.62', + lng: '7.15', + placeName: 'Norderney, Riffgat', + distanceKm: '8' + }, t ) - ).toContain('tide_data_for_place_and_position') + ).toContain('tide_fetched_from') expect( formatTideLocationLabel({ locationSource: 'gps', lat: '53.62', lng: '7.15' }, t) ).toContain('tide_data_for_position') + expect( + formatTideLocationLabel({ locationSource: 'gps', tideFallback: 'open_meteo' }, t) + ).toContain('tide_open_meteo_fallback') + }) + + it('stores distance from BSH API metadata', () => { + const meta = buildTideLocationMeta( + { mode: 'nearby', lat: '53.624526', lng: '7.155263', source: 'gps' }, + { + distanceKm: 8.1, + location: { + name: 'Norderney, Riffgat', + lat: 53.696389, + lon: 7.157778, + source: 'bsh_station' + } + } + ) + expect(meta.distanceKm).toBe('8.1') + expect(meta.placeName).toBe('Norderney, Riffgat') }) it('returns missing without position or departure', () => { diff --git a/client/src/utils/tideLocation.ts b/client/src/utils/tideLocation.ts index 072b7dc..e986b39 100644 --- a/client/src/utils/tideLocation.ts +++ b/client/src/utils/tideLocation.ts @@ -7,7 +7,10 @@ import type { LogEntryTides, LogEventPayload, TideLocationSource } from './logEn export type { TideLocationSource } -export type TideLocationMeta = Pick +export type TideLocationMeta = Pick< + LogEntryTides, + 'locationSource' | 'placeName' | 'lat' | 'lng' | 'distanceKm' | 'tideFallback' +> export type TideFetchLocation = | { mode: 'nearby'; lat: string; lng: string; source: 'gps' } @@ -54,18 +57,33 @@ function asRecord(value: unknown): Record | null { : null } +function readDistanceKm(apiData: Record): string | undefined { + if (apiData.distanceKm == null || apiData.distanceKm === '') return undefined + const km = Number(apiData.distanceKm) + if (Number.isNaN(km)) return undefined + return String(Math.round(km * 10) / 10) +} + +function readTideFallback(apiData: Record): 'open_meteo' | undefined { + return apiData.fallback === 'open_meteo' ? 'open_meteo' : undefined +} + export function buildTideLocationMeta( fetchLocation: TideFetchLocation, apiData: Record ): TideLocationMeta { const apiLocation = asRecord(apiData.location) + const distanceKm = readDistanceKm(apiData) + const tideFallback = readTideFallback(apiData) if (fetchLocation.mode === 'nearby') { return { locationSource: 'gps', lat: fetchLocation.lat, lng: fetchLocation.lng, - placeName: apiLocation?.name ? String(apiLocation.name) : undefined + placeName: apiLocation?.name ? String(apiLocation.name) : undefined, + ...(distanceKm ? { distanceKm } : {}), + ...(tideFallback ? { tideFallback } : {}) } } @@ -77,7 +95,9 @@ export function buildTideLocationMeta( locationSource: apiLocation?.source === 'geocoded' ? 'geocoded' : 'departure', placeName, lat, - lng + lng, + ...(distanceKm ? { distanceKm } : {}), + ...(tideFallback ? { tideFallback } : {}) } } @@ -93,6 +113,15 @@ export function formatTideLocationLabel( const placeName = tides.placeName?.trim() const lat = tides.lat?.trim() const lng = tides.lng?.trim() + const distanceKm = tides.distanceKm?.trim() + + if (tides.tideFallback === 'open_meteo') { + return t('logs.tide_open_meteo_fallback') + } + + if (placeName && distanceKm) { + return t('logs.tide_fetched_from', { place: placeName, distance: distanceKm }) + } if (placeName && lat && lng) { return t('logs.tide_data_for_place_and_position', { place: placeName, lat, lng }) @@ -114,6 +143,8 @@ export function pickTideLocationMeta(tides: LogEntryTides): TideLocationMeta { locationSource: tides.locationSource, placeName: tides.placeName, lat: tides.lat, - lng: tides.lng + lng: tides.lng, + distanceKm: tides.distanceKm, + tideFallback: tides.tideFallback } } diff --git a/client/src/utils/tideTurtle.test.ts b/client/src/utils/tideTurtle.test.ts index 4c40fbc..ef45af9 100644 --- a/client/src/utils/tideTurtle.test.ts +++ b/client/src/utils/tideTurtle.test.ts @@ -25,16 +25,24 @@ describe('parseTideTurtleForDate', () => { expect(parsed.distanceKm).toBe(1.2) }) - it('reads Open-Meteo coordinate response without distance', () => { + it('reads BSH coordinate response with distance to nearest station', () => { const parsed = parseTideTurtleForDate( { - location: { source: 'coordinates', lat: 53.62, lon: 7.15 }, + distanceKm: 8, + location: { + source: 'bsh_station', + name: 'Norderney, Riffgat', + lat: 53.696389, + lon: 7.157778, + stationId: 'norderney_riffgat' + }, tides: sampleNearby.tides }, '2026-06-11' ) expect(parsed.highWater).toBe('10:50') - expect(parsed.distanceKm).toBeUndefined() + expect(parsed.distanceKm).toBe(8) + expect(parsed.placeName).toBe('Norderney, Riffgat') }) it('leaves missing tide type empty', () => { diff --git a/client/src/utils/tideTurtle.ts b/client/src/utils/tideTurtle.ts index 716d7fa..b68e5fb 100644 --- a/client/src/utils/tideTurtle.ts +++ b/client/src/utils/tideTurtle.ts @@ -74,11 +74,9 @@ export function extractTideTurtlePayload(data: Record): { if (!placeName && spatial?.name) placeName = String(spatial.name) const distanceKm = - location?.source === 'coordinates' - ? undefined - : data.distanceKm != null && data.distanceKm !== '' - ? Number(data.distanceKm) - : undefined + data.distanceKm != null && data.distanceKm !== '' + ? Number(data.distanceKm) + : undefined return { extrema, timezone, placeName, distanceKm } } diff --git a/server/src/fixtures/bsh-norderney_riffgat.json b/server/src/fixtures/bsh-norderney_riffgat.json new file mode 100644 index 0000000..642672b --- /dev/null +++ b/server/src/fixtures/bsh-norderney_riffgat.json @@ -0,0 +1 @@ +{"type": "Feature", "id": "norderney_riffgat", "geometry": {"type": "Point", "coordinates": [7.157778, 53.696389]}, "properties": {"gauge_label": "Norderney, Riffgat", "latitude": 53.696389, "longitude": 7.157778, "area": "Jade und Ostfriesland", "forecast_timestamp": "2026-06-12 08:09:54+02:00", "high_water_low_water": [{"event_timestamp": "2026-06-12 09:20:00+02:00", "event": "HW", "tidal_prediction_value": "606", "forecast_value": 616, "forecast_uncertainty": 10.0, "forecast_deviation": "-0,1 m", "forecast_automated_event_warning": "Wasserstandsvorhersage", "forecast_event_forecast_timestamp": "2026-06-12 08:09:54+02:00"}, {"event_timestamp": "2026-06-12 15:39:00+02:00", "event": "NW", "tidal_prediction_value": "377", "forecast_value": 403, "forecast_uncertainty": 10.0, "forecast_deviation": "+0,2 m", "forecast_automated_event_warning": "Wasserstandsvorhersage", "forecast_event_forecast_timestamp": "2026-06-12 08:09:54+02:00", "mos_forecast_r0_value": 415, "mos_forecast_r0_deviation": "+0,3 m", "mos_forecast_r1_value": 409, "mos_forecast_r1_deviation": "+0,3 m", "mos_forecast_r2_value": 412, "mos_forecast_r2_deviation": "+0,3 m", "mos_forecast_r3_value": 414, "mos_forecast_r3_deviation": "+0,3 m", "mos_forecast_r4_value": 411, "mos_forecast_r4_deviation": "+0,3 m", "mos_forecast_r5_value": 400, "mos_forecast_r5_deviation": "+0,2 m", "mos_forecast_event_forecast_timestamp": "2026-06-12 10:30:00+02:00"}, {"event_timestamp": "2026-06-12 21:41:00+02:00", "event": "HW", "tidal_prediction_value": "629", "forecast_value": 666, "forecast_uncertainty": 10.0, "forecast_deviation": "+0,4 m", "forecast_automated_event_warning": "Wasserstandsvorhersage", "forecast_event_forecast_timestamp": "2026-06-12 08:09:54+02:00", "mos_forecast_r0_value": 653, "mos_forecast_r0_deviation": "+0,3 m", "mos_forecast_r1_value": 653, "mos_forecast_r1_deviation": "+0,3 m", "mos_forecast_r2_value": 658, "mos_forecast_r2_deviation": "+0,3 m", "mos_forecast_r3_value": 657, "mos_forecast_r3_deviation": "+0,3 m", "mos_forecast_r4_value": 653, "mos_forecast_r4_deviation": "+0,3 m", "mos_forecast_r5_value": 651, "mos_forecast_r5_deviation": "+0,2 m", "mos_forecast_event_forecast_timestamp": "2026-06-12 10:30:00+02:00"}, {"event_timestamp": "2026-06-13 04:14:00+02:00", "event": "NW", "tidal_prediction_value": "362", "forecast_value": 393, "forecast_uncertainty": 10.0, "forecast_deviation": "+0,1 m", "forecast_automated_event_warning": "Wasserstandsvorhersage", "forecast_event_forecast_timestamp": "2026-06-12 08:09:54+02:00", "mos_forecast_r0_value": 403, "mos_forecast_r0_deviation": "+0,2 m", "mos_forecast_r1_value": 395, "mos_forecast_r1_deviation": "+0,1 m", "mos_forecast_r2_value": 404, "mos_forecast_r2_deviation": "+0,2 m", "mos_forecast_r3_value": 400, "mos_forecast_r3_deviation": "+0,2 m", "mos_forecast_r4_value": 394, "mos_forecast_r4_deviation": "+0,1 m", "mos_forecast_r5_value": 388, "mos_forecast_r5_deviation": "+/-0,0 m", "mos_forecast_event_forecast_timestamp": "2026-06-12 10:30:00+02:00"}, {"event_timestamp": "2026-06-13 10:21:00+02:00", "event": "HW", "tidal_prediction_value": "617", "mos_forecast_r0_value": 655, "mos_forecast_r0_deviation": "+0,3 m", "mos_forecast_r1_value": 649, "mos_forecast_r1_deviation": "+0,2 m", "mos_forecast_r2_value": 656, "mos_forecast_r2_deviation": "+0,3 m", "mos_forecast_r3_value": 657, "mos_forecast_r3_deviation": "+0,3 m", "mos_forecast_r4_value": 649, "mos_forecast_r4_deviation": "+0,2 m", "mos_forecast_r5_value": 652, "mos_forecast_r5_deviation": "+0,3 m", "mos_forecast_event_forecast_timestamp": "2026-06-12 10:30:00+02:00"}, {"event_timestamp": "2026-06-13 16:47:00+02:00", "event": "NW", "tidal_prediction_value": "366", "mos_forecast_r0_value": 421, "mos_forecast_r0_deviation": "+0,4 m", "mos_forecast_r1_value": 416, "mos_forecast_r1_deviation": "+0,3 m", "mos_forecast_r2_value": 424, "mos_forecast_r2_deviation": "+0,4 m", "mos_forecast_r3_value": 410, "mos_forecast_r3_deviation": "+0,3 m", "mos_forecast_r4_value": 436, "mos_forecast_r4_deviation": "+0,5 m", "mos_forecast_r5_value": 405, "mos_forecast_r5_deviation": "+0,2 m", "mos_forecast_event_forecast_timestamp": "2026-06-12 10:30:00+02:00"}], "copyright": {"de": "@Bundesamt für Seeschifffahrt und Hydrographie (BSH). Das BSH übernimmt für die angegebenen Informationen keine Gewähr. Amtliche Wasserstandsvorhersage des Bundes gemäß §1 SeeAufG.", "en": "@Federal Maritime and Hydrographic Agency (BSH). The BSH accepts no liability for the information provided here. Official water level forecast of the federal government according to §1 SeeAufG."}}} \ No newline at end of file diff --git a/server/src/fixtures/bsh-station-index.json b/server/src/fixtures/bsh-station-index.json new file mode 100644 index 0000000..c0d870b --- /dev/null +++ b/server/src/fixtures/bsh-station-index.json @@ -0,0 +1,42 @@ +[ + { + "id": "bensersiel", + "name": "Bensersiel", + "lat": 53.674722, + "lon": 7.575, + "area": "Jade und Ostfriesland", + "hasHwnw": true + }, + { + "id": "emden_grosse_seeschleuse", + "name": "Emden, Ems, Große Seeschleuse", + "lat": 53.336667, + "lon": 7.186389, + "area": "Ems", + "hasHwnw": true + }, + { + "id": "kiel-holtenau", + "name": "Kiel-Holtenau", + "lat": 54.3720866822911, + "lon": 10.1570496121807, + "area": "Kieler Bucht", + "hasHwnw": false + }, + { + "id": "leyhoern_leybucht", + "name": "Leyhörn, Leybucht", + "lat": 53.549167, + "lon": 7.036111, + "area": "Jade und Ostfriesland", + "hasHwnw": true + }, + { + "id": "norderney_riffgat", + "name": "Norderney, Riffgat", + "lat": 53.696389, + "lon": 7.157778, + "area": "Jade und Ostfriesland", + "hasHwnw": true + } +] \ No newline at end of file diff --git a/server/src/routes/tides.ts b/server/src/routes/tides.ts index a727bb8..9021de4 100644 --- a/server/src/routes/tides.ts +++ b/server/src/routes/tides.ts @@ -3,7 +3,7 @@ import { requireUser } from '../middleware/auth.js' import { fetchTidesForCoordinates, fetchTidesForPlace -} from '../utils/openMeteoTides.js' +} from '../utils/tideProvider.js' const router = Router() diff --git a/server/src/utils/bshTides.test.ts b/server/src/utils/bshTides.test.ts new file mode 100644 index 0000000..4274a34 --- /dev/null +++ b/server/src/utils/bshTides.test.ts @@ -0,0 +1,75 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { + findNearestBshStation, + haversineKm, + parseBshFeatureToExtrema, + parseBshHwnwForecast, + setBshStationCacheForTests, + type BshStation +} from './bshTides.js' + +const fixturesDir = join(dirname(fileURLToPath(import.meta.url)), '../fixtures') + +function loadJson(name: string): T { + return JSON.parse(readFileSync(join(fixturesDir, name), 'utf8')) as T +} + +const stationIndex = loadJson('bsh-station-index.json') + +describe('haversineKm', () => { + it('returns zero for identical points', () => { + expect(haversineKm(53.62, 7.15, 53.62, 7.15)).toBe(0) + }) +}) + +describe('findNearestBshStation', () => { + it('picks Norderney Riffgat for Norddeich coordinates', () => { + const nearest = findNearestBshStation(53.624526, 7.155263, stationIndex) + expect(nearest?.station.id).toBe('norderney_riffgat') + expect(nearest?.distanceKm).toBeGreaterThan(5) + expect(nearest?.distanceKm).toBeLessThan(12) + }) + + it('picks Kiel-Holtenau for Kiel coordinates', () => { + const nearest = findNearestBshStation(54.32, 10.14, stationIndex) + expect(nearest?.station.id).toBe('kiel-holtenau') + expect(nearest?.distanceKm).toBeLessThan(10) + }) +}) + +describe('parseBshHwnwForecast', () => { + it('maps HW/NW events to extrema with Europe/Berlin dates', () => { + const feature = loadJson<{ properties: Record }>('bsh-norderney_riffgat.json') + const extrema = parseBshHwnwForecast(feature) + + expect(extrema.length).toBeGreaterThan(0) + const high = extrema.find((e) => e.isHigh) + const low = extrema.find((e) => !e.isHigh) + expect(high?.date).toMatch(/^\d{4}-\d{2}-\d{2}$/) + expect(low?.date).toMatch(/^\d{4}-\d{2}-\d{2}$/) + expect(high?.time).toContain('T') + expect(high?.height).toBeGreaterThan(0) + }) +}) + +describe('parseBshFeatureToExtrema', () => { + it('uses hwnw_forecast when available', () => { + const feature = loadJson('bsh-norderney_riffgat.json') + const extrema = parseBshFeatureToExtrema(feature) + expect(extrema.some((e) => e.isHigh)).toBe(true) + expect(extrema.some((e) => !e.isHigh)).toBe(true) + }) +}) + +describe('setBshStationCacheForTests', () => { + it('allows injecting station cache', () => { + setBshStationCacheForTests(stationIndex) + expect(findNearestBshStation(53.624526, 7.155263, stationIndex)?.station.id).toBe( + 'norderney_riffgat' + ) + setBshStationCacheForTests(null) + }) +}) diff --git a/server/src/utils/bshTides.ts b/server/src/utils/bshTides.ts new file mode 100644 index 0000000..4682a8a --- /dev/null +++ b/server/src/utils/bshTides.ts @@ -0,0 +1,315 @@ +import type { TideExtreme, TideLookupResult } from './openMeteoTides.js' + +export const MAX_BSH_DISTANCE_KM = 75 +export const BSH_TIMEZONE = 'Europe/Berlin' + +const API_BASE = + 'https://gdi.bsh.de/ldproxy/rest/services/WaterLevelForecast/collections/waterlevelforecastdata/items' +const LIST_LIMIT = 1000 +const MAX_PAGES = 20 +const CACHE_TTL_MS = 24 * 60 * 60 * 1000 +const FETCH_TIMEOUT_MS = 15_000 + +export interface BshStation { + id: string + name: string + lat: number + lon: number + area?: string +} + +interface OgcFeatureCollection { + features?: OgcFeature[] + links?: Array<{ rel?: string; href?: string }> +} + +interface OgcFeature { + type?: string + id?: string + geometry?: { coordinates?: [number, number] } + properties?: Record +} + +interface HwnwEvent { + event?: string + event_timestamp?: string + forecast_value?: number | string | null + tidal_prediction_value?: number | string | null +} + +interface CurvePoint { + timestamp?: string + automated_curve_forecast?: number | string | null +} + +let stationCache: { stations: BshStation[]; loadedAt: number } | null = null + +async function fetchJson(url: string): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) + try { + const res = await fetch(url, { signal: controller.signal, redirect: 'follow' }) + const data = await res.json() + if (!res.ok) { + throw new Error(`BSH HTTP ${res.status}`) + } + return data as T + } finally { + clearTimeout(timeout) + } +} + +function parseNum(value: unknown): number | null { + if (value == null || value === '') return null + if (typeof value === 'number') return value + const n = Number(value) + return Number.isNaN(n) ? null : n +} + +function stationFromFeature(feature: OgcFeature): BshStation | null { + const id = feature.id + const props = feature.properties + if (!id || !props) return null + + const name = String(props.gauge_label ?? '').trim() + if (!name) return null + + const geom = feature.geometry?.coordinates + const lat = parseNum(props.latitude) ?? (geom ? geom[1] : null) + const lon = parseNum(props.longitude) ?? (geom ? geom[0] : null) + if (lat == null || lon == null) return null + + return { + id, + name, + lat, + lon, + area: props.area ? String(props.area) : undefined + } +} + +export function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number { + const R = 6371 + const p = Math.PI / 180 + const dLat = (lat2 - lat1) * p + const dLon = (lon2 - lon1) * p + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(lat1 * p) * Math.cos(lat2 * p) * Math.sin(dLon / 2) ** 2 + return 2 * R * Math.asin(Math.sqrt(a)) +} + +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 } + } + } + return best +} + +export async function loadBshStationIndex(): Promise { + if (stationCache && Date.now() - stationCache.loadedAt < CACHE_TTL_MS) { + return stationCache.stations + } + + const stations: BshStation[] = [] + let nextUrl: string | null = `${API_BASE}?f=json&limit=${LIST_LIMIT}` + + for (let page = 0; page < MAX_PAGES && nextUrl; page += 1) { + const currentUrl = nextUrl + const payload: OgcFeatureCollection = await fetchJson(currentUrl) + const features = payload.features ?? [] + for (const feature of features) { + const station = stationFromFeature(feature) + if (station) stations.push(station) + } + + nextUrl = null + const links = payload.links ?? [] + for (let i = 0; i < links.length; i += 1) { + const link = links[i] + if (link.rel === 'next' && link.href) { + nextUrl = link.href + break + } + } + } + + if (stations.length === 0) { + throw new Error('bsh_empty_station_list') + } + + stationCache = { stations, loadedAt: Date.now() } + return stations +} + +/** Test helper: inject a pre-built station list and skip live index fetch. */ +export function setBshStationCacheForTests(stations: BshStation[] | null): void { + stationCache = stations ? { stations, loadedAt: Date.now() } : null +} + +function localDateFromIso(iso: string, timeZone: string): string { + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return '' + return new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit' + }).format(date) +} + +function bshTimestampToIso(timestamp: string): string { + const normalized = timestamp.trim().replace(' ', 'T') + const date = new Date(normalized) + if (Number.isNaN(date.getTime())) return '' + return date.toISOString() +} + +function heightMetresFromCm(value: unknown): number { + const cm = parseNum(value) + if (cm == null) return 0 + return Number((cm / 100).toFixed(2)) +} + +export function parseBshHwnwForecast( + feature: OgcFeature, + timeZone = BSH_TIMEZONE +): TideExtreme[] { + const props = feature.properties ?? {} + const hwnw = props.high_water_low_water + if (!Array.isArray(hwnw) || hwnw.length === 0) return [] + + const extrema: TideExtreme[] = [] + for (const raw of hwnw as HwnwEvent[]) { + const event = String(raw.event ?? '').toUpperCase() + const timestamp = String(raw.event_timestamp ?? '').trim() + if (!timestamp || (event !== 'HW' && event !== 'NW')) continue + + const iso = bshTimestampToIso(timestamp) + if (!iso) continue + + const value = raw.forecast_value ?? raw.tidal_prediction_value + extrema.push({ + time: iso, + date: localDateFromIso(iso, timeZone), + height: heightMetresFromCm(value), + isHigh: event === 'HW' + }) + } + return extrema +} + +function parseBshCurveForecast( + feature: OgcFeature, + timeZone = BSH_TIMEZONE +): TideExtreme[] { + const curve = feature.properties?.curve + if (!Array.isArray(curve) || curve.length < 3) return [] + + const points = (curve as CurvePoint[]) + .map((p) => ({ + timestamp: String(p.timestamp ?? '').trim(), + level: parseNum(p.automated_curve_forecast) + })) + .filter((p) => p.timestamp && p.level != null) as Array<{ + timestamp: string + level: number + }> + + const extrema: TideExtreme[] = [] + for (let i = 1; i < points.length - 1; i += 1) { + const prev = points[i - 1].level + const curr = points[i].level + const next = points[i + 1].level + const isHigh = curr >= prev && curr >= next && (curr > prev || curr > next) + const isLow = curr <= prev && curr <= next && (curr < prev || curr < next) + if (!isHigh && !isLow) continue + + const iso = bshTimestampToIso(points[i].timestamp) + if (!iso) continue + extrema.push({ + time: iso, + date: localDateFromIso(iso, timeZone), + height: Number((curr / 100).toFixed(2)), + isHigh + }) + } + return extrema +} + +export function parseBshFeatureToExtrema(feature: OgcFeature): TideExtreme[] { + const hwnw = parseBshHwnwForecast(feature) + if (hwnw.length > 0) return hwnw + return parseBshCurveForecast(feature) +} + +async function fetchBshStationFeature(stationId: string): Promise { + const feature = await fetchJson(`${API_BASE}/${stationId}?f=json`) + if (feature.type !== 'Feature' || !feature.properties) { + throw new Error('bsh_invalid_station') + } + return feature +} + +export interface BshTideLookupResult extends TideLookupResult { + distanceKm: number +} + +export async function fetchBshTidesForCoordinates( + lat: number, + lon: number +): Promise { + const stations = await loadBshStationIndex() + const nearest = findNearestBshStation(lat, lon, stations) + if (!nearest) { + throw new Error('no_bsh_station') + } + + if (nearest.distanceKm > MAX_BSH_DISTANCE_KM) { + const err = new Error('bsh_station_too_far') as Error & { distanceKm?: number } + err.distanceKm = nearest.distanceKm + throw err + } + + 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 + } + } + } +} diff --git a/server/src/utils/openMeteoTides.ts b/server/src/utils/openMeteoTides.ts index 0d4d01d..fa1449c 100644 --- a/server/src/utils/openMeteoTides.ts +++ b/server/src/utils/openMeteoTides.ts @@ -10,17 +10,20 @@ export interface TideExtreme { isHigh: boolean } +export type TideLocationSource = 'coordinates' | 'geocoded' | 'bsh_station' + export interface TideLookupResult { location: { name?: string lat: number lon: number - source: 'coordinates' | 'geocoded' + source: TideLocationSource + stationId?: string } tides: { data: { timezone: string - datum: 'MSL' + datum: 'MSL' | 'gauge' source: string extrema: TideExtreme[] } diff --git a/server/src/utils/tideProvider.test.ts b/server/src/utils/tideProvider.test.ts new file mode 100644 index 0000000..f7bb645 --- /dev/null +++ b/server/src/utils/tideProvider.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import * as bshTides from './bshTides.js' +import * as openMeteoTides from './openMeteoTides.js' +import { fetchTidesForCoordinates } from './tideProvider.js' + +describe('fetchTidesForCoordinates', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('returns BSH data when station is within range', async () => { + vi.spyOn(bshTides, 'fetchBshTidesForCoordinates').mockResolvedValue({ + distanceKm: 8, + location: { + name: 'Norderney, Riffgat', + lat: 53.696389, + lon: 7.157778, + source: 'bsh_station', + stationId: 'norderney_riffgat' + }, + tides: { + data: { + timezone: 'Europe/Berlin', + datum: 'gauge', + source: 'BSH', + extrema: [ + { + time: '2026-06-12T07:20:00.000Z', + date: '2026-06-12', + height: 6.16, + isHigh: true + } + ] + } + } + }) + + const result = await fetchTidesForCoordinates(53.62, 7.15) + expect(result.distanceKm).toBe(8) + expect(result.location.source).toBe('bsh_station') + expect(result.fallback).toBeUndefined() + }) + + it('falls back to Open-Meteo when BSH station is too far', async () => { + vi.spyOn(bshTides, 'fetchBshTidesForCoordinates').mockRejectedValue( + Object.assign(new Error('bsh_station_too_far'), { distanceKm: 120 }) + ) + vi.spyOn(openMeteoTides, 'fetchTidesForCoordinates').mockResolvedValue({ + location: { lat: 62, lon: 5, source: 'coordinates' }, + tides: { + data: { + timezone: 'Europe/Oslo', + datum: 'MSL', + source: 'Open-Meteo Marine', + extrema: [ + { + time: '2026-06-12T10:00:00.000Z', + date: '2026-06-12', + height: 1.2, + isHigh: true + } + ] + } + } + }) + + const result = await fetchTidesForCoordinates(62, 5) + expect(result.fallback).toBe('open_meteo') + expect(result.tides.data.source).toContain('Fallback') + }) +}) diff --git a/server/src/utils/tideProvider.ts b/server/src/utils/tideProvider.ts new file mode 100644 index 0000000..13b9df6 --- /dev/null +++ b/server/src/utils/tideProvider.ts @@ -0,0 +1,58 @@ +import { fetchBshTidesForCoordinates, MAX_BSH_DISTANCE_KM } from './bshTides.js' +import { + fetchTidesForCoordinates as fetchOpenMeteoTidesForCoordinates, + fetchTidesForPlace as fetchOpenMeteoTidesForPlace, + geocodePlace, + type TideLookupResult +} from './openMeteoTides.js' + +export type TideProviderResult = TideLookupResult & { + distanceKm?: number + fallback?: 'open_meteo' +} + +export async function fetchTidesForCoordinates( + lat: number, + lon: number, + options?: { name?: string; source?: 'coordinates' | 'geocoded' } +): Promise { + try { + const bsh = await fetchBshTidesForCoordinates(lat, lon) + return bsh + } catch (error: unknown) { + const message = error instanceof Error ? error.message : '' + const tooFar = message === 'bsh_station_too_far' + const noStation = message === 'no_bsh_station' || message === 'bsh_empty_station_list' + const noData = message === 'no_tide_data' + + if (!tooFar && !noStation && !noData) { + console.warn('BSH tide lookup failed, trying Open-Meteo fallback:', error) + } + + const fallback = await fetchOpenMeteoTidesForCoordinates(lat, lon, options) + return { + ...fallback, + fallback: 'open_meteo', + tides: { + data: { + ...fallback.tides.data, + source: `${fallback.tides.data.source} (Fallback — keine BSH-Station innerhalb ${MAX_BSH_DISTANCE_KM} km)` + } + } + } + } +} + +export async function fetchTidesForPlace(query: string): Promise { + const place = await geocodePlace(query) + if (!place) { + const err = new Error('place_not_found') as Error & { status?: number } + err.status = 404 + throw err + } + + return fetchTidesForCoordinates(place.latitude, place.longitude, { + name: place.name, + source: 'geocoded' + }) +}