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:
2026-06-12 11:00:41 +02:00
parent 0b46154696
commit 7d6c908f65
20 changed files with 680 additions and 32 deletions
+3 -2
View File
@@ -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}})",
+3 -2
View File
@@ -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}})",
+3 -2
View File
@@ -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}})",
+3 -2
View File
@@ -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}})",
+3 -2
View File
@@ -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}})",
+3 -2
View File
@@ -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}})",
+3 -2
View File
@@ -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}})",
+10 -1
View File
@@ -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
}
}
+28 -2
View File
@@ -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', () => {
+35 -4
View File
@@ -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
}
}
+11 -3
View File
@@ -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', () => {
+3 -5
View File
@@ -74,11 +74,9 @@ 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 !== ''
? Number(data.distanceKm)
: undefined
data.distanceKm != null && data.distanceKm !== ''
? Number(data.distanceKm)
: undefined
return { extrema, timezone, placeName, distanceKm }
}