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,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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user