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:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user