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:
@@ -3881,6 +3881,45 @@ html.theme-cupertino .events-scroll-container {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tide-station-picker__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: min(50vh, 320px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tide-station-picker__option {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--app-border, rgba(255, 255, 255, 0.12));
|
||||
border-radius: 10px;
|
||||
background: var(--app-surface-elevated, rgba(255, 255, 255, 0.04));
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tide-station-picker__option:hover {
|
||||
border-color: var(--app-accent, #2dd4bf);
|
||||
}
|
||||
|
||||
.tide-station-picker__name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tide-station-picker__meta {
|
||||
font-size: 13px;
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.live-log-sail-pills {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -58,13 +58,18 @@ const formatSpeedKn = (speedKn: number) =>
|
||||
formatAppDecimal(speedKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
||||
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
||||
import { fetchTidesByPlace, fetchTidesNearby, TidesApiError } from '../services/tides.js'
|
||||
import { TidesApiError, type TideStation } from '../services/tides.js'
|
||||
import { TideStationPickerModal } from './TideStationPickerModal.tsx'
|
||||
import {
|
||||
buildTideLocationMeta,
|
||||
formatTideLocationLabel,
|
||||
resolveTideFetchLocation
|
||||
} from '../utils/tideLocation.js'
|
||||
import { parseTideTurtleForDate } from '../utils/tideTurtle.js'
|
||||
import {
|
||||
fetchTidesForEntry,
|
||||
fetchTidesForStationChoice,
|
||||
type TideFetchNeedsStationPick
|
||||
} from '../utils/tideFetch.js'
|
||||
import {
|
||||
geolocationErrorI18nKey,
|
||||
getCurrentPosition,
|
||||
@@ -217,6 +222,7 @@ export default function LiveLogView({
|
||||
lowWater: string
|
||||
location: ReturnType<typeof buildTideLocationMeta>
|
||||
} | null>(null)
|
||||
const [tideStationPicker, setTideStationPicker] = useState<TideFetchNeedsStationPick | null>(null)
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||
const [commentText, setCommentText] = useState('')
|
||||
const [valueInput, setValueInput] = useState('')
|
||||
@@ -802,6 +808,38 @@ export default function LiveLogView({
|
||||
})()
|
||||
}
|
||||
|
||||
const handleTideStationPick = (pick: TideFetchNeedsStationPick, station: TideStation) => {
|
||||
setTidesLoading(true)
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await fetchTidesForStationChoice({
|
||||
stationId: station.id,
|
||||
entryDate: pick.entryDate,
|
||||
fetchLocation: pick.fetchLocation,
|
||||
queryLat: pick.queryLat,
|
||||
queryLng: pick.queryLng,
|
||||
analyticsSource: 'live_log'
|
||||
})
|
||||
setTideStationPicker(null)
|
||||
setTidePreview({
|
||||
highWater: result.highWater,
|
||||
lowWater: result.lowWater,
|
||||
location: result.location
|
||||
})
|
||||
setModal('tides')
|
||||
} catch (err) {
|
||||
if (err instanceof TidesApiError && err.code === 'NO_DATA_FOR_DATE') {
|
||||
void showAlert(t('logs.tide_no_data_for_date', { date: pick.entryDate }), t('logs.tides'))
|
||||
return
|
||||
}
|
||||
console.error('Live log tide station fetch failed:', err)
|
||||
void showAlert(t('logs.tide_fetch_failed'), t('logs.tides'))
|
||||
} finally {
|
||||
setTidesLoading(false)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
const handleFetchTides = () => {
|
||||
if (!entryId || busy || tidesLoading) return
|
||||
if (!isOnline) {
|
||||
@@ -835,24 +873,21 @@ export default function LiveLogView({
|
||||
return
|
||||
}
|
||||
|
||||
const data =
|
||||
location.mode === 'nearby'
|
||||
? await fetchTidesNearby(location.lat, location.lng, {
|
||||
analyticsSource: 'live_log',
|
||||
locationSource: location.source
|
||||
const outcome = await fetchTidesForEntry({
|
||||
fetchLocation: location,
|
||||
entryDate: entryDateForLocation,
|
||||
analyticsSource: 'live_log'
|
||||
})
|
||||
: await fetchTidesByPlace(location.query, { analyticsSource: 'live_log' })
|
||||
|
||||
const parsed = parseTideTurtleForDate(data, date)
|
||||
if (!parsed.highWater && !parsed.lowWater) {
|
||||
void showAlert(t('logs.tide_no_data'), t('logs.tides'))
|
||||
if (outcome.kind === 'pick_station') {
|
||||
setTideStationPicker(outcome)
|
||||
return
|
||||
}
|
||||
|
||||
setTidePreview({
|
||||
highWater: parsed.highWater,
|
||||
lowWater: parsed.lowWater,
|
||||
location: buildTideLocationMeta(location, data)
|
||||
highWater: outcome.highWater,
|
||||
lowWater: outcome.lowWater,
|
||||
location: outcome.location
|
||||
})
|
||||
setModal('tides')
|
||||
} catch (err) {
|
||||
@@ -865,6 +900,10 @@ export default function LiveLogView({
|
||||
void showAlert(t('logs.tide_place_not_found', { place: departure.trim() }), t('logs.tides'))
|
||||
return
|
||||
}
|
||||
if (err.code === 'NO_DATA_FOR_DATE') {
|
||||
void showAlert(t('logs.tide_no_data_for_date', { date }), t('logs.tides'))
|
||||
return
|
||||
}
|
||||
if (err.code === 'NOT_FOUND') {
|
||||
void showAlert(t('logs.tide_no_data'), t('logs.tides'))
|
||||
return
|
||||
@@ -1574,6 +1613,17 @@ export default function LiveLogView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tideStationPicker ? (
|
||||
<TideStationPickerModal
|
||||
title={t('logs.tide_pick_station_title')}
|
||||
hint={t('logs.tide_pick_station_hint')}
|
||||
cancelLabel={t('logs.live_cancel')}
|
||||
stations={tideStationPicker.stations}
|
||||
onCancel={() => setTideStationPicker(null)}
|
||||
onSelect={(station) => handleTideStationPick(tideStationPicker, station)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{modal === 'tides' && tidePreview && (
|
||||
<div
|
||||
className="live-log-modal-backdrop"
|
||||
|
||||
@@ -43,15 +43,19 @@ import { putEntryRecord } from '../utils/entryListCache.js'
|
||||
import { getLogbookAccess } from '../services/logbookAccess.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
||||
import { fetchTidesByPlace, fetchTidesNearby, TidesApiError } from '../services/tides.js'
|
||||
import { TidesApiError, type TideStation } from '../services/tides.js'
|
||||
import { TideStationPickerModal } from './TideStationPickerModal.tsx'
|
||||
import {
|
||||
buildTideLocationMeta,
|
||||
formatTideLocationLabel,
|
||||
pickTideLocationMeta,
|
||||
resolveTideFetchLocation,
|
||||
type TideLocationMeta
|
||||
} from '../utils/tideLocation.js'
|
||||
import { parseTideTurtleForDate } from '../utils/tideTurtle.js'
|
||||
import {
|
||||
fetchTidesForEntry,
|
||||
fetchTidesForStationChoice,
|
||||
type TideFetchNeedsStationPick
|
||||
} from '../utils/tideFetch.js'
|
||||
import {
|
||||
buildTravelDayContext,
|
||||
fetchTravelDaySummaryUsage,
|
||||
@@ -313,6 +317,7 @@ export default function LogEntryEditor({
|
||||
const [tideLowWater, setTideLowWater] = useState('')
|
||||
const [tideLocation, setTideLocation] = useState<TideLocationMeta>({})
|
||||
const [tidesLoading, setTidesLoading] = useState(false)
|
||||
const [tideStationPicker, setTideStationPicker] = useState<TideFetchNeedsStationPick | null>(null)
|
||||
const [tanksCollapsed, setTanksCollapsed] = useState(true)
|
||||
|
||||
const [columnSelectorOpen, setColumnSelectorOpen] = useState(false)
|
||||
@@ -1303,6 +1308,41 @@ export default function LogEntryEditor({
|
||||
}
|
||||
}
|
||||
|
||||
const applyTideFetchResult = (result: {
|
||||
highWater: string
|
||||
lowWater: string
|
||||
location: TideLocationMeta
|
||||
}) => {
|
||||
if (result.highWater) setTideHighWater(result.highWater)
|
||||
if (result.lowWater) setTideLowWater(result.lowWater)
|
||||
setTideLocation(result.location)
|
||||
}
|
||||
|
||||
const handleTideStationPick = async (pick: TideFetchNeedsStationPick, station: TideStation) => {
|
||||
setTidesLoading(true)
|
||||
try {
|
||||
const result = await fetchTidesForStationChoice({
|
||||
stationId: station.id,
|
||||
entryDate: pick.entryDate,
|
||||
fetchLocation: pick.fetchLocation,
|
||||
queryLat: pick.queryLat,
|
||||
queryLng: pick.queryLng,
|
||||
analyticsSource: 'entry_editor'
|
||||
})
|
||||
applyTideFetchResult(result)
|
||||
setTideStationPicker(null)
|
||||
} catch (err) {
|
||||
if (err instanceof TidesApiError && err.code === 'NO_DATA_FOR_DATE') {
|
||||
showAlert(t('logs.tide_no_data_for_date', { date: pick.entryDate }), t('logs.tide_fetch_btn'))
|
||||
return
|
||||
}
|
||||
console.error('Tide station fetch failed:', err)
|
||||
showAlert(t('logs.tide_fetch_failed'), t('logs.tide_fetch_btn'))
|
||||
} finally {
|
||||
setTidesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFetchTides = async () => {
|
||||
if (!isOnline) {
|
||||
showAlert(t('logs.weather_offline'), t('logs.tide_fetch_btn'))
|
||||
@@ -1332,23 +1372,18 @@ export default function LogEntryEditor({
|
||||
return
|
||||
}
|
||||
|
||||
const data =
|
||||
location.mode === 'nearby'
|
||||
? await fetchTidesNearby(location.lat, location.lng, {
|
||||
analyticsSource: 'entry_editor',
|
||||
locationSource: location.source
|
||||
const outcome = await fetchTidesForEntry({
|
||||
fetchLocation: location,
|
||||
entryDate: entryDateForLocation,
|
||||
analyticsSource: 'entry_editor'
|
||||
})
|
||||
: await fetchTidesByPlace(location.query, { analyticsSource: 'entry_editor' })
|
||||
|
||||
const parsed = parseTideTurtleForDate(data, date)
|
||||
if (!parsed.highWater && !parsed.lowWater) {
|
||||
showAlert(t('logs.tide_no_data'), t('logs.tide_fetch_btn'))
|
||||
if (outcome.kind === 'pick_station') {
|
||||
setTideStationPicker(outcome)
|
||||
return
|
||||
}
|
||||
|
||||
if (parsed.highWater) setTideHighWater(parsed.highWater)
|
||||
if (parsed.lowWater) setTideLowWater(parsed.lowWater)
|
||||
setTideLocation(buildTideLocationMeta(location, data))
|
||||
applyTideFetchResult(outcome)
|
||||
} catch (err) {
|
||||
if (err instanceof TidesApiError) {
|
||||
if (err.code === 'OFFLINE') {
|
||||
@@ -1359,6 +1394,10 @@ export default function LogEntryEditor({
|
||||
showAlert(t('logs.tide_place_not_found', { place: departure.trim() }), t('logs.tide_fetch_btn'))
|
||||
return
|
||||
}
|
||||
if (err.code === 'NO_DATA_FOR_DATE') {
|
||||
showAlert(t('logs.tide_no_data_for_date', { date }), t('logs.tide_fetch_btn'))
|
||||
return
|
||||
}
|
||||
if (err.code === 'NOT_FOUND') {
|
||||
showAlert(t('logs.tide_no_data'), t('logs.tide_fetch_btn'))
|
||||
return
|
||||
@@ -3101,6 +3140,19 @@ export default function LogEntryEditor({
|
||||
nmeaArchive={nmeaArchive}
|
||||
onImport={handleNmeaImport}
|
||||
/>
|
||||
|
||||
{tideStationPicker ? (
|
||||
<TideStationPickerModal
|
||||
title={t('logs.tide_pick_station_title')}
|
||||
hint={t('logs.tide_pick_station_hint')}
|
||||
cancelLabel={t('logs.live_cancel')}
|
||||
stations={tideStationPicker.stations}
|
||||
onCancel={() => setTideStationPicker(null)}
|
||||
onSelect={(station) => {
|
||||
void handleTideStationPick(tideStationPicker, station)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { TideStation } from '../services/tides.js'
|
||||
|
||||
type TideStationPickerModalProps = {
|
||||
title: string
|
||||
hint: string
|
||||
cancelLabel: string
|
||||
stations: TideStation[]
|
||||
onSelect: (station: TideStation) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function TideStationPickerModal({
|
||||
title,
|
||||
hint,
|
||||
cancelLabel,
|
||||
stations,
|
||||
onSelect,
|
||||
onCancel
|
||||
}: TideStationPickerModalProps) {
|
||||
return (
|
||||
<div
|
||||
className="live-log-modal-backdrop"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onCancel()
|
||||
}}
|
||||
>
|
||||
<div className="live-log-modal tide-station-picker" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{title}</h3>
|
||||
<p className="live-log-modal-hint" role="note">
|
||||
{hint}
|
||||
</p>
|
||||
<ul className="tide-station-picker__list">
|
||||
{stations.map((station) => (
|
||||
<li key={station.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="tide-station-picker__option"
|
||||
onClick={() => onSelect(station)}
|
||||
>
|
||||
<span className="tide-station-picker__name">{station.name}</span>
|
||||
<span className="tide-station-picker__meta">
|
||||
{station.distanceKm} km
|
||||
{station.area ? ` · ${station.area}` : ''}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="live-log-modal-actions">
|
||||
<button type="button" className="btn secondary" onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -200,6 +200,9 @@
|
||||
"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_no_data_for_date": "Ingen BSH-prognose for rejsedagen {{date}} (kun fremtidige tidspunkter).",
|
||||
"tide_pick_station_title": "Vælg tidevandsmåler",
|
||||
"tide_pick_station_hint": "Vælg den nærmeste BSH-måler for tidevandsprognosen.",
|
||||
"tide_place_not_found": "“{{place}}” kunne ikke findes — angiv en kystby eller havn.",
|
||||
"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.",
|
||||
|
||||
@@ -200,6 +200,9 @@
|
||||
"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_no_data_for_date": "Für den Reisetag {{date}} liegt keine BSH-Vorhersage vor (nur zukünftige Termine).",
|
||||
"tide_pick_station_title": "Pegel auswählen",
|
||||
"tide_pick_station_hint": "Wähle den nächstgelegenen BSH-Pegel für die Gezeiten-Vorhersage.",
|
||||
"tide_place_not_found": "„{{place}}“ konnte nicht geortet werden — bitte einen Küstenort oder Hafen angeben.",
|
||||
"tide_fetched_at_position": "Amtliche BSH-Vorhersage vom nächstgelegenen Pegel.",
|
||||
"tide_open_meteo_fallback": "Modellprognose (Open-Meteo) — keine BSH-Station in Reichweite.",
|
||||
|
||||
@@ -200,6 +200,9 @@
|
||||
"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_no_data_for_date": "No BSH forecast for travel day {{date}} (future dates only).",
|
||||
"tide_pick_station_title": "Select tide gauge",
|
||||
"tide_pick_station_hint": "Choose the nearest BSH gauge for the tide forecast.",
|
||||
"tide_place_not_found": "“{{place}}” could not be geocoded — please use a coastal place or harbour name.",
|
||||
"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.",
|
||||
|
||||
@@ -200,6 +200,9 @@
|
||||
"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_no_data_for_date": "Sin pronóstico BSH para el día de viaje {{date}} (solo fechas futuras).",
|
||||
"tide_pick_station_title": "Elegir marégrafo",
|
||||
"tide_pick_station_hint": "Elige el marégrafo BSH más cercano para el pronóstico de mareas.",
|
||||
"tide_place_not_found": "«{{place}}» no se encontró — indica un lugar costero o puerto.",
|
||||
"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.",
|
||||
|
||||
@@ -200,6 +200,9 @@
|
||||
"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_no_data_for_date": "Pas de prévision BSH pour le jour de voyage {{date}} (dates futures uniquement).",
|
||||
"tide_pick_station_title": "Choisir un marégraphe",
|
||||
"tide_pick_station_hint": "Choisissez le marégraphe BSH le plus proche pour la prévision.",
|
||||
"tide_place_not_found": "« {{place}} » introuvable — indiquez un lieu côtier ou un port.",
|
||||
"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é.",
|
||||
|
||||
@@ -200,6 +200,9 @@
|
||||
"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_no_data_for_date": "Ingen BSH-prognose for reisedagen {{date}} (kun fremtidige tidspunkter).",
|
||||
"tide_pick_station_title": "Velg tidevannsmåler",
|
||||
"tide_pick_station_hint": "Velg nærmeste BSH-måler for tidevannsprognosen.",
|
||||
"tide_place_not_found": "«{{place}}» ble ikke funnet — oppgi en kyststad eller havn.",
|
||||
"tide_fetched_at_position": "Offisiell BSH-prognose fra nærmeste tidevannsmåler.",
|
||||
"tide_open_meteo_fallback": "Modellprognose (Open-Meteo) — ingen BSH-stasjon innen rekkevidde.",
|
||||
|
||||
@@ -200,6 +200,9 @@
|
||||
"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_no_data_for_date": "Ingen BSH-prognos för resedagen {{date}} (endast framtida tider).",
|
||||
"tide_pick_station_title": "Välj tidvattensmätare",
|
||||
"tide_pick_station_hint": "Välj närmaste BSH-mätare för tidvattenprognosen.",
|
||||
"tide_place_not_found": "“{{place}}” kunde inte hittas — ange en kustort eller hamn.",
|
||||
"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.",
|
||||
|
||||
@@ -5,21 +5,71 @@ import {
|
||||
trackPlausibleEvent
|
||||
} from './analytics.js'
|
||||
|
||||
export interface TideStation {
|
||||
id: string
|
||||
name: string
|
||||
lat: number
|
||||
lon: number
|
||||
distanceKm: number
|
||||
area?: string
|
||||
}
|
||||
|
||||
export class TidesApiError extends Error {
|
||||
code: 'OFFLINE' | 'NOT_FOUND' | 'PLACE_NOT_FOUND' | 'BAD_REQUEST' | 'REQUEST_FAILED'
|
||||
code:
|
||||
| 'OFFLINE'
|
||||
| 'NOT_FOUND'
|
||||
| 'NO_DATA_FOR_DATE'
|
||||
| 'PLACE_NOT_FOUND'
|
||||
| 'BAD_REQUEST'
|
||||
| 'REQUEST_FAILED'
|
||||
stations?: TideStation[]
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
code: 'OFFLINE' | 'NOT_FOUND' | 'PLACE_NOT_FOUND' | 'BAD_REQUEST' | 'REQUEST_FAILED' = 'REQUEST_FAILED'
|
||||
code:
|
||||
| 'OFFLINE'
|
||||
| 'NOT_FOUND'
|
||||
| 'NO_DATA_FOR_DATE'
|
||||
| 'PLACE_NOT_FOUND'
|
||||
| 'BAD_REQUEST'
|
||||
| 'REQUEST_FAILED' = 'REQUEST_FAILED',
|
||||
stations?: TideStation[]
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'TidesApiError'
|
||||
this.code = code
|
||||
this.stations = stations
|
||||
}
|
||||
}
|
||||
|
||||
const TIDES_FETCH_TIMEOUT_MS = 20_000
|
||||
|
||||
function readStations(data: Record<string, unknown>): TideStation[] | undefined {
|
||||
if (!Array.isArray(data.stations)) return undefined
|
||||
const stations: TideStation[] = []
|
||||
for (const item of data.stations) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
const row = item as Record<string, unknown>
|
||||
const id = String(row.id ?? '').trim()
|
||||
const name = String(row.name ?? '').trim()
|
||||
const lat = Number(row.lat)
|
||||
const lon = Number(row.lon)
|
||||
const distanceKm = Number(row.distanceKm)
|
||||
if (!id || !name || Number.isNaN(lat) || Number.isNaN(lon) || Number.isNaN(distanceKm)) {
|
||||
continue
|
||||
}
|
||||
stations.push({
|
||||
id,
|
||||
name,
|
||||
lat,
|
||||
lon,
|
||||
distanceKm,
|
||||
area: row.area ? String(row.area) : undefined
|
||||
})
|
||||
}
|
||||
return stations.length > 0 ? stations : undefined
|
||||
}
|
||||
|
||||
async function fetchTides(path: string): Promise<Record<string, unknown>> {
|
||||
if (!navigator.onLine) {
|
||||
throw new TidesApiError('Offline', 'OFFLINE')
|
||||
@@ -44,11 +94,12 @@ async function fetchTides(path: string): Promise<Record<string, unknown>> {
|
||||
throw new TidesApiError('Invalid tide request parameters', 'BAD_REQUEST')
|
||||
}
|
||||
if (res.status === 404) {
|
||||
const stations = readStations(data as Record<string, unknown>)
|
||||
const code =
|
||||
typeof data?.error === 'string' && data.error === 'place_not_found'
|
||||
? 'PLACE_NOT_FOUND'
|
||||
: 'NOT_FOUND'
|
||||
throw new TidesApiError('Tide data not found', code)
|
||||
throw new TidesApiError('Tide data not found', code, stations)
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new TidesApiError(
|
||||
@@ -59,6 +110,16 @@ async function fetchTides(path: string): Promise<Record<string, unknown>> {
|
||||
return data as Record<string, unknown>
|
||||
}
|
||||
|
||||
export async function fetchNearbyTideStations(
|
||||
lat: string,
|
||||
lon: string,
|
||||
limit = 8
|
||||
): Promise<TideStation[]> {
|
||||
const searchParams = new URLSearchParams({ lat, lon, limit: String(limit) })
|
||||
const data = await fetchTides(`/api/tides/stations/nearby?${searchParams.toString()}`)
|
||||
return readStations(data) ?? []
|
||||
}
|
||||
|
||||
export async function fetchTidesNearby(
|
||||
lat: string,
|
||||
lon: string,
|
||||
@@ -75,6 +136,28 @@ export async function fetchTidesNearby(
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchTidesByStation(
|
||||
stationId: string,
|
||||
options?: {
|
||||
queryLat?: string
|
||||
queryLng?: string
|
||||
analyticsSource?: TideAnalyticsSource
|
||||
}
|
||||
): Promise<Record<string, unknown>> {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (options?.queryLat) searchParams.set('lat', options.queryLat)
|
||||
if (options?.queryLng) searchParams.set('lon', options.queryLng)
|
||||
const suffix = searchParams.toString() ? `?${searchParams.toString()}` : ''
|
||||
const data = await fetchTides(`/api/tides/station/${encodeURIComponent(stationId)}${suffix}`)
|
||||
if (options?.analyticsSource) {
|
||||
trackPlausibleEvent(PlausibleEvents.TIDE_FETCHED, {
|
||||
source: options.analyticsSource,
|
||||
location_source: 'gps'
|
||||
})
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchTidesByPlace(
|
||||
placeQuery: string,
|
||||
options?: { analyticsSource?: TideAnalyticsSource }
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import * as tidesService from '../services/tides.js'
|
||||
import { fetchTidesForEntry } from './tideFetch.js'
|
||||
|
||||
describe('fetchTidesForEntry', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns tide times when nearby fetch succeeds for entry date', async () => {
|
||||
vi.spyOn(tidesService, 'fetchTidesNearby').mockResolvedValue({
|
||||
distanceKm: 8,
|
||||
location: { name: 'Norderney, Riffgat', source: 'bsh_station' },
|
||||
tides: {
|
||||
data: {
|
||||
timezone: 'Europe/Berlin',
|
||||
extrema: [
|
||||
{
|
||||
time: '2026-06-12T07:20:00.000Z',
|
||||
date: '2026-06-12',
|
||||
height: 6.16,
|
||||
isHigh: true
|
||||
},
|
||||
{
|
||||
time: '2026-06-12T13:39:00.000Z',
|
||||
date: '2026-06-12',
|
||||
height: 4.03,
|
||||
isHigh: false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const outcome = await fetchTidesForEntry({
|
||||
fetchLocation: { mode: 'nearby', lat: '53.624526', lng: '7.155263', source: 'gps' },
|
||||
entryDate: '2026-06-12',
|
||||
analyticsSource: 'entry_editor'
|
||||
})
|
||||
|
||||
expect(outcome).toMatchObject({
|
||||
highWater: '09:20',
|
||||
lowWater: '15:39'
|
||||
})
|
||||
})
|
||||
|
||||
it('offers station picker when fetch succeeds but entry date has no extrema', async () => {
|
||||
vi.spyOn(tidesService, 'fetchTidesNearby').mockResolvedValue({
|
||||
tides: {
|
||||
data: {
|
||||
timezone: 'Europe/Berlin',
|
||||
extrema: [
|
||||
{
|
||||
time: '2026-06-12T07:20:00.000Z',
|
||||
date: '2026-06-12',
|
||||
height: 6.16,
|
||||
isHigh: true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
fetchTidesForEntry({
|
||||
fetchLocation: { mode: 'nearby', lat: '53.62', lng: '7.15', source: 'gps' },
|
||||
entryDate: '2026-06-01',
|
||||
analyticsSource: 'entry_editor'
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'NO_DATA_FOR_DATE' })
|
||||
})
|
||||
|
||||
it('offers station picker when nearby fetch returns not found', async () => {
|
||||
vi.spyOn(tidesService, 'fetchTidesNearby').mockRejectedValue(
|
||||
new tidesService.TidesApiError('Tide data not found', 'NOT_FOUND', [
|
||||
{
|
||||
id: 'norderney_riffgat',
|
||||
name: 'Norderney, Riffgat',
|
||||
lat: 53.69,
|
||||
lon: 7.15,
|
||||
distanceKm: 8
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const outcome = await fetchTidesForEntry({
|
||||
fetchLocation: { mode: 'nearby', lat: '53.624526', lng: '7.155263', source: 'gps' },
|
||||
entryDate: '2026-06-12',
|
||||
analyticsSource: 'entry_editor'
|
||||
})
|
||||
|
||||
expect(outcome).toEqual({
|
||||
kind: 'pick_station',
|
||||
entryDate: '2026-06-12',
|
||||
fetchLocation: { mode: 'nearby', lat: '53.624526', lng: '7.155263', source: 'gps' },
|
||||
stations: [
|
||||
{
|
||||
id: 'norderney_riffgat',
|
||||
name: 'Norderney, Riffgat',
|
||||
lat: 53.69,
|
||||
lon: 7.15,
|
||||
distanceKm: 8
|
||||
}
|
||||
],
|
||||
lat: '53.624526',
|
||||
lng: '7.155263'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
fetchNearbyTideStations,
|
||||
fetchTidesByPlace,
|
||||
fetchTidesByStation,
|
||||
fetchTidesNearby,
|
||||
type TideStation,
|
||||
TidesApiError
|
||||
} from '../services/tides.js'
|
||||
import type { TideFetchLocation } from './tideLocation.js'
|
||||
import { buildTideLocationMeta, type TideLocationMeta } from './tideLocation.js'
|
||||
import { extractTideTurtlePayload, parseTideTurtleForDate } from './tideTurtle.js'
|
||||
|
||||
export type TideFetchResult = {
|
||||
highWater: string
|
||||
lowWater: string
|
||||
location: TideLocationMeta
|
||||
apiData: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type TideFetchNeedsStationPick = {
|
||||
kind: 'pick_station'
|
||||
entryDate: string
|
||||
fetchLocation: TideFetchLocation
|
||||
stations: TideStation[]
|
||||
queryLat?: string
|
||||
queryLng?: string
|
||||
}
|
||||
|
||||
export type TideFetchOutcome = TideFetchResult | TideFetchNeedsStationPick
|
||||
|
||||
function readQueryCoords(fetchLocation: TideFetchLocation): { lat?: string; lng?: string } {
|
||||
if (fetchLocation.mode === 'nearby') {
|
||||
return { lat: fetchLocation.lat, lng: fetchLocation.lng }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function hasTideTimesForDate(data: Record<string, unknown>, entryDate: string): boolean {
|
||||
const parsed = parseTideTurtleForDate(data, entryDate)
|
||||
return Boolean(parsed.highWater || parsed.lowWater)
|
||||
}
|
||||
|
||||
function toResult(
|
||||
data: Record<string, unknown>,
|
||||
entryDate: string,
|
||||
fetchLocation: TideFetchLocation
|
||||
): TideFetchResult | null {
|
||||
const parsed = parseTideTurtleForDate(data, entryDate)
|
||||
if (!parsed.highWater && !parsed.lowWater) return null
|
||||
return {
|
||||
highWater: parsed.highWater,
|
||||
lowWater: parsed.lowWater,
|
||||
location: buildTideLocationMeta(fetchLocation, data),
|
||||
apiData: data
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNearbyStations(
|
||||
fetchLocation: TideFetchLocation,
|
||||
stationsFromError?: TideStation[]
|
||||
): Promise<TideStation[]> {
|
||||
if (stationsFromError && stationsFromError.length > 0) {
|
||||
return stationsFromError
|
||||
}
|
||||
if (fetchLocation.mode !== 'nearby') return []
|
||||
return fetchNearbyTideStations(fetchLocation.lat, fetchLocation.lng)
|
||||
}
|
||||
|
||||
export async function fetchTidesForEntry(options: {
|
||||
fetchLocation: TideFetchLocation
|
||||
entryDate: string
|
||||
analyticsSource: 'entry_editor' | 'live_log'
|
||||
}): Promise<TideFetchOutcome> {
|
||||
const { fetchLocation, entryDate, analyticsSource } = options
|
||||
const queryCoords = readQueryCoords(fetchLocation)
|
||||
let stationsFromError: TideStation[] | undefined
|
||||
|
||||
try {
|
||||
const data =
|
||||
fetchLocation.mode === 'nearby'
|
||||
? await fetchTidesNearby(fetchLocation.lat, fetchLocation.lng, {
|
||||
analyticsSource,
|
||||
locationSource: fetchLocation.source
|
||||
})
|
||||
: await fetchTidesByPlace(fetchLocation.query, { analyticsSource })
|
||||
|
||||
const result = toResult(data, entryDate, fetchLocation)
|
||||
if (result) return result
|
||||
|
||||
const { extrema } = extractTideTurtlePayload(data)
|
||||
if (extrema.length > 0) {
|
||||
throw new TidesApiError('No tide data for entry date', 'NO_DATA_FOR_DATE')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof TidesApiError && error.code === 'NO_DATA_FOR_DATE') {
|
||||
throw error
|
||||
}
|
||||
if (error instanceof TidesApiError && error.stations?.length) {
|
||||
stationsFromError = error.stations
|
||||
} else if (!(error instanceof TidesApiError) || error.code !== 'NOT_FOUND') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const stations = await loadNearbyStations(fetchLocation, stationsFromError)
|
||||
if (stations.length > 0) {
|
||||
return {
|
||||
kind: 'pick_station',
|
||||
entryDate,
|
||||
fetchLocation,
|
||||
stations,
|
||||
...queryCoords
|
||||
}
|
||||
}
|
||||
|
||||
throw new TidesApiError('Tide data not found', 'NOT_FOUND')
|
||||
}
|
||||
|
||||
export async function fetchTidesForStationChoice(options: {
|
||||
stationId: string
|
||||
entryDate: string
|
||||
fetchLocation: TideFetchLocation
|
||||
queryLat?: string
|
||||
queryLng?: string
|
||||
analyticsSource: 'entry_editor' | 'live_log'
|
||||
}): Promise<TideFetchResult> {
|
||||
const data = await fetchTidesByStation(options.stationId, {
|
||||
queryLat: options.queryLat,
|
||||
queryLng: options.queryLng,
|
||||
analyticsSource: options.analyticsSource
|
||||
})
|
||||
|
||||
const result = toResult(data, options.entryDate, options.fetchLocation)
|
||||
if (!result) {
|
||||
throw new TidesApiError('Tide data not found', 'NOT_FOUND')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function tideDataHasForecastForDate(
|
||||
data: Record<string, unknown>,
|
||||
entryDate: string
|
||||
): boolean {
|
||||
return hasTideTimesForDate(data, entryDate)
|
||||
}
|
||||
@@ -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