feat: BSH-Pegelauswahl und Fix für Eintragstag beim Gezeiten-Abruf

Bei fehlgeschlagenem Auto-Abruf nächste BSH-Stationen anbieten; Reisetag
korrekt aus dem Eintrag parsen und Vergangenheitshinweis anzeigen.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-12 11:16:01 +02:00
parent 7d6c908f65
commit 4f519e34b4
18 changed files with 807 additions and 75 deletions
+62 -1
View File
@@ -2,7 +2,9 @@ import { Router } from 'express'
import { requireUser } from '../middleware/auth.js'
import {
fetchTidesForCoordinates,
fetchTidesForPlace
fetchTidesForPlace,
fetchTidesForStation,
listNearbyTideStations
} from '../utils/tideProvider.js'
const router = Router()
@@ -15,6 +17,61 @@ function parseLatLon(lat: unknown, lon: unknown): { lat: number; lon: number } |
return { lat: latNum, lon: lonNum }
}
function parseLimit(value: unknown, fallback = 8): number {
const n = Number(value)
if (Number.isNaN(n)) return fallback
return Math.min(20, Math.max(1, Math.floor(n)))
}
async function noTideDataResponse(lat: number, lon: number) {
const stations = await listNearbyTideStations(lat, lon, 8)
if (stations.length > 0) {
return { error: 'no_tide_data', stations }
}
return { error: 'no_tide_data' }
}
router.get('/stations/nearby', requireUser, async (req, res) => {
try {
const coords = parseLatLon(req.query.lat, req.query.lon)
if (!coords) {
return res.status(400).json({ error: 'lat and lon are required' })
}
const stations = await listNearbyTideStations(coords.lat, coords.lon, parseLimit(req.query.limit))
return res.json({ stations })
} catch (error: unknown) {
console.error('Error listing nearby tide stations:', error)
return res.status(502).json({ error: 'station_list_failed' })
}
})
router.get('/station/:stationId', requireUser, async (req, res) => {
try {
const stationId = String(req.params.stationId ?? '').trim()
if (!stationId) {
return res.status(400).json({ error: 'stationId is required' })
}
const coords = parseLatLon(req.query.lat, req.query.lon)
const data = await fetchTidesForStation(
stationId,
coords ? { queryLat: coords.lat, queryLon: coords.lon } : undefined
)
return res.json(data)
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Tide lookup failed'
if (message === 'bsh_invalid_station') {
return res.status(404).json({ error: 'station_not_found' })
}
if (message === 'no_tide_data') {
return res.status(404).json({ error: 'no_tide_data' })
}
console.error('Error fetching station tides:', error)
return res.status(502).json({ error: message })
}
})
router.get('/nearby', requireUser, async (req, res) => {
try {
const coords = parseLatLon(req.query.lat, req.query.lon)
@@ -27,6 +84,10 @@ router.get('/nearby', requireUser, async (req, res) => {
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Tide lookup failed'
if (message === 'no_tide_data') {
const coords = parseLatLon(req.query.lat, req.query.lon)
if (coords) {
return res.status(404).json(await noTideDataResponse(coords.lat, coords.lon))
}
return res.status(404).json({ error: 'no_tide_data' })
}
console.error('Error fetching nearby tides:', error)
+10
View File
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import {
findNearestBshStation,
findNearestBshStations,
haversineKm,
parseBshFeatureToExtrema,
parseBshHwnwForecast,
@@ -25,6 +26,15 @@ describe('haversineKm', () => {
})
})
describe('findNearestBshStations', () => {
it('returns multiple ranked stations', () => {
const nearest = findNearestBshStations(53.624526, 7.155263, stationIndex, 3)
expect(nearest).toHaveLength(3)
expect(nearest[0].id).toBe('norderney_riffgat')
expect(nearest[1].distanceKm).toBeGreaterThanOrEqual(nearest[0].distanceKm)
})
})
describe('findNearestBshStation', () => {
it('picks Norderney Riffgat for Norddeich coordinates', () => {
const nearest = findNearestBshStation(53.624526, 7.155263, stationIndex)
+106 -39
View File
@@ -99,21 +99,52 @@ export function haversineKm(lat1: number, lon1: number, lat2: number, lon2: numb
return 2 * R * Math.asin(Math.sqrt(a))
}
export interface BshStationSuggestion {
id: string
name: string
lat: number
lon: number
distanceKm: number
area?: string
}
export function findNearestBshStations(
lat: number,
lon: number,
stations: BshStation[],
limit = 8
): BshStationSuggestion[] {
const ranked = stations
.map((station) => ({
id: station.id,
name: station.name,
lat: station.lat,
lon: station.lon,
area: station.area,
distanceKm: Number(haversineKm(lat, lon, station.lat, station.lon).toFixed(1))
}))
.sort((a, b) => a.distanceKm - b.distanceKm)
return ranked.slice(0, Math.max(1, limit))
}
export function findNearestBshStation(
lat: number,
lon: number,
stations: BshStation[]
): { station: BshStation; distanceKm: number } | null {
if (stations.length === 0) return null
let best: { station: BshStation; distanceKm: number } | null = null
for (const station of stations) {
const distanceKm = haversineKm(lat, lon, station.lat, station.lon)
if (!best || distanceKm < best.distanceKm) {
best = { station, distanceKm }
}
const nearest = findNearestBshStations(lat, lon, stations, 1)[0]
if (!nearest) return null
return {
station: {
id: nearest.id,
name: nearest.name,
lat: nearest.lat,
lon: nearest.lon,
area: nearest.area
},
distanceKm: nearest.distanceKm
}
return best
}
export async function loadBshStationIndex(): Promise<BshStation[]> {
@@ -265,6 +296,71 @@ export interface BshTideLookupResult extends TideLookupResult {
distanceKm: number
}
export async function listNearbyBshStations(
lat: number,
lon: number,
limit = 8
): Promise<BshStationSuggestion[]> {
const stations = await loadBshStationIndex()
return findNearestBshStations(lat, lon, stations, limit)
}
function buildBshTideResult(
station: BshStation,
distanceKm: number,
feature: OgcFeature
): BshTideLookupResult {
const extrema = parseBshFeatureToExtrema(feature)
if (extrema.length === 0) {
throw new Error('no_tide_data')
}
const copyright = feature.properties?.copyright
let sourceNote = 'BSH Wasserstandsvorhersage (© BSH, CC BY 4.0)'
if (copyright && typeof copyright === 'object' && copyright !== null) {
const cr = copyright as Record<string, string>
sourceNote = cr.de || cr.en || sourceNote
}
return {
distanceKm: Number(distanceKm.toFixed(1)),
location: {
name: station.name,
lat: station.lat,
lon: station.lon,
source: 'bsh_station',
stationId: station.id
},
tides: {
data: {
timezone: BSH_TIMEZONE,
datum: 'gauge',
source: sourceNote,
extrema
}
}
}
}
export async function fetchBshTidesForStation(
stationId: string,
options?: { queryLat?: number; queryLon?: number }
): Promise<BshTideLookupResult> {
const stations = await loadBshStationIndex()
const station = stations.find((item) => item.id === stationId)
if (!station) {
throw new Error('bsh_invalid_station')
}
const feature = await fetchBshStationFeature(stationId)
const distanceKm =
options?.queryLat != null && options?.queryLon != null
? haversineKm(options.queryLat, options.queryLon, station.lat, station.lon)
: 0
return buildBshTideResult(station, distanceKm, feature)
}
export async function fetchBshTidesForCoordinates(
lat: number,
lon: number
@@ -282,34 +378,5 @@ export async function fetchBshTidesForCoordinates(
}
const feature = await fetchBshStationFeature(nearest.station.id)
const extrema = parseBshFeatureToExtrema(feature)
if (extrema.length === 0) {
throw new Error('no_tide_data')
}
const copyright = feature.properties?.copyright
let sourceNote = 'BSH Wasserstandsvorhersage (© BSH, CC BY 4.0)'
if (copyright && typeof copyright === 'object' && copyright !== null) {
const cr = copyright as Record<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
}
}
}
return buildBshTideResult(nearest.station, nearest.distanceKm, feature)
}
+35 -1
View File
@@ -1,4 +1,10 @@
import { fetchBshTidesForCoordinates, MAX_BSH_DISTANCE_KM } from './bshTides.js'
import {
fetchBshTidesForCoordinates,
fetchBshTidesForStation,
listNearbyBshStations,
MAX_BSH_DISTANCE_KM,
type BshStationSuggestion
} from './bshTides.js'
import {
fetchTidesForCoordinates as fetchOpenMeteoTidesForCoordinates,
fetchTidesForPlace as fetchOpenMeteoTidesForPlace,
@@ -43,6 +49,34 @@ export async function fetchTidesForCoordinates(
}
}
export async function listNearbyTideStations(
lat: number,
lon: number,
limit = 8
): Promise<BshStationSuggestion[]> {
try {
return await listNearbyBshStations(lat, lon, limit)
} catch {
return []
}
}
export async function fetchTidesForStation(
stationId: string,
options?: { queryLat?: number; queryLon?: number }
): Promise<TideProviderResult> {
try {
return await fetchBshTidesForStation(stationId, options)
} catch (error: unknown) {
const message = error instanceof Error ? error.message : ''
if (message === 'bsh_invalid_station' || message === 'no_tide_data') {
throw error
}
console.warn('BSH station tide lookup failed:', error)
throw new Error('no_tide_data')
}
}
export async function fetchTidesForPlace(query: string): Promise<TideProviderResult> {
const place = await geocodePlace(query)
if (!place) {