feat: Gezeiten über BSH-OGC-API mit Stations-Suche
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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}})",
|
||||
|
||||
@@ -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}})",
|
||||
|
||||
@@ -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}})",
|
||||
|
||||
@@ -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}})",
|
||||
|
||||
@@ -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}})",
|
||||
|
||||
@@ -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}})",
|
||||
|
||||
@@ -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}})",
|
||||
|
||||
@@ -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<string, unknown>): 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<string, unknown>): 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<string
|
||||
if (lat) tides.lat = lat
|
||||
const lng = input.tides.lng?.trim()
|
||||
if (lng) tides.lng = lng
|
||||
const distanceKm = input.tides.distanceKm?.trim()
|
||||
if (distanceKm) tides.distanceKm = distanceKm
|
||||
if (input.tides.tideFallback === 'open_meteo') tides.tideFallback = 'open_meteo'
|
||||
payload.tides = tides
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,13 +130,39 @@ describe('resolveTideFetchLocation', () => {
|
||||
`${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', () => {
|
||||
|
||||
@@ -7,7 +7,10 @@ import type { LogEntryTides, LogEventPayload, TideLocationSource } from './logEn
|
||||
|
||||
export type { TideLocationSource }
|
||||
|
||||
export type TideLocationMeta = Pick<LogEntryTides, 'locationSource' | 'placeName' | 'lat' | 'lng'>
|
||||
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<string, unknown> | null {
|
||||
: null
|
||||
}
|
||||
|
||||
function readDistanceKm(apiData: Record<string, unknown>): 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<string, unknown>): 'open_meteo' | undefined {
|
||||
return apiData.fallback === 'open_meteo' ? 'open_meteo' : undefined
|
||||
}
|
||||
|
||||
export function buildTideLocationMeta(
|
||||
fetchLocation: TideFetchLocation,
|
||||
apiData: Record<string, unknown>
|
||||
): 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -74,9 +74,7 @@ export function extractTideTurtlePayload(data: Record<string, unknown>): {
|
||||
if (!placeName && spatial?.name) placeName = String(spatial.name)
|
||||
|
||||
const distanceKm =
|
||||
location?.source === 'coordinates'
|
||||
? undefined
|
||||
: data.distanceKm != null && data.distanceKm !== ''
|
||||
data.distanceKm != null && data.distanceKm !== ''
|
||||
? Number(data.distanceKm)
|
||||
: undefined
|
||||
|
||||
|
||||
@@ -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."}}}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -3,7 +3,7 @@ import { requireUser } from '../middleware/auth.js'
|
||||
import {
|
||||
fetchTidesForCoordinates,
|
||||
fetchTidesForPlace
|
||||
} from '../utils/openMeteoTides.js'
|
||||
} from '../utils/tideProvider.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
|
||||
@@ -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<T>(name: string): T {
|
||||
return JSON.parse(readFileSync(join(fixturesDir, name), 'utf8')) as T
|
||||
}
|
||||
|
||||
const stationIndex = loadJson<BshStation[]>('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<string, unknown> }>('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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>
|
||||
}
|
||||
|
||||
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<T>(url: string): Promise<T> {
|
||||
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<BshStation[]> {
|
||||
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<OgcFeatureCollection>(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<OgcFeature> {
|
||||
const feature = await fetchJson<OgcFeature>(`${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<BshTideLookupResult> {
|
||||
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<string, string>
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<TideProviderResult> {
|
||||
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<TideProviderResult> {
|
||||
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'
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user