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;
|
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 {
|
.live-log-sail-pills {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,13 +58,18 @@ const formatSpeedKn = (speedKn: number) =>
|
|||||||
formatAppDecimal(speedKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
formatAppDecimal(speedKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||||
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
||||||
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.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 {
|
import {
|
||||||
buildTideLocationMeta,
|
buildTideLocationMeta,
|
||||||
formatTideLocationLabel,
|
formatTideLocationLabel,
|
||||||
resolveTideFetchLocation
|
resolveTideFetchLocation
|
||||||
} from '../utils/tideLocation.js'
|
} from '../utils/tideLocation.js'
|
||||||
import { parseTideTurtleForDate } from '../utils/tideTurtle.js'
|
import {
|
||||||
|
fetchTidesForEntry,
|
||||||
|
fetchTidesForStationChoice,
|
||||||
|
type TideFetchNeedsStationPick
|
||||||
|
} from '../utils/tideFetch.js'
|
||||||
import {
|
import {
|
||||||
geolocationErrorI18nKey,
|
geolocationErrorI18nKey,
|
||||||
getCurrentPosition,
|
getCurrentPosition,
|
||||||
@@ -217,6 +222,7 @@ export default function LiveLogView({
|
|||||||
lowWater: string
|
lowWater: string
|
||||||
location: ReturnType<typeof buildTideLocationMeta>
|
location: ReturnType<typeof buildTideLocationMeta>
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
const [tideStationPicker, setTideStationPicker] = useState<TideFetchNeedsStationPick | null>(null)
|
||||||
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||||
const [commentText, setCommentText] = useState('')
|
const [commentText, setCommentText] = useState('')
|
||||||
const [valueInput, setValueInput] = 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 = () => {
|
const handleFetchTides = () => {
|
||||||
if (!entryId || busy || tidesLoading) return
|
if (!entryId || busy || tidesLoading) return
|
||||||
if (!isOnline) {
|
if (!isOnline) {
|
||||||
@@ -835,24 +873,21 @@ export default function LiveLogView({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const data =
|
const outcome = await fetchTidesForEntry({
|
||||||
location.mode === 'nearby'
|
fetchLocation: location,
|
||||||
? await fetchTidesNearby(location.lat, location.lng, {
|
entryDate: entryDateForLocation,
|
||||||
analyticsSource: 'live_log',
|
analyticsSource: 'live_log'
|
||||||
locationSource: location.source
|
})
|
||||||
})
|
|
||||||
: await fetchTidesByPlace(location.query, { analyticsSource: 'live_log' })
|
|
||||||
|
|
||||||
const parsed = parseTideTurtleForDate(data, date)
|
if (outcome.kind === 'pick_station') {
|
||||||
if (!parsed.highWater && !parsed.lowWater) {
|
setTideStationPicker(outcome)
|
||||||
void showAlert(t('logs.tide_no_data'), t('logs.tides'))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setTidePreview({
|
setTidePreview({
|
||||||
highWater: parsed.highWater,
|
highWater: outcome.highWater,
|
||||||
lowWater: parsed.lowWater,
|
lowWater: outcome.lowWater,
|
||||||
location: buildTideLocationMeta(location, data)
|
location: outcome.location
|
||||||
})
|
})
|
||||||
setModal('tides')
|
setModal('tides')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -865,6 +900,10 @@ export default function LiveLogView({
|
|||||||
void showAlert(t('logs.tide_place_not_found', { place: departure.trim() }), t('logs.tides'))
|
void showAlert(t('logs.tide_place_not_found', { place: departure.trim() }), t('logs.tides'))
|
||||||
return
|
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') {
|
if (err.code === 'NOT_FOUND') {
|
||||||
void showAlert(t('logs.tide_no_data'), t('logs.tides'))
|
void showAlert(t('logs.tide_no_data'), t('logs.tides'))
|
||||||
return
|
return
|
||||||
@@ -1574,6 +1613,17 @@ export default function LiveLogView({
|
|||||||
</div>
|
</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 && (
|
{modal === 'tides' && tidePreview && (
|
||||||
<div
|
<div
|
||||||
className="live-log-modal-backdrop"
|
className="live-log-modal-backdrop"
|
||||||
|
|||||||
@@ -43,15 +43,19 @@ import { putEntryRecord } from '../utils/entryListCache.js'
|
|||||||
import { getLogbookAccess } from '../services/logbookAccess.js'
|
import { getLogbookAccess } from '../services/logbookAccess.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.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 {
|
import {
|
||||||
buildTideLocationMeta,
|
|
||||||
formatTideLocationLabel,
|
formatTideLocationLabel,
|
||||||
pickTideLocationMeta,
|
pickTideLocationMeta,
|
||||||
resolveTideFetchLocation,
|
resolveTideFetchLocation,
|
||||||
type TideLocationMeta
|
type TideLocationMeta
|
||||||
} from '../utils/tideLocation.js'
|
} from '../utils/tideLocation.js'
|
||||||
import { parseTideTurtleForDate } from '../utils/tideTurtle.js'
|
import {
|
||||||
|
fetchTidesForEntry,
|
||||||
|
fetchTidesForStationChoice,
|
||||||
|
type TideFetchNeedsStationPick
|
||||||
|
} from '../utils/tideFetch.js'
|
||||||
import {
|
import {
|
||||||
buildTravelDayContext,
|
buildTravelDayContext,
|
||||||
fetchTravelDaySummaryUsage,
|
fetchTravelDaySummaryUsage,
|
||||||
@@ -313,6 +317,7 @@ export default function LogEntryEditor({
|
|||||||
const [tideLowWater, setTideLowWater] = useState('')
|
const [tideLowWater, setTideLowWater] = useState('')
|
||||||
const [tideLocation, setTideLocation] = useState<TideLocationMeta>({})
|
const [tideLocation, setTideLocation] = useState<TideLocationMeta>({})
|
||||||
const [tidesLoading, setTidesLoading] = useState(false)
|
const [tidesLoading, setTidesLoading] = useState(false)
|
||||||
|
const [tideStationPicker, setTideStationPicker] = useState<TideFetchNeedsStationPick | null>(null)
|
||||||
const [tanksCollapsed, setTanksCollapsed] = useState(true)
|
const [tanksCollapsed, setTanksCollapsed] = useState(true)
|
||||||
|
|
||||||
const [columnSelectorOpen, setColumnSelectorOpen] = useState(false)
|
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 () => {
|
const handleFetchTides = async () => {
|
||||||
if (!isOnline) {
|
if (!isOnline) {
|
||||||
showAlert(t('logs.weather_offline'), t('logs.tide_fetch_btn'))
|
showAlert(t('logs.weather_offline'), t('logs.tide_fetch_btn'))
|
||||||
@@ -1332,23 +1372,18 @@ export default function LogEntryEditor({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const data =
|
const outcome = await fetchTidesForEntry({
|
||||||
location.mode === 'nearby'
|
fetchLocation: location,
|
||||||
? await fetchTidesNearby(location.lat, location.lng, {
|
entryDate: entryDateForLocation,
|
||||||
analyticsSource: 'entry_editor',
|
analyticsSource: 'entry_editor'
|
||||||
locationSource: location.source
|
})
|
||||||
})
|
|
||||||
: await fetchTidesByPlace(location.query, { analyticsSource: 'entry_editor' })
|
|
||||||
|
|
||||||
const parsed = parseTideTurtleForDate(data, date)
|
if (outcome.kind === 'pick_station') {
|
||||||
if (!parsed.highWater && !parsed.lowWater) {
|
setTideStationPicker(outcome)
|
||||||
showAlert(t('logs.tide_no_data'), t('logs.tide_fetch_btn'))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.highWater) setTideHighWater(parsed.highWater)
|
applyTideFetchResult(outcome)
|
||||||
if (parsed.lowWater) setTideLowWater(parsed.lowWater)
|
|
||||||
setTideLocation(buildTideLocationMeta(location, data))
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof TidesApiError) {
|
if (err instanceof TidesApiError) {
|
||||||
if (err.code === 'OFFLINE') {
|
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'))
|
showAlert(t('logs.tide_place_not_found', { place: departure.trim() }), t('logs.tide_fetch_btn'))
|
||||||
return
|
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') {
|
if (err.code === 'NOT_FOUND') {
|
||||||
showAlert(t('logs.tide_no_data'), t('logs.tide_fetch_btn'))
|
showAlert(t('logs.tide_no_data'), t('logs.tide_fetch_btn'))
|
||||||
return
|
return
|
||||||
@@ -3101,6 +3140,19 @@ export default function LogEntryEditor({
|
|||||||
nmeaArchive={nmeaArchive}
|
nmeaArchive={nmeaArchive}
|
||||||
onImport={handleNmeaImport}
|
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>
|
</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_position_stale": "Den sidste position er ældre end 2 timer. Log position igen eller angiv afgangshavn.",
|
||||||
"tide_fetch_failed": "Tidevand kunne ikke hentes.",
|
"tide_fetch_failed": "Tidevand kunne ikke hentes.",
|
||||||
"tide_no_data": "Ingen tidevandsdata for dette sted.",
|
"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_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_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_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_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_fetch_failed": "Gezeiten konnten nicht abgerufen werden.",
|
||||||
"tide_no_data": "Für diesen Ort liegen keine Gezeitendaten vor.",
|
"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_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_fetched_at_position": "Amtliche BSH-Vorhersage vom nächstgelegenen Pegel.",
|
||||||
"tide_open_meteo_fallback": "Modellprognose (Open-Meteo) — keine BSH-Station in Reichweite.",
|
"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_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_fetch_failed": "Could not fetch tide data.",
|
||||||
"tide_no_data": "No tide data available for this location.",
|
"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_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_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_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_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_fetch_failed": "No se pudieron obtener las mareas.",
|
||||||
"tide_no_data": "No hay datos de marea para este lugar.",
|
"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_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_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_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_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_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": "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_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_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_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_position_stale": "Siste posisjon er eldre enn 2 timer. Logg posisjon på nytt eller angi avreisehavn.",
|
||||||
"tide_fetch_failed": "Kunne ikke hente tidevann.",
|
"tide_fetch_failed": "Kunne ikke hente tidevann.",
|
||||||
"tide_no_data": "Ingen tidevannsdata for dette stedet.",
|
"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_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_fetched_at_position": "Offisiell BSH-prognose fra nærmeste tidevannsmåler.",
|
||||||
"tide_open_meteo_fallback": "Modellprognose (Open-Meteo) — ingen BSH-stasjon innen rekkevidde.",
|
"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_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_fetch_failed": "Kunde inte hämta tidvatten.",
|
||||||
"tide_no_data": "Inga tidvattendata för denna plats.",
|
"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_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_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_open_meteo_fallback": "Modellprognos (Open-Meteo) — ingen BSH-station inom räckhåll.",
|
||||||
|
|||||||
@@ -5,21 +5,71 @@ import {
|
|||||||
trackPlausibleEvent
|
trackPlausibleEvent
|
||||||
} from './analytics.js'
|
} from './analytics.js'
|
||||||
|
|
||||||
|
export interface TideStation {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
lat: number
|
||||||
|
lon: number
|
||||||
|
distanceKm: number
|
||||||
|
area?: string
|
||||||
|
}
|
||||||
|
|
||||||
export class TidesApiError extends Error {
|
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(
|
constructor(
|
||||||
message: string,
|
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)
|
super(message)
|
||||||
this.name = 'TidesApiError'
|
this.name = 'TidesApiError'
|
||||||
this.code = code
|
this.code = code
|
||||||
|
this.stations = stations
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const TIDES_FETCH_TIMEOUT_MS = 20_000
|
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>> {
|
async function fetchTides(path: string): Promise<Record<string, unknown>> {
|
||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
throw new TidesApiError('Offline', 'OFFLINE')
|
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')
|
throw new TidesApiError('Invalid tide request parameters', 'BAD_REQUEST')
|
||||||
}
|
}
|
||||||
if (res.status === 404) {
|
if (res.status === 404) {
|
||||||
|
const stations = readStations(data as Record<string, unknown>)
|
||||||
const code =
|
const code =
|
||||||
typeof data?.error === 'string' && data.error === 'place_not_found'
|
typeof data?.error === 'string' && data.error === 'place_not_found'
|
||||||
? 'PLACE_NOT_FOUND'
|
? 'PLACE_NOT_FOUND'
|
||||||
: 'NOT_FOUND'
|
: 'NOT_FOUND'
|
||||||
throw new TidesApiError('Tide data not found', code)
|
throw new TidesApiError('Tide data not found', code, stations)
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new TidesApiError(
|
throw new TidesApiError(
|
||||||
@@ -59,6 +110,16 @@ async function fetchTides(path: string): Promise<Record<string, unknown>> {
|
|||||||
return data as 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(
|
export async function fetchTidesNearby(
|
||||||
lat: string,
|
lat: string,
|
||||||
lon: string,
|
lon: string,
|
||||||
@@ -75,6 +136,28 @@ export async function fetchTidesNearby(
|
|||||||
return data
|
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(
|
export async function fetchTidesByPlace(
|
||||||
placeQuery: string,
|
placeQuery: string,
|
||||||
options?: { analyticsSource?: TideAnalyticsSource }
|
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 { requireUser } from '../middleware/auth.js'
|
||||||
import {
|
import {
|
||||||
fetchTidesForCoordinates,
|
fetchTidesForCoordinates,
|
||||||
fetchTidesForPlace
|
fetchTidesForPlace,
|
||||||
|
fetchTidesForStation,
|
||||||
|
listNearbyTideStations
|
||||||
} from '../utils/tideProvider.js'
|
} from '../utils/tideProvider.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
@@ -15,6 +17,61 @@ function parseLatLon(lat: unknown, lon: unknown): { lat: number; lon: number } |
|
|||||||
return { lat: latNum, lon: lonNum }
|
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) => {
|
router.get('/nearby', requireUser, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const coords = parseLatLon(req.query.lat, req.query.lon)
|
const coords = parseLatLon(req.query.lat, req.query.lon)
|
||||||
@@ -27,6 +84,10 @@ router.get('/nearby', requireUser, async (req, res) => {
|
|||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Tide lookup failed'
|
const message = error instanceof Error ? error.message : 'Tide lookup failed'
|
||||||
if (message === 'no_tide_data') {
|
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' })
|
return res.status(404).json({ error: 'no_tide_data' })
|
||||||
}
|
}
|
||||||
console.error('Error fetching nearby tides:', error)
|
console.error('Error fetching nearby tides:', error)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
findNearestBshStation,
|
findNearestBshStation,
|
||||||
|
findNearestBshStations,
|
||||||
haversineKm,
|
haversineKm,
|
||||||
parseBshFeatureToExtrema,
|
parseBshFeatureToExtrema,
|
||||||
parseBshHwnwForecast,
|
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', () => {
|
describe('findNearestBshStation', () => {
|
||||||
it('picks Norderney Riffgat for Norddeich coordinates', () => {
|
it('picks Norderney Riffgat for Norddeich coordinates', () => {
|
||||||
const nearest = findNearestBshStation(53.624526, 7.155263, stationIndex)
|
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))
|
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(
|
export function findNearestBshStation(
|
||||||
lat: number,
|
lat: number,
|
||||||
lon: number,
|
lon: number,
|
||||||
stations: BshStation[]
|
stations: BshStation[]
|
||||||
): { station: BshStation; distanceKm: number } | null {
|
): { station: BshStation; distanceKm: number } | null {
|
||||||
if (stations.length === 0) return null
|
const nearest = findNearestBshStations(lat, lon, stations, 1)[0]
|
||||||
|
if (!nearest) return null
|
||||||
let best: { station: BshStation; distanceKm: number } | null = null
|
return {
|
||||||
for (const station of stations) {
|
station: {
|
||||||
const distanceKm = haversineKm(lat, lon, station.lat, station.lon)
|
id: nearest.id,
|
||||||
if (!best || distanceKm < best.distanceKm) {
|
name: nearest.name,
|
||||||
best = { station, distanceKm }
|
lat: nearest.lat,
|
||||||
}
|
lon: nearest.lon,
|
||||||
|
area: nearest.area
|
||||||
|
},
|
||||||
|
distanceKm: nearest.distanceKm
|
||||||
}
|
}
|
||||||
return best
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadBshStationIndex(): Promise<BshStation[]> {
|
export async function loadBshStationIndex(): Promise<BshStation[]> {
|
||||||
@@ -265,6 +296,71 @@ export interface BshTideLookupResult extends TideLookupResult {
|
|||||||
distanceKm: number
|
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(
|
export async function fetchBshTidesForCoordinates(
|
||||||
lat: number,
|
lat: number,
|
||||||
lon: number
|
lon: number
|
||||||
@@ -282,34 +378,5 @@ export async function fetchBshTidesForCoordinates(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const feature = await fetchBshStationFeature(nearest.station.id)
|
const feature = await fetchBshStationFeature(nearest.station.id)
|
||||||
const extrema = parseBshFeatureToExtrema(feature)
|
return buildBshTideResult(nearest.station, nearest.distanceKm, 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
fetchTidesForCoordinates as fetchOpenMeteoTidesForCoordinates,
|
fetchTidesForCoordinates as fetchOpenMeteoTidesForCoordinates,
|
||||||
fetchTidesForPlace as fetchOpenMeteoTidesForPlace,
|
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> {
|
export async function fetchTidesForPlace(query: string): Promise<TideProviderResult> {
|
||||||
const place = await geocodePlace(query)
|
const place = await geocodePlace(query)
|
||||||
if (!place) {
|
if (!place) {
|
||||||
|
|||||||
Reference in New Issue
Block a user