From 5d4e498528cf74e4237136347190cacb96d8f367 Mon Sep 17 00:00:00 2001 From: elpatron Date: Thu, 11 Jun 2026 14:22:25 +0200 Subject: [PATCH] feat: Gezeiten im Logbuch per Open-Meteo Marine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HW/NW-Felder im Reisetag und Live-Journal mit Server-Proxy auf Basis von Open-Meteo Marine am GPS-Standort; neueste Position und frischer DB-Stand vor dem Abruf, Bestätigung nach Übernehmen, Accordion-Layout bereinigt. Co-authored-by: Cursor --- client/src/App.css | 35 ++++ client/src/components/LiveLogView.tsx | 170 ++++++++++++++++ client/src/components/LogEntryEditor.tsx | 186 ++++++++++++++++- client/src/i18n/locales/da.json | 16 ++ client/src/i18n/locales/de.json | 16 ++ client/src/i18n/locales/en.json | 16 ++ client/src/i18n/locales/es.json | 16 ++ client/src/i18n/locales/fr.json | 16 ++ client/src/i18n/locales/nb.json | 16 ++ client/src/i18n/locales/sv.json | 16 ++ client/src/services/analytics.ts | 3 + client/src/services/quickEventLog.ts | 22 ++ client/src/services/tides.ts | 91 +++++++++ client/src/utils/liveEventCodes.ts | 23 ++- client/src/utils/liveLogPosition.test.ts | 10 + client/src/utils/logEntryPayload.test.ts | 20 ++ client/src/utils/logEntryPayload.ts | 24 +++ client/src/utils/tideLocation.test.ts | 120 +++++++++++ client/src/utils/tideLocation.ts | 47 +++++ client/src/utils/tideTurtle.test.ts | 53 +++++ client/src/utils/tideTurtle.ts | 108 ++++++++++ docs/plausible-events.md | 3 +- server/src/app.ts | 2 + server/src/routes/tides.ts | 60 ++++++ server/src/utils/openMeteoTides.test.ts | 22 ++ server/src/utils/openMeteoTides.ts | 249 +++++++++++++++++++++++ 26 files changed, 1353 insertions(+), 7 deletions(-) create mode 100644 client/src/services/tides.ts create mode 100644 client/src/utils/tideLocation.test.ts create mode 100644 client/src/utils/tideLocation.ts create mode 100644 client/src/utils/tideTurtle.test.ts create mode 100644 client/src/utils/tideTurtle.ts create mode 100644 server/src/routes/tides.ts create mode 100644 server/src/utils/openMeteoTides.test.ts create mode 100644 server/src/utils/openMeteoTides.ts diff --git a/client/src/App.css b/client/src/App.css index fb3738e..dd60701 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -4607,6 +4607,41 @@ html.theme-cupertino .events-scroll-container { grid-column: 1 / -1; } +/* Tides accordion (LogEntryEditor) */ +.tides-panel { + display: flex; + flex-direction: column; + gap: 16px; + margin-top: 16px; +} + +.tides-panel__hints { + display: flex; + flex-direction: column; + gap: 8px; +} + +.tides-panel__hints .form-hint { + margin: 0; + font-size: 13px; + color: var(--app-text-muted); + line-height: 1.45; +} + +.tides-panel__fields { + margin: 0; +} + +.tides-panel__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.tides-panel__actions .btn { + width: auto; +} + .metric-range-input--compact { gap: 0; margin: 0; diff --git a/client/src/components/LiveLogView.tsx b/client/src/components/LiveLogView.tsx index e7bb539..cc280b3 100644 --- a/client/src/components/LiveLogView.tsx +++ b/client/src/components/LiveLogView.tsx @@ -19,6 +19,7 @@ import { Radio, Sailboat, Undo2, + Waves, Zap } from 'lucide-react' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' @@ -29,6 +30,7 @@ import { appendTankRefill as apiAppendTankRefill, findOrCreateTodayEntry, loadEntry, + patchEntryTides, removeLastEvent } from '../services/quickEventLog.js' import CreatorAvatar from './CreatorAvatar.tsx' @@ -56,6 +58,9 @@ 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 { resolveTideFetchLocation } from '../utils/tideLocation.js' +import { parseTideTurtleForDate } from '../utils/tideTurtle.js' import { geolocationErrorI18nKey, getCurrentPosition, @@ -108,6 +113,7 @@ type LiveModal = | 'sog' | 'stw' | 'position' + | 'tides' | 'photo' | 'voice' @@ -190,6 +196,7 @@ export default function LiveLogView({ const [entryId, setEntryId] = useState(null) const [dayOfTravel, setDayOfTravel] = useState('') const [date, setDate] = useState('') + const [departure, setDeparture] = useState('') const [events, setEvents] = useState([]) const [crewSnapshotsById, setCrewSnapshotsById] = useState>({}) const [selectedSkipperId, setSelectedSkipperId] = useState(null) @@ -200,6 +207,15 @@ export default function LiveLogView({ const [modal, setModal] = useState('none') const [weatherExpanded, setWeatherExpanded] = useState(false) const [weatherOwmLoading, setWeatherOwmLoading] = useState(false) + const [tidesLoading, setTidesLoading] = useState(false) + const [tidePreview, setTidePreview] = useState<{ + highWater: string + lowWater: string + placeName?: string + distanceKm?: number + source: 'gps' | 'departure' + departureQuery?: string + } | null>(null) const [isOnline, setIsOnline] = useState(navigator.onLine) const [commentText, setCommentText] = useState('') const [valueInput, setValueInput] = useState('') @@ -301,6 +317,7 @@ export default function LiveLogView({ const entryEvents = (loaded.data.events as LogEventPayload[]) || [] setDayOfTravel(String(loaded.data.dayOfTravel || '')) setDate(String(loaded.data.date || '')) + setDeparture(String(loaded.data.departure || '')) setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e })))) setCrewSnapshotsById((loaded.data.crewSnapshotsById as Record) || {}) setSelectedSkipperId(typeof loaded.data.selectedSkipperId === 'string' ? loaded.data.selectedSkipperId : null) @@ -784,6 +801,105 @@ export default function LiveLogView({ })() } + const handleFetchTides = () => { + if (!entryId || busy || tidesLoading) return + if (!isOnline) { + void showAlert(t('logs.weather_offline'), t('logs.tides')) + return + } + + setTidesLoading(true) + setError(null) + void (async () => { + try { + const loaded = await loadEntry(logbookId, entryId) + const eventsForLocation = loaded + ? sortLogEventsByTime((loaded.data.events as LogEventPayload[]) || []) + : events + const entryDateForLocation = loaded ? String(loaded.data.date || date) : date + const departureForLocation = loaded ? String(loaded.data.departure || departure) : departure + + const location = resolveTideFetchLocation({ + events: eventsForLocation, + entryDate: entryDateForLocation, + departure: departureForLocation + }) + if ('error' in location) { + void showAlert( + location.error === 'stale' + ? t('logs.tide_position_stale') + : t('logs.tide_location_required'), + t('logs.tides') + ) + 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 parsed = parseTideTurtleForDate(data, date) + if (!parsed.highWater && !parsed.lowWater) { + void showAlert(t('logs.tide_no_data'), t('logs.tides')) + return + } + + setTidePreview({ + highWater: parsed.highWater, + lowWater: parsed.lowWater, + placeName: parsed.placeName, + distanceKm: parsed.distanceKm, + source: location.source, + departureQuery: location.mode === 'by-place' ? location.query : undefined + }) + setModal('tides') + } catch (err) { + if (err instanceof TidesApiError) { + if (err.code === 'OFFLINE') { + void showAlert(t('logs.weather_offline'), t('logs.tides')) + return + } + if (err.code === 'PLACE_NOT_FOUND') { + void showAlert(t('logs.tide_place_not_found', { place: departure.trim() }), t('logs.tides')) + return + } + if (err.code === 'NOT_FOUND') { + void showAlert(t('logs.tide_no_data'), t('logs.tides')) + return + } + } + console.error('Live log tide fetch failed:', err) + void showAlert(t('logs.tide_fetch_failed'), t('logs.tides')) + } finally { + setTidesLoading(false) + } + })() + } + + const confirmTides = () => { + if (!entryId || !tidePreview || busy) return + const preview = tidePreview + void runQuickAction(async () => { + await patchEntryTides(logbookId, entryId, { + highWater: preview.highWater, + lowWater: preview.lowWater + }) + setTidePreview(null) + setModal('none') + void showAlert( + t('logs.tide_applied_success', { + highWater: preview.highWater || '—', + lowWater: preview.lowWater || '—' + }), + t('logs.tides') + ) + }, 'tides', false) + } + const handleUndo = () => { if (!entryId || busy) return const photoId = undoPhotoIdRef.current @@ -1257,6 +1373,10 @@ export default function LiveLogView({ {t('logs.live_position')} + + + + + + )} + {modal === 'comment' && (
setModal('none')}>
e.stopPropagation()}> diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index 7118766..d2fe6f8 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -8,7 +8,7 @@ import { syncLogbook } from '../services/sync.js' import { saveEntryDraft, clearEntryDraft } from '../services/entryDraft.js' import { getErrorMessage } from '../utils/errors.js' import { downloadLogbookPagePdf } from '../services/pdfExport.js' -import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp, Sparkles, Sliders } from 'lucide-react' +import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp, Sparkles, Sliders, Waves } from 'lucide-react' import PhotoCapture from './PhotoCapture.tsx' import EventRemarksCell from './EventRemarksCell.tsx' import CreatorAvatar from './CreatorAvatar.tsx' @@ -33,7 +33,7 @@ import { hasAnySignature } from '../utils/signatures.js' import type { SignatureValue } from '../types/signatures.js' -import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, hasUnsavedEventDraft, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js' +import { buildLogEntryPayload, readLogEntryTides, sortLogEventsByTime, normalizeLogEvent, hasUnsavedEventDraft, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js' import EventTimeInput24h from './EventTimeInput24h.tsx' import CourseDialInput from './CourseDialInput.tsx' import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js' @@ -43,13 +43,16 @@ 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 { resolveTideFetchLocation } from '../utils/tideLocation.js' +import { parseTideTurtleForDate } from '../utils/tideTurtle.js' import { buildTravelDayContext, fetchTravelDaySummaryUsage, generateTravelDaySummary, TravelDaySummaryApiError } from '../services/aiSummary.js' -import { tryDecryptEntryPayload } from '../services/quickEventLog.js' +import { loadEntry, tryDecryptEntryPayload } from '../services/quickEventLog.js' import { getAiAuthorized } from '../services/userPreferences.js' import { getDecryptedTrack, @@ -107,6 +110,7 @@ import { } from '../utils/tankCapacity.js' import { formatAppCoordinate, + formatAppDecimal, parseAppDecimal, parseAppDecimalOrZero } from '../utils/numberFormat.js' @@ -164,6 +168,7 @@ function fingerprintFromStoredEntry(decrypted: Record): string motorHoursRaw != null && motorHoursRaw !== '' ? (parseAppDecimal(String(motorHoursRaw)) ?? undefined) : undefined, + tides: readLogEntryTides(decrypted), events: (decrypted.events as LogEventPayload[]) || [], entryCrew: entryCrewFromPreviousEntry(decrypted as Record) }) @@ -298,6 +303,11 @@ export default function LogEntryEditor({ const [eventsCollapsed, setEventsCollapsed] = useState(true) const [addEventFormCollapsed, setAddEventFormCollapsed] = useState(false) + const [tidesCollapsed, setTidesCollapsed] = useState(true) + const [tideHighWater, setTideHighWater] = useState('') + const [tideLowWater, setTideLowWater] = useState('') + const [tidesLoading, setTidesLoading] = useState(false) + const [tideFetchHint, setTideFetchHint] = useState('') const [tanksCollapsed, setTanksCollapsed] = useState(true) const [columnSelectorOpen, setColumnSelectorOpen] = useState(false) @@ -430,6 +440,7 @@ export default function LogEntryEditor({ consumption: parseAppDecimalOrZero(fuelConsumption) }, greywater: { level: parseAppDecimalOrZero(greywaterLevel) }, + tides: { highWater: tideHighWater, lowWater: tideLowWater }, trackDistanceNm: parseOptionalFormDecimal(trackDistanceNm), trackSpeedMaxKn: parseOptionalFormDecimal(trackSpeedMaxKn), trackSpeedAvgKn: parseOptionalFormDecimal(trackSpeedAvgKn), @@ -442,6 +453,7 @@ export default function LogEntryEditor({ fwMorning, fwRefilled, fwEvening, fwConsumption, fuelMorning, fuelRefilled, fuelEvening, fuelConsumption, greywaterLevel, + tideHighWater, tideLowWater, trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours, events, entryCrew @@ -921,6 +933,11 @@ export default function LogEntryEditor({ setGreywaterLevel('0') } + const preloadedTides = readLogEntryTides(preloadedEntry as Record) + setTideHighWater(preloadedTides.highWater) + setTideLowWater(preloadedTides.lowWater) + setTideFetchHint('') + setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '') setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '') setEntryCrew(entryCrewFromPreviousEntry(preloadedEntry as Record)) @@ -962,6 +979,11 @@ export default function LogEntryEditor({ setGreywaterLevel('0') } + const loadedTides = readLogEntryTides(decrypted as Record) + setTideHighWater(loadedTides.highWater) + setTideLowWater(loadedTides.lowWater) + setTideFetchHint('') + setSignSkipper(normalizeSignature(decrypted.signSkipper) || '') setSignCrew(normalizeSignature(decrypted.signCrew) || '') setEntryCrew(entryCrewFromPreviousEntry(decrypted as Record)) @@ -1271,6 +1293,93 @@ export default function LogEntryEditor({ } } + const handleFetchTides = async () => { + if (!isOnline) { + showAlert(t('logs.weather_offline'), t('logs.tide_fetch_btn')) + return + } + + setTidesLoading(true) + setTideFetchHint('') + try { + const loaded = await loadEntry(logbookId, entryId) + const eventsForLocation = loaded + ? sortLogEventsByTime((loaded.data.events as LogEventPayload[]) || []) + : events + const entryDateForLocation = loaded ? String(loaded.data.date || date) : date + const departureForLocation = loaded ? String(loaded.data.departure || departure) : departure + + const location = resolveTideFetchLocation({ + events: eventsForLocation, + entryDate: entryDateForLocation, + departure: departureForLocation + }) + if ('error' in location) { + if (location.error === 'stale') { + showAlert(t('logs.tide_position_stale'), t('logs.tide_fetch_btn')) + } else { + showAlert(t('logs.tide_location_required'), t('logs.tide_fetch_btn')) + } + 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 parsed = parseTideTurtleForDate(data, date) + if (!parsed.highWater && !parsed.lowWater) { + showAlert(t('logs.tide_no_data'), t('logs.tide_fetch_btn')) + return + } + + if (parsed.highWater) setTideHighWater(parsed.highWater) + if (parsed.lowWater) setTideLowWater(parsed.lowWater) + + if (location.source === 'departure') { + setTideFetchHint( + t('logs.tide_fetched_from_departure', { + place: parsed.placeName || location.query + }) + ) + } else if (location.source === 'gps') { + setTideFetchHint(t('logs.tide_fetched_at_position')) + } else if (parsed.placeName) { + setTideFetchHint( + parsed.distanceKm != null + ? t('logs.tide_fetched_from', { + place: parsed.placeName, + distance: formatAppDecimal(parsed.distanceKm, { maximumFractionDigits: 1 }) ?? String(parsed.distanceKm) + }) + : parsed.placeName + ) + } + } catch (err) { + if (err instanceof TidesApiError) { + if (err.code === 'OFFLINE') { + showAlert(t('logs.weather_offline'), t('logs.tide_fetch_btn')) + return + } + if (err.code === 'PLACE_NOT_FOUND') { + showAlert(t('logs.tide_place_not_found', { place: departure.trim() }), t('logs.tide_fetch_btn')) + return + } + if (err.code === 'NOT_FOUND') { + showAlert(t('logs.tide_no_data'), t('logs.tide_fetch_btn')) + return + } + } + console.error('Tide fetch failed:', err) + showAlert(t('logs.tide_fetch_failed'), t('logs.tide_fetch_btn')) + } finally { + setTidesLoading(false) + } + } + const handleGenerateAiSummary = async () => { if (!canSignSkipper || readOnly || aiSummaryLoading) return if (!getAiAuthorized()) { @@ -2113,6 +2222,77 @@ export default function LogEntryEditor({
)} + {/* Tides */} +
+
setTidesCollapsed(!tidesCollapsed)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + setTidesCollapsed(!tidesCollapsed) + } + }} + role="button" + aria-expanded={!tidesCollapsed} + tabIndex={0} + > +
+ +

