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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-12 11:16:01 +02:00
co-authored by Cursor
parent 7d6c908f65
commit 4f519e34b4
18 changed files with 807 additions and 75 deletions
+65 -15
View File
@@ -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
})
: await fetchTidesByPlace(location.query, { analyticsSource: 'live_log' })
const outcome = await fetchTidesForEntry({
fetchLocation: location,
entryDate: entryDateForLocation,
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"
+68 -16
View File
@@ -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
})
: await fetchTidesByPlace(location.query, { analyticsSource: 'entry_editor' })
const outcome = await fetchTidesForEntry({
fetchLocation: location,
entryDate: entryDateForLocation,
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>
)
}