{t('logs.tides')}

+
+ {tidesCollapsed ? : } +
+ + {!tidesCollapsed && ( +
+
+

+ {t('logs.tide_disclaimer')} +

+ {tideFetchHint ? ( +

+ {tideFetchHint} +

+ ) : null} +
+
+
+ + +
+
+ + +
+
+ {!readOnly && ( +
+ +
+ )} +
+ )} +
+ {/* Section 2: Tanks (Freshwater, Fuel, and Greywater) */}
diff --git a/client/src/services/quickEventLog.ts b/client/src/services/quickEventLog.ts index c682bbd..b9bb4f0 100644 --- a/client/src/services/quickEventLog.ts +++ b/client/src/services/quickEventLog.ts @@ -7,9 +7,11 @@ import { putEntryRecord } from '../utils/entryListCache.js' import { buildLogEntryPayload, normalizeLogEvent, + readLogEntryTides, sortLogEventsByTime, currentLocalTimeHHMM, localDateString, + type LogEntryTides, type LogEventPayload } from '../utils/logEntryPayload.js' import { @@ -75,6 +77,7 @@ function buildEncryptedPayload( destination?: string freshwater?: { morning: number; refilled: number; evening: number; consumption: number } fuel?: { morning: number; refilled: number; evening: number; consumption: number } + tides?: LogEntryTides clearSignatures?: boolean } ): Record { @@ -113,6 +116,7 @@ function buildEncryptedPayload( freshwater, fuel: fuelLevels, greywater: gw ? { level: gw.level || 0 } : undefined, + tides: options.tides ?? readLogEntryTides(data), trackDistanceNm: trackDistance != null && trackDistance !== '' ? parseFloat(String(trackDistance)) @@ -398,6 +402,24 @@ export async function appendQuickEvents( return { events: nextEvents, hadSignature } } +export async function patchEntryTides( + logbookId: string, + entryId: string, + tides: LogEntryTides +): Promise { + const loaded = await loadEntry(logbookId, entryId) + if (!loaded) throw new Error('Entry not found') + + const hadSignature = !!(loaded.data.signSkipper || loaded.data.signCrew) + const currentEvents = (loaded.data.events as LogEventPayload[]) || [] + + await persistEntry(logbookId, entryId, loaded.data, { + events: currentEvents, + tides, + clearSignatures: hadSignature + }) +} + async function persistEntry( logbookId: string, entryId: string, diff --git a/client/src/services/tides.ts b/client/src/services/tides.ts new file mode 100644 index 0000000..6aac5f1 --- /dev/null +++ b/client/src/services/tides.ts @@ -0,0 +1,91 @@ +import { apiFetch } from './api.js' +import { + type TideAnalyticsSource, + PlausibleEvents, + trackPlausibleEvent +} from './analytics.js' + +export class TidesApiError extends Error { + code: 'OFFLINE' | 'NOT_FOUND' | 'PLACE_NOT_FOUND' | 'BAD_REQUEST' | 'REQUEST_FAILED' + + constructor( + message: string, + code: 'OFFLINE' | 'NOT_FOUND' | 'PLACE_NOT_FOUND' | 'BAD_REQUEST' | 'REQUEST_FAILED' = 'REQUEST_FAILED' + ) { + super(message) + this.name = 'TidesApiError' + this.code = code + } +} + +const TIDES_FETCH_TIMEOUT_MS = 20_000 + +async function fetchTides(path: string): Promise> { + if (!navigator.onLine) { + throw new TidesApiError('Offline', 'OFFLINE') + } + + const controller = new AbortController() + const timeoutId = window.setTimeout(() => controller.abort(), TIDES_FETCH_TIMEOUT_MS) + let res: Response + try { + res = await apiFetch(path, { signal: controller.signal }) + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') { + throw new TidesApiError('Tide request timed out') + } + throw err + } finally { + window.clearTimeout(timeoutId) + } + + const data = await res.json().catch(() => ({})) + if (res.status === 400) { + throw new TidesApiError('Invalid tide request parameters', 'BAD_REQUEST') + } + if (res.status === 404) { + const code = + typeof data?.error === 'string' && data.error === 'place_not_found' + ? 'PLACE_NOT_FOUND' + : 'NOT_FOUND' + throw new TidesApiError('Tide data not found', code) + } + if (!res.ok) { + throw new TidesApiError( + typeof data?.error === 'string' ? data.error : 'Tide API rejected the request' + ) + } + + return data as Record +} + +export async function fetchTidesNearby( + lat: string, + lon: string, + options?: { analyticsSource?: TideAnalyticsSource; locationSource?: 'gps' | 'departure' } +): Promise> { + const searchParams = new URLSearchParams({ lat, lon }) + const data = await fetchTides(`/api/tides/nearby?${searchParams.toString()}`) + if (options?.analyticsSource) { + trackPlausibleEvent(PlausibleEvents.TIDE_FETCHED, { + source: options.analyticsSource, + location_source: options.locationSource ?? 'gps' + }) + } + return data +} + +export async function fetchTidesByPlace( + placeQuery: string, + options?: { analyticsSource?: TideAnalyticsSource } +): Promise> { + const searchParams = new URLSearchParams({ q: placeQuery.trim() }) + const data = await fetchTides(`/api/tides/by-place?${searchParams.toString()}`) + if (options?.analyticsSource) { + trackPlausibleEvent(PlausibleEvents.TIDE_FETCHED, { + source: options.analyticsSource, + location_source: 'departure' + }) + } + return data +} diff --git a/client/src/utils/liveEventCodes.ts b/client/src/utils/liveEventCodes.ts index 183908f..81fa3fd 100644 --- a/client/src/utils/liveEventCodes.ts +++ b/client/src/utils/liveEventCodes.ts @@ -154,6 +154,9 @@ export function getLastAutoPositionMs( /** Max age of a logged position for OpenWeatherMap lookups in live log. */ export const LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS = 6 * 60 * 60 * 1000 +/** Max age of a logged position for tide lookups (TideTurtle). */ +export const LIVE_LOG_TIDE_POSITION_MAX_AGE_MS = 2 * 60 * 60 * 1000 + export type LiveLogPositionSource = 'position' | 'auto_position' export interface LiveLogPosition { @@ -176,7 +179,10 @@ export function getLatestLoggedPosition( events: Array<{ remarks: string; time: string; gpsLat?: string; gpsLng?: string }>, entryDate: string ): LiveLogPosition | null { - for (let i = events.length - 1; i >= 0; i--) { + let best: LiveLogPosition | null = null + let bestIndex = -1 + + for (let i = 0; i < events.length; i++) { const event = events[i] const code = event.remarks.trim() if (!isPositionEventCode(code)) continue @@ -185,14 +191,25 @@ export function getLatestLoggedPosition( if (!lat || !lng) continue const loggedAtMs = eventTimestampMs(entryDate, event.time) if (loggedAtMs == null) continue - return { + + const candidate: LiveLogPosition = { lat, lng, loggedAtMs, source: isManualPositionEventCode(code) ? 'position' : 'auto_position' } + + if ( + !best || + candidate.loggedAtMs > best.loggedAtMs || + (candidate.loggedAtMs === best.loggedAtMs && i > bestIndex) + ) { + best = candidate + bestIndex = i + } } - return null + + return best } /** Logged position for weather if recorded within `maxAgeMs` (default 6 h). */ diff --git a/client/src/utils/liveLogPosition.test.ts b/client/src/utils/liveLogPosition.test.ts index ce54b8c..d212b4e 100644 --- a/client/src/utils/liveLogPosition.test.ts +++ b/client/src/utils/liveLogPosition.test.ts @@ -19,6 +19,16 @@ describe('live log position', () => { expect(position?.source).toBe('position') }) + it('picks latest position by event time even when array is not sorted', () => { + const entryDate = '2026-06-01' + const events = [ + { remarks: LIVE_EVENT_CODES.POSITION, time: '14:16', gpsLat: '54.12', gpsLng: '10.65' }, + { remarks: LIVE_EVENT_CODES.POSITION, time: '14:03', gpsLat: '53.62', gpsLng: '7.15' } + ] + const position = getLatestLoggedPosition(events, entryDate) + expect(position?.lat).toBe('54.12') + }) + it('reads legacy __live:fix remarks', () => { const entryDate = '2026-06-01' const events = [ diff --git a/client/src/utils/logEntryPayload.test.ts b/client/src/utils/logEntryPayload.test.ts index 847b712..26a3073 100644 --- a/client/src/utils/logEntryPayload.test.ts +++ b/client/src/utils/logEntryPayload.test.ts @@ -72,3 +72,23 @@ describe('buildLogEntryPayload greywater', () => { expect(payload.greywater).toBeUndefined() }) }) + +describe('buildLogEntryPayload tides', () => { + const base = { + date: '2026-06-11', + dayOfTravel: '1', + departure: 'Norddeich', + destination: 'Juist', + freshwater: { morning: 0, refilled: 0, evening: 0, consumption: 0 }, + fuel: { morning: 0, refilled: 0, evening: 0, consumption: 0 }, + events: [] as LogEventPayload[] + } + + it('persists high and low water times', () => { + const payload = buildLogEntryPayload({ + ...base, + tides: { highWater: '18:34', lowWater: '12:05' } + }) + expect(payload.tides).toEqual({ highWater: '18:34', lowWater: '12:05' }) + }) +}) diff --git a/client/src/utils/logEntryPayload.ts b/client/src/utils/logEntryPayload.ts index 149ccdb..a9146f4 100644 --- a/client/src/utils/logEntryPayload.ts +++ b/client/src/utils/logEntryPayload.ts @@ -150,6 +150,11 @@ export function sortLogEventsByTime(events: T[]): T[] return [...events].sort((a, b) => (a.time || '').localeCompare(b.time || '')) } +export interface LogEntryTides { + highWater: string + lowWater: string +} + export interface LogEntryPayloadInput { date: string dayOfTravel: string @@ -158,6 +163,7 @@ export interface LogEntryPayloadInput { freshwater: { morning: number; refilled: number; evening: number; consumption: number } fuel: { morning: number; refilled: number; evening: number; consumption: number } greywater?: { level: number } + tides?: LogEntryTides trackDistanceNm?: number trackSpeedMaxKn?: number trackSpeedAvgKn?: number @@ -166,6 +172,16 @@ export interface LogEntryPayloadInput { entryCrew?: EntryCrewFields } +export function readLogEntryTides(data: Record): LogEntryTides { + const tides = data.tides as Record | undefined + const highRaw = String(tides?.highWater ?? '').trim() + const lowRaw = String(tides?.lowWater ?? '').trim() + return { + highWater: parseTimeToHHMM(highRaw) ?? '', + lowWater: parseTimeToHHMM(lowRaw) ?? '' + } +} + export function buildLogEntryPayload(input: LogEntryPayloadInput): Record { const payload: Record = { date: input.date, @@ -191,6 +207,14 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record { + it('uses chronologically latest position when several are logged', () => { + const result = resolveTideFetchLocation({ + events: [ + { + time: '14:03', + remarks: LIVE_EVENT_CODES.POSITION, + gpsLat: '53.624526', + gpsLng: '7.155263' + }, + { + time: '14:16', + remarks: LIVE_EVENT_CODES.POSITION, + gpsLat: '54.120000', + gpsLng: '10.650000' + } + ], + entryDate, + departure: 'Norddeich', + nowMs + }) + expect(result).toEqual({ + mode: 'nearby', + lat: '54.120000', + lng: '10.650000', + source: 'gps' + }) + }) + + it('prefers fresh GPS position', () => { + const result = resolveTideFetchLocation({ + events: [ + { + time: '11:30', + remarks: LIVE_EVENT_CODES.POSITION, + gpsLat: '54.32', + gpsLng: '10.14' + } + ], + entryDate, + departure: 'Kiel', + nowMs + }) + expect(result).toEqual({ + mode: 'nearby', + lat: '54.32', + lng: '10.14', + source: 'gps' + }) + }) + + it('falls back to departure when no position', () => { + const result = resolveTideFetchLocation({ + events: [], + entryDate, + departure: 'Sylt', + nowMs + }) + expect(result).toEqual({ + mode: 'by-place', + query: 'Sylt', + source: 'departure' + }) + }) + + it('falls back to departure when position is stale', () => { + const result = resolveTideFetchLocation({ + events: [ + { + time: '08:00', + remarks: LIVE_EVENT_CODES.POSITION, + gpsLat: '54.32', + gpsLng: '10.14' + } + ], + entryDate, + departure: 'Kiel', + nowMs + }) + expect(result).toEqual({ + mode: 'by-place', + query: 'Kiel', + source: 'departure' + }) + }) + + it('returns stale without departure', () => { + const result = resolveTideFetchLocation({ + events: [ + { + time: '08:00', + remarks: LIVE_EVENT_CODES.POSITION, + gpsLat: '54.32', + gpsLng: '10.14' + } + ], + entryDate, + departure: '', + nowMs + }) + expect(result).toEqual({ error: 'stale' }) + }) + + it('returns missing without position or departure', () => { + const result = resolveTideFetchLocation({ + events: [], + entryDate, + departure: '', + nowMs + }) + expect(result).toEqual({ error: 'missing' }) + }) +}) diff --git a/client/src/utils/tideLocation.ts b/client/src/utils/tideLocation.ts new file mode 100644 index 0000000..2839635 --- /dev/null +++ b/client/src/utils/tideLocation.ts @@ -0,0 +1,47 @@ +import { + getLastLoggedPositionWithin, + getLatestLoggedPosition, + LIVE_LOG_TIDE_POSITION_MAX_AGE_MS +} from './liveEventCodes.js' +import type { LogEventPayload } from './logEntryPayload.js' + +export type TideLocationSource = 'gps' | 'departure' + +export type TideFetchLocation = + | { mode: 'nearby'; lat: string; lng: string; source: 'gps' } + | { mode: 'by-place'; query: string; source: 'departure' } + +export type TideLocationError = 'stale' | 'missing' + +export function resolveTideFetchLocation(options: { + events: Array> + entryDate: string + departure: string + maxAgeMs?: number + nowMs?: number +}): TideFetchLocation | { error: TideLocationError } { + const maxAgeMs = options.maxAgeMs ?? LIVE_LOG_TIDE_POSITION_MAX_AGE_MS + const nowMs = options.nowMs ?? Date.now() + const departure = options.departure.trim() + + const fresh = getLastLoggedPositionWithin( + options.events, + options.entryDate, + maxAgeMs, + nowMs + ) + if (fresh) { + return { mode: 'nearby', lat: fresh.lat, lng: fresh.lng, source: 'gps' } + } + + if (departure) { + return { mode: 'by-place', query: departure, source: 'departure' } + } + + const latest = getLatestLoggedPosition(options.events, options.entryDate) + if (latest && nowMs - latest.loggedAtMs > maxAgeMs) { + return { error: 'stale' } + } + + return { error: 'missing' } +} diff --git a/client/src/utils/tideTurtle.test.ts b/client/src/utils/tideTurtle.test.ts new file mode 100644 index 0000000..4c40fbc --- /dev/null +++ b/client/src/utils/tideTurtle.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { parseTideTurtleForDate } from './tideTurtle.js' + +const sampleNearby = { + distanceKm: 1.2, + place: { name: 'Kiel' }, + tides: { + data: { + timezone: 'Europe/Berlin', + extrema: [ + { time: '2026-06-11T08:50:00.000Z', date: '2026-06-11', height: 0.5, isHigh: true }, + { time: '2026-06-11T14:34:00.000Z', date: '2026-06-11', height: -0.2, isHigh: false }, + { time: '2026-06-12T09:00:00.000Z', date: '2026-06-12', height: 0.6, isHigh: true } + ] + } + } +} + +describe('parseTideTurtleForDate', () => { + it('returns first high and low on entry date in local timezone', () => { + const parsed = parseTideTurtleForDate(sampleNearby, '2026-06-11') + expect(parsed.highWater).toBe('10:50') + expect(parsed.lowWater).toBe('16:34') + expect(parsed.placeName).toBe('Kiel') + expect(parsed.distanceKm).toBe(1.2) + }) + + it('reads Open-Meteo coordinate response without distance', () => { + const parsed = parseTideTurtleForDate( + { + location: { source: 'coordinates', lat: 53.62, lon: 7.15 }, + tides: sampleNearby.tides + }, + '2026-06-11' + ) + expect(parsed.highWater).toBe('10:50') + expect(parsed.distanceKm).toBeUndefined() + }) + + it('leaves missing tide type empty', () => { + const parsed = parseTideTurtleForDate( + { + data: { + timezone: 'UTC', + extrema: [{ time: '2026-06-11T12:00:00.000Z', date: '2026-06-11', height: 1, isHigh: true }] + } + }, + '2026-06-11' + ) + expect(parsed.highWater).toBe('12:00') + expect(parsed.lowWater).toBe('') + }) +}) diff --git a/client/src/utils/tideTurtle.ts b/client/src/utils/tideTurtle.ts new file mode 100644 index 0000000..716d7fa --- /dev/null +++ b/client/src/utils/tideTurtle.ts @@ -0,0 +1,108 @@ +export interface TideExtreme { + time: string + date: string + height: number + isHigh: boolean +} + +export interface ParsedTideTimes { + highWater: string + lowWater: string + placeName?: string + distanceKm?: number + timezone: string +} + +function isoToHHMM(iso: string, timeZone: string): string { + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return '' + const parts = new Intl.DateTimeFormat('en-GB', { + timeZone, + hour: '2-digit', + minute: '2-digit', + hour12: false + }).formatToParts(d) + const hour = parts.find((p) => p.type === 'hour')?.value ?? '00' + const minute = parts.find((p) => p.type === 'minute')?.value ?? '00' + return `${hour}:${minute}` +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null +} + +function readExtrema(data: Record): TideExtreme[] { + const raw = data.extrema + if (!Array.isArray(raw)) return [] + const out: TideExtreme[] = [] + for (const item of raw) { + const row = asRecord(item) + if (!row) continue + const time = String(row.time ?? '').trim() + const date = String(row.date ?? '').trim() + if (!time || !date) continue + out.push({ + time, + date, + height: Number(row.height ?? 0), + isHigh: row.isHigh === true || row.type === 'high' + }) + } + return out +} + +/** Normalize TideTurtle nearby or place JSON into extrema + metadata. */ +export function extractTideTurtlePayload(data: Record): { + extrema: TideExtreme[] + timezone: string + placeName?: string + distanceKm?: number +} { + const place = asRecord(data.place) + const location = asRecord(data.location) + const tidesRoot = asRecord(data.tides) ?? data + const tidesData = asRecord(tidesRoot.data) ?? tidesRoot + const spatial = asRecord(tidesData.spatialCoverage) ?? asRecord(data.spatialCoverage) + + const timezone = String(tidesData.timezone ?? 'UTC') + const extrema = readExtrema(tidesData) + + let placeName = place?.name ? String(place.name) : undefined + if (!placeName && location?.name) placeName = String(location.name) + if (!placeName && spatial?.name) placeName = String(spatial.name) + + const distanceKm = + location?.source === 'coordinates' + ? undefined + : data.distanceKm != null && data.distanceKm !== '' + ? Number(data.distanceKm) + : undefined + + return { extrema, timezone, placeName, distanceKm } +} + +/** First high and first low tide on entryDate (YYYY-MM-DD). */ +export function parseTideTurtleForDate( + data: Record, + entryDate: string +): ParsedTideTimes { + const { extrema, timezone, placeName, distanceKm } = extractTideTurtlePayload(data) + + let highWater = '' + let lowWater = '' + + for (const extreme of extrema) { + if (extreme.date !== entryDate) continue + if (extreme.isHigh && !highWater) { + highWater = isoToHHMM(extreme.time, timezone) + } + if (!extreme.isHigh && !lowWater) { + lowWater = isoToHHMM(extreme.time, timezone) + } + if (highWater && lowWater) break + } + + return { highWater, lowWater, placeName, distanceKm, timezone } +} diff --git a/docs/plausible-events.md b/docs/plausible-events.md index 415d49d..a61fdb0 100644 --- a/docs/plausible-events.md +++ b/docs/plausible-events.md @@ -49,6 +49,7 @@ Das Script wird über `plausible-bootstrap.js` geladen; `data-domain` ist der ak | Voice Memo Uploaded | Sprachnotiz gespeichert (`voiceAttachments.ts`) | `context`: `logbook` \| `live_log` | | Voice Memo Transcribed | Sprachmemo transkribiert (`LiveLogView.tsx`, `EventRemarksCell.tsx`) | `status`: `success` \| `failed`, `mode`: `auto` (beim Speichern) \| `manual` (nachträglich) | | OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) | +| Tide Fetched | Erfolgreicher TideTurtle-Abruf (`tides.ts`) | `source`: `live_log` \| `entry_editor`; `location_source`: `gps` \| `departure` | | AI Summary Generated | Erfolgreiche KI-Zusammenfassung eines Reisetags (`aiSummary.ts`) | — | | Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes` (Anzahlen/Größe, keine Inhalte) | | Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes`, `mode`: `same_id` \| `overwrite` \| `new_id` | @@ -148,7 +149,7 @@ Empfohlene Goal-Ketten für Auswertung (nur Business!): 8. **Internationalisierung:** Language Changed (Verteilung `to`, Pfade mit Übersetzungs-Feedback) 9. **NMEA-Import:** NMEA Uploaded → NMEA Imported (Modus, `events`, optional Track; Upload-Funnel vs. Abbruch) 10. **Live-Journal:** Live Log Opened → Live Log Event Logged (Verteilung `action`; z. B. `position`, `course`, `motor_start`) → Photo Uploaded / Voice Memo Uploaded (Filter `context`: `live_log`) -11. **OpenWeatherMap:** OWM Weather Fetched (Verteilung `source`; Live-Journal vs. Reisetag-Editor) +11. **OpenWeatherMap / Gezeiten:** OWM Weather Fetched (Verteilung `source`); Tide Fetched (Verteilung `location_source`) 12. **PWA-Stabilitaet:** PWA Boot Watchdog Soft → PWA Boot Watchdog Hard → PWA Boot Watchdog Fallback → PWA Boot Watchdog Manual Repair ## Entwicklung diff --git a/server/src/app.ts b/server/src/app.ts index be01268..d9e3206 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -10,6 +10,7 @@ import collaborationRouter from './routes/collaboration.js' import signRouter from './routes/sign.js' import pushRouter from './routes/push.js' import weatherRouter from './routes/weather.js' +import tidesRouter from './routes/tides.js' import aiRouter from './routes/ai.js' import feedbackRouter from './routes/feedback.js' import adminRouter from './routes/admin.js' @@ -120,6 +121,7 @@ export function createApp(): express.Express { app.use('/api/sign', signRouter) app.use('/api/push', pushRouter) app.use('/api/weather', weatherRouter) + app.use('/api/tides', tidesRouter) app.use('/api/ai', aiRouter) app.use('/api/feedback', feedbackRouter) app.use('/api/admin', adminRouter) diff --git a/server/src/routes/tides.ts b/server/src/routes/tides.ts new file mode 100644 index 0000000..a727bb8 --- /dev/null +++ b/server/src/routes/tides.ts @@ -0,0 +1,60 @@ +import { Router } from 'express' +import { requireUser } from '../middleware/auth.js' +import { + fetchTidesForCoordinates, + fetchTidesForPlace +} from '../utils/openMeteoTides.js' + +const router = Router() + +function parseLatLon(lat: unknown, lon: unknown): { lat: number; lon: number } | null { + const latNum = Number(lat) + const lonNum = Number(lon) + if (Number.isNaN(latNum) || Number.isNaN(lonNum)) return null + if (latNum < -90 || latNum > 90 || lonNum < -180 || lonNum > 180) return null + return { lat: latNum, lon: lonNum } +} + +router.get('/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 data = await fetchTidesForCoordinates(coords.lat, coords.lon) + return res.json(data) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Tide lookup failed' + if (message === 'no_tide_data') { + return res.status(404).json({ error: 'no_tide_data' }) + } + console.error('Error fetching nearby tides:', error) + return res.status(502).json({ error: message }) + } +}) + +router.get('/by-place', requireUser, async (req, res) => { + try { + const query = typeof req.query.q === 'string' ? req.query.q.trim() : '' + if (!query) { + return res.status(400).json({ error: 'q is required' }) + } + + const data = await fetchTidesForPlace(query) + return res.json(data) + } catch (error: unknown) { + const status = (error as { status?: number }).status + const message = error instanceof Error ? error.message : 'Tide lookup failed' + if (status === 404 || message === 'place_not_found') { + return res.status(404).json({ error: 'place_not_found' }) + } + if (message === 'no_tide_data') { + return res.status(404).json({ error: 'no_tide_data' }) + } + console.error('Error fetching place tides:', error) + return res.status(502).json({ error: message }) + } +}) + +export default router diff --git a/server/src/utils/openMeteoTides.test.ts b/server/src/utils/openMeteoTides.test.ts new file mode 100644 index 0000000..1bee844 --- /dev/null +++ b/server/src/utils/openMeteoTides.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { findSeaLevelExtrema } from './openMeteoTides.js' + +describe('findSeaLevelExtrema', () => { + it('detects one high and one low from a simple sinusoidal day', () => { + const times = [ + '2026-06-11T00:00', + '2026-06-11T01:00', + '2026-06-11T02:00', + '2026-06-11T03:00', + '2026-06-11T04:00', + '2026-06-11T05:00', + '2026-06-11T06:00' + ] + const levels = [1.0, 0.0, -1.0, 0.0, 1.0, 0.0, -1.0] + const extrema = findSeaLevelExtrema(times, levels, 'Europe/Berlin') + + expect(extrema.some((e) => e.isHigh)).toBe(true) + expect(extrema.some((e) => !e.isHigh)).toBe(true) + expect(extrema.every((e) => e.date === '2026-06-11')).toBe(true) + }) +}) diff --git a/server/src/utils/openMeteoTides.ts b/server/src/utils/openMeteoTides.ts new file mode 100644 index 0000000..0d4d01d --- /dev/null +++ b/server/src/utils/openMeteoTides.ts @@ -0,0 +1,249 @@ +const MARINE_API = 'https://marine-api.open-meteo.com/v1/marine' +const GEOCODING_API = 'https://geocoding-api.open-meteo.com/v1/search' +const FETCH_TIMEOUT_MS = 15_000 +const FORECAST_DAYS = 7 + +export interface TideExtreme { + time: string + date: string + height: number + isHigh: boolean +} + +export interface TideLookupResult { + location: { + name?: string + lat: number + lon: number + source: 'coordinates' | 'geocoded' + } + tides: { + data: { + timezone: string + datum: 'MSL' + source: string + extrema: TideExtreme[] + } + } +} + +interface MarineResponse { + timezone?: string + utc_offset_seconds?: number + hourly?: { + time?: string[] + sea_level_height_msl?: Array + } +} + +interface GeocodingResult { + name: string + latitude: number + longitude: number + country_code?: string + admin1?: string +} + +async function fetchJson(url: string): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) + try { + const res = await fetch(url, { signal: controller.signal }) + const data = await res.json() + if (!res.ok) { + const message = + typeof (data as { reason?: string })?.reason === 'string' + ? (data as { reason: string }).reason + : `Upstream HTTP ${res.status}` + throw new Error(message) + } + return data as T + } finally { + clearTimeout(timeout) + } +} + +function localDateFromIso(iso: string, timeZone: string): string { + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return '' + return new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit' + }).format(date) +} + +function interpolateExtremumTime( + t0: number, + y0: number, + t1: number, + y1: number, + t2: number, + y2: number +): { timeOffsetHours: number; height: number } { + const denom = y0 - 2 * y1 + y2 + if (Math.abs(denom) < 1e-6) { + return { timeOffsetHours: t1, height: y1 } + } + const offset = 0.5 * (y0 - y2) / denom + const clamped = Math.max(t0, Math.min(t2, offset)) + const height = y1 + 0.25 * (y0 - y2) * (clamped - t1) + return { timeOffsetHours: clamped, height } +} + +function localHourlyTimeToUtcIso(localIso: string, utcOffsetSeconds: number): string { + const [datePart, timePart] = localIso.split('T') + if (!datePart || !timePart) return localIso + const [year, month, day] = datePart.split('-').map(Number) + const [hour, minute] = timePart.split(':').map(Number) + if ([year, month, day, hour, minute].some((n) => Number.isNaN(n))) return localIso + const utcMs = Date.UTC(year, month - 1, day, hour, minute) - utcOffsetSeconds * 1000 + return new Date(utcMs).toISOString() +} + +function addFractionalHoursToLocalIso(localIso: string, deltaHours: number): string { + const [datePart, timePart] = localIso.split('T') + if (!datePart || !timePart) return localIso + const [year, month, day] = datePart.split('-').map(Number) + const [hour, minute] = timePart.split(':').map(Number) + if ([year, month, day, hour, minute].some((n) => Number.isNaN(n))) return localIso + const totalMinutes = hour * 60 + minute + Math.round(deltaHours * 60) + const dayOffset = Math.floor(totalMinutes / (24 * 60)) + const minutesInDay = ((totalMinutes % (24 * 60)) + 24 * 60) % (24 * 60) + const nextDay = new Date(Date.UTC(year, month - 1, day + dayOffset)) + const y = nextDay.getUTCFullYear() + const m = String(nextDay.getUTCMonth() + 1).padStart(2, '0') + const d = String(nextDay.getUTCDate()).padStart(2, '0') + const hh = String(Math.floor(minutesInDay / 60)).padStart(2, '0') + const mm = String(minutesInDay % 60).padStart(2, '0') + return `${y}-${m}-${d}T${hh}:${mm}` +} + +export function findSeaLevelExtrema( + times: string[], + levels: Array, + timeZone: string, + utcOffsetSeconds = 0 +): TideExtreme[] { + const extrema: TideExtreme[] = [] + if (times.length < 3) return extrema + + for (let i = 1; i < times.length - 1; i += 1) { + const prev = levels[i - 1] + const curr = levels[i] + const next = levels[i + 1] + if (prev == null || curr == null || next == null) continue + + const isHigh = curr >= prev && curr >= next && (curr > prev || curr > next) + const isLow = curr <= prev && curr <= next && (curr < prev || curr < next) + if (!isHigh && !isLow) continue + + const { timeOffsetHours, height } = interpolateExtremumTime( + i - 1, + prev, + i, + curr, + i + 1, + next + ) + const localIso = addFractionalHoursToLocalIso(times[i], timeOffsetHours - i) + const iso = localHourlyTimeToUtcIso(localIso, utcOffsetSeconds) + extrema.push({ + time: iso, + date: localDateFromIso(iso, timeZone), + height: Number(height.toFixed(2)), + isHigh + }) + } + + return extrema +} + +export async function fetchTidesForCoordinates( + lat: number, + lon: number, + options?: { name?: string; source?: 'coordinates' | 'geocoded' } +): Promise { + const url = new URL(MARINE_API) + url.searchParams.set('latitude', String(lat)) + url.searchParams.set('longitude', String(lon)) + url.searchParams.set('hourly', 'sea_level_height_msl') + url.searchParams.set('timezone', 'auto') + url.searchParams.set('forecast_days', String(FORECAST_DAYS)) + + const data = await fetchJson(url.toString()) + const times = data.hourly?.time ?? [] + const levels = data.hourly?.sea_level_height_msl ?? [] + const timezone = data.timezone || 'UTC' + const utcOffsetSeconds = data.utc_offset_seconds ?? 0 + + if (times.length === 0 || levels.length === 0) { + throw new Error('no_tide_data') + } + + const extrema = findSeaLevelExtrema(times, levels, timezone, utcOffsetSeconds) + if (extrema.length === 0) { + throw new Error('no_tide_data') + } + + return { + location: { + name: options?.name, + lat, + lon, + source: options?.source ?? 'coordinates' + }, + tides: { + data: { + timezone, + datum: 'MSL', + source: + 'Open-Meteo Marine (MeteoFrance SMOC, 0.08° grid) — model-derived, MSL not chart datum', + extrema + } + } + } +} + +function scoreGeocodingResult(query: string, result: GeocodingResult): number { + const q = query.trim().toLowerCase() + const name = result.name.toLowerCase() + let score = 0 + if (name === q) score += 100 + if (name.startsWith(q) || q.startsWith(name)) score += 40 + if (result.country_code === 'DE' || result.country_code === 'NO' || result.country_code === 'DK') { + score += 10 + } + if (result.admin1?.toLowerCase().includes('niedersachsen') || result.admin1?.toLowerCase().includes('lower saxony')) { + score += 5 + } + return score +} + +export async function geocodePlace(query: string): Promise { + const url = new URL(GEOCODING_API) + url.searchParams.set('name', query.trim()) + url.searchParams.set('count', '10') + url.searchParams.set('language', 'de') + + const data = await fetchJson<{ results?: GeocodingResult[] }>(url.toString()) + const results = data.results ?? [] + if (results.length === 0) return null + + return [...results].sort((a, b) => scoreGeocodingResult(query, b) - scoreGeocodingResult(query, a))[0] +} + +export async function fetchTidesForPlace(query: string): Promise { + const place = await geocodePlace(query) + if (!place) { + const err = new Error('place_not_found') as Error & { status?: number } + err.status = 404 + throw err + } + + return fetchTidesForCoordinates(place.latitude, place.longitude, { + name: place.name, + source: 'geocoded' + }) +}