feat: Gezeiten im Logbuch per Open-Meteo Marine

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 <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 14:22:25 +02:00
parent d667062ec2
commit 5d4e498528
26 changed files with 1353 additions and 7 deletions
+35
View File
@@ -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;
+170
View File
@@ -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<string | null>(null)
const [dayOfTravel, setDayOfTravel] = useState('')
const [date, setDate] = useState('')
const [departure, setDeparture] = useState('')
const [events, setEvents] = useState<LogEventPayload[]>([])
const [crewSnapshotsById, setCrewSnapshotsById] = useState<Record<string, any>>({})
const [selectedSkipperId, setSelectedSkipperId] = useState<string | null>(null)
@@ -200,6 +207,15 @@ export default function LiveLogView({
const [modal, setModal] = useState<LiveModal>('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<string, any>) || {})
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({
<MapPin size={18} />
{t('logs.live_position')}
</button>
<button type="button" className="live-log-action-btn" onClick={handleFetchTides} disabled={busy || tidesLoading}>
<Waves size={18} />
{tidesLoading ? t('logs.tide_fetch_loading') : t('logs.tides')}
</button>
<button type="button" className="live-log-action-btn" onClick={() => { setCommentText(''); setModal('comment') }} disabled={busy}>
<MessageSquare size={18} />
{t('logs.live_comment_btn')}
@@ -1455,6 +1575,56 @@ export default function LiveLogView({
</div>
)}
{modal === 'tides' && tidePreview && (
<div
className="live-log-modal-backdrop"
onClick={(e) => { if (e.target === e.currentTarget) closeModal() }}
>
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
<h3>{t('logs.tides')}</h3>
<p className="live-log-modal-hint" role="note">
{t('logs.tide_disclaimer')}
</p>
{tidePreview.source === 'departure' && tidePreview.departureQuery ? (
<p className="live-log-modal-hint" role="status">
{t('logs.tide_fetched_from_departure', {
place: tidePreview.placeName || tidePreview.departureQuery
})}
</p>
) : tidePreview.source === 'gps' ? (
<p className="live-log-modal-hint" role="status">
{t('logs.tide_fetched_at_position')}
</p>
) : tidePreview.placeName ? (
<p className="live-log-modal-hint" role="status">
{tidePreview.distanceKm != null
? t('logs.tide_fetched_from', {
place: tidePreview.placeName,
distance: formatAppDecimal(tidePreview.distanceKm, { maximumFractionDigits: 1 }) ?? String(tidePreview.distanceKm)
})
: tidePreview.placeName}
</p>
) : null}
<dl className="live-log-tide-preview">
<div>
<dt>{t('logs.tide_high_water')}</dt>
<dd>{tidePreview.highWater || '—'}</dd>
</div>
<div>
<dt>{t('logs.tide_low_water')}</dt>
<dd>{tidePreview.lowWater || '—'}</dd>
</div>
</dl>
<div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={closeModal}>{t('logs.live_cancel')}</button>
<button type="button" className="btn primary" onClick={confirmTides} disabled={busy}>
{t('logs.tide_apply')}
</button>
</div>
</div>
</div>
)}
{modal === 'comment' && (
<div className="live-log-modal-backdrop" onClick={() => setModal('none')}>
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
+183 -3
View File
@@ -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, unknown>): string
motorHoursRaw != null && motorHoursRaw !== ''
? (parseAppDecimal(String(motorHoursRaw)) ?? undefined)
: undefined,
tides: readLogEntryTides(decrypted),
events: (decrypted.events as LogEventPayload[]) || [],
entryCrew: entryCrewFromPreviousEntry(decrypted as Record<string, unknown>)
})
@@ -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<string, unknown>)
setTideHighWater(preloadedTides.highWater)
setTideLowWater(preloadedTides.lowWater)
setTideFetchHint('')
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
setEntryCrew(entryCrewFromPreviousEntry(preloadedEntry as Record<string, unknown>))
@@ -962,6 +979,11 @@ export default function LogEntryEditor({
setGreywaterLevel('0')
}
const loadedTides = readLogEntryTides(decrypted as Record<string, unknown>)
setTideHighWater(loadedTides.highWater)
setTideLowWater(loadedTides.lowWater)
setTideFetchHint('')
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
setEntryCrew(entryCrewFromPreviousEntry(decrypted as Record<string, unknown>))
@@ -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({
</div>
)}
{/* Tides */}
<div className="form-card">
<div
className="form-header mb-4 accordion-header"
onClick={() => setTidesCollapsed(!tidesCollapsed)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setTidesCollapsed(!tidesCollapsed)
}
}}
role="button"
aria-expanded={!tidesCollapsed}
tabIndex={0}
>
<div className="accordion-header-title">
<Waves size={20} className="form-icon" />
<h3>{t('logs.tides')}</h3>
</div>
{tidesCollapsed ? <ChevronDown size={20} /> : <ChevronUp size={20} />}
</div>
{!tidesCollapsed && (
<div className="tides-panel">
<div className="tides-panel__hints">
<p className="form-hint" role="note">
{t('logs.tide_disclaimer')}
</p>
{tideFetchHint ? (
<p className="form-hint" role="status">
{tideFetchHint}
</p>
) : null}
</div>
<div className="form-grid tides-panel__fields">
<div className="input-group">
<label>{t('logs.tide_high_water')}</label>
<EventTimeInput24h
value={tideHighWater}
onChange={setTideHighWater}
disabled={readOnly || saving || tidesLoading}
aria-label={t('logs.tide_high_water')}
/>
</div>
<div className="input-group">
<label>{t('logs.tide_low_water')}</label>
<EventTimeInput24h
value={tideLowWater}
onChange={setTideLowWater}
disabled={readOnly || saving || tidesLoading}
aria-label={t('logs.tide_low_water')}
/>
</div>
</div>
{!readOnly && (
<div className="tides-panel__actions">
<button
type="button"
className="btn secondary"
onClick={() => void handleFetchTides()}
disabled={saving || tidesLoading}
>
<Waves size={16} />
{tidesLoading ? t('logs.tide_fetch_loading') : t('logs.tide_fetch_btn')}
</button>
</div>
)}
</div>
)}
</div>
{/* Section 2: Tanks (Freshwater, Fuel, and Greywater) */}
<div className="form-card">
<div
+16
View File
@@ -190,6 +190,22 @@
"departure": "Afgangshavn (rejse fra)",
"destination": "Ankomsthavn (til)",
"route": "Rejse fra/til",
"tides": "Tidevand",
"tide_high_water": "Højvande",
"tide_low_water": "Lavvande",
"tide_fetch_btn": "Hent tidevand",
"tide_fetch_loading": "Henter tidevand…",
"tide_disclaimer": "Ingen garanti for rigtighed — kontrollér oplysningerne mod officielle kilder!",
"tide_location_required": "Tidevandsopslag kræver en aktuel position (max. 2 timer) eller en 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_no_data": "Ingen tidevandsdata for dette sted.",
"tide_place_not_found": "“{{place}}” kunne ikke findes — angiv en kystby eller havn.",
"tide_fetched_at_position": "Modelprognose ved aktuel position (Open-Meteo Marine).",
"tide_fetched_from": "Data fra {{place}} (ca. {{distance}} km væk)",
"tide_fetched_from_departure": "Tidevand baseret på afgang “{{place}}” (ingen aktuel GPS-position).",
"tide_applied_success": "Tidevand overført: højvande {{highWater}}, lavvande {{lowWater}}. Synligt i rejsedagseditoren under “Tidevand”.",
"tide_apply": "Anvend",
"tanks": "Tanke",
"customize_columns": "Tilpas kolonner",
"column_selector_title": "Kolonner, der skal vises",
+16
View File
@@ -190,6 +190,22 @@
"departure": "Start-Hafen (Reise von)",
"destination": "Ziel-Hafen (nach)",
"route": "Reise von/nach",
"tides": "Tiden",
"tide_high_water": "Hochwasser",
"tide_low_water": "Niedrigwasser",
"tide_fetch_btn": "Gezeiten abrufen",
"tide_fetch_loading": "Gezeiten werden geladen…",
"tide_disclaimer": "Keine Gewähr auf Richtigkeit — überprüfe die Informationen anhand offizieller Quellen!",
"tide_location_required": "Für den Gezeiten-Abruf wird eine aktuelle Position (max. 2 Stunden alt) oder ein Abfahrtsort benötigt.",
"tide_position_stale": "Die letzte Position ist älter als 2 Stunden. Bitte Position erneut setzen oder Abfahrtsort eintragen.",
"tide_fetch_failed": "Gezeiten konnten nicht abgerufen werden.",
"tide_no_data": "Für diesen Ort liegen keine Gezeitendaten vor.",
"tide_place_not_found": "„{{place}}“ konnte nicht geortet werden — bitte einen Küstenort oder Hafen angeben.",
"tide_fetched_at_position": "Modellprognose am aktuellen Standort (Open-Meteo Marine).",
"tide_fetched_from": "Daten von {{place}} (ca. {{distance}} km entfernt)",
"tide_fetched_from_departure": "Gezeiten basierend auf Abfahrtsort „{{place}}“ (keine aktuelle GPS-Position).",
"tide_applied_success": "Gezeiten übernommen: Hochwasser {{highWater}}, Niedrigwasser {{lowWater}}. Im Reisetag-Editor unter „Tiden“ sichtbar.",
"tide_apply": "Übernehmen",
"tanks": "Tanks",
"customize_columns": "Spalten anpassen",
"column_selector_title": "Anzuzeigende Spalten",
+16
View File
@@ -190,6 +190,22 @@
"departure": "Departure Port (von)",
"destination": "Destination Port (nach)",
"route": "Route / Journey",
"tides": "Tides",
"tide_high_water": "High water",
"tide_low_water": "Low water",
"tide_fetch_btn": "Fetch tides",
"tide_fetch_loading": "Loading tides…",
"tide_disclaimer": "No guarantee of accuracy — verify against official sources!",
"tide_location_required": "Tide lookup needs a current position (max. 2 hours old) or 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_no_data": "No tide data available for this location.",
"tide_place_not_found": "“{{place}}” could not be geocoded — please use a coastal place or harbour name.",
"tide_fetched_at_position": "Model forecast at current position (Open-Meteo Marine).",
"tide_fetched_from": "Data from {{place}} (about {{distance}} km away)",
"tide_fetched_from_departure": "Tides based on departure “{{place}}” (no current GPS position).",
"tide_applied_success": "Tides applied: high water {{highWater}}, low water {{lowWater}}. Visible in the travel day editor under “Tides”.",
"tide_apply": "Apply",
"tanks": "Tanks",
"customize_columns": "Customize columns",
"column_selector_title": "Columns to Show",
+16
View File
@@ -190,6 +190,22 @@
"departure": "Puerto de salida (viaje desde)",
"destination": "Puerto de destino (a)",
"route": "Viaje desde/hacia",
"tides": "Mareas",
"tide_high_water": "Pleamar",
"tide_low_water": "Bajamar",
"tide_fetch_btn": "Obtener mareas",
"tide_fetch_loading": "Cargando mareas…",
"tide_disclaimer": "Sin garantía de exactitud — comprueba con fuentes oficiales.",
"tide_location_required": "Las mareas requieren una posición actual (máx. 2 h) o un 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_no_data": "No hay datos de marea para este lugar.",
"tide_place_not_found": "«{{place}}» no se encontró — indica un lugar costero o puerto.",
"tide_fetched_at_position": "Pronóstico modelo en la posición actual (Open-Meteo Marine).",
"tide_fetched_from": "Datos de {{place}} (aprox. {{distance}} km)",
"tide_fetched_from_departure": "Mareas según salida «{{place}}» (sin posición GPS actual).",
"tide_applied_success": "Mareas guardadas: pleamar {{highWater}}, bajamar {{lowWater}}. Visible en el editor del día de viaje, sección «Mareas».",
"tide_apply": "Aplicar",
"tanks": "Depósitos",
"customize_columns": "Ajustar columnas",
"column_selector_title": "Columnas que se deben mostrar",
+16
View File
@@ -190,6 +190,22 @@
"departure": "Port de départ (départ de)",
"destination": "Port de destination (vers)",
"route": "Voyage au départ de/à destination de",
"tides": "Marées",
"tide_high_water": "Pleine mer",
"tide_low_water": "Basse mer",
"tide_fetch_btn": "Récupérer les marées",
"tide_fetch_loading": "Chargement des marées…",
"tide_disclaimer": "Aucune garantie d'exactitude — vérifiez auprès de sources officielles !",
"tide_location_required": "Les marées nécessitent une position actuelle (max. 2 h) ou un 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_no_data": "Aucune donnée de marée pour cet endroit.",
"tide_place_not_found": "« {{place}} » introuvable — indiquez un lieu côtier ou un port.",
"tide_fetched_at_position": "Prévision modèle à la position actuelle (Open-Meteo Marine).",
"tide_fetched_from": "Données de {{place}} (env. {{distance}} km)",
"tide_fetched_from_departure": "Marées basées sur le départ « {{place}} » (pas de position GPS actuelle).",
"tide_applied_success": "Marées enregistrées : pleine mer {{highWater}}, basse mer {{lowWater}}. Visible dans l’éditeur du jour de voyage, section « Marées ».",
"tide_apply": "Appliquer",
"tanks": "Réservoirs",
"customize_columns": "Ajuster les colonnes",
"column_selector_title": "Colonnes à afficher",
+16
View File
@@ -190,6 +190,22 @@
"departure": "Avreisehavn (reise fra)",
"destination": "Ankomsthavn (til)",
"route": "Reise fra/til",
"tides": "Tidevann",
"tide_high_water": "Høyvann",
"tide_low_water": "Lavvann",
"tide_fetch_btn": "Hent tidevann",
"tide_fetch_loading": "Henter tidevann…",
"tide_disclaimer": "Ingen garanti for riktighet — kontroller opplysningene mot offisielle kilder!",
"tide_location_required": "Tidevann krever aktuell posisjon (maks 2 timer) eller 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_no_data": "Ingen tidevannsdata for dette stedet.",
"tide_place_not_found": "«{{place}}» ble ikke funnet — oppgi en kyststad eller havn.",
"tide_fetched_at_position": "Modellprognose ved gjeldende posisjon (Open-Meteo Marine).",
"tide_fetched_from": "Data fra {{place}} (ca. {{distance}} km unna)",
"tide_fetched_from_departure": "Tidevann basert på avreise «{{place}}» (ingen aktuell GPS-posisjon).",
"tide_applied_success": "Tidevann lagret: høyvann {{highWater}}, lavvann {{lowWater}}. Synlig i reisedagseditoren under «Tidevann».",
"tide_apply": "Bruk",
"tanks": "Tanker",
"customize_columns": "Tilpass kolonner",
"column_selector_title": "Kolonner som skal vises",
+16
View File
@@ -190,6 +190,22 @@
"departure": "Avgångshamn (avresa från)",
"destination": "Ankomsthamn (till)",
"route": "Resa från/till",
"tides": "Tidvatten",
"tide_high_water": "Högvatten",
"tide_low_water": "Lågvatten",
"tide_fetch_btn": "Hämta tidvatten",
"tide_fetch_loading": "Hämtar tidvatten…",
"tide_disclaimer": "Ingen garanti för riktighet — verifiera mot officiella källor!",
"tide_location_required": "Tidvatten kräver aktuell position (max 2 timmar) eller 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_no_data": "Inga tidvattendata för denna plats.",
"tide_place_not_found": "“{{place}}” kunde inte hittas — ange en kustort eller hamn.",
"tide_fetched_at_position": "Modellprognos vid aktuell position (Open-Meteo Marine).",
"tide_fetched_from": "Data från {{place}} (ca {{distance}} km bort)",
"tide_fetched_from_departure": "Tidvatten baserat på avgång “{{place}}” (ingen aktuell GPS-position).",
"tide_applied_success": "Tidvatten tillämpat: högvatten {{highWater}}, lågvatten {{lowWater}}. Syns i resedagseditorn under “Tidvatten”.",
"tide_apply": "Använd",
"tanks": "Tankar",
"customize_columns": "Anpassa kolumnerna",
"column_selector_title": "Kolumner som ska visas",
+3
View File
@@ -44,6 +44,7 @@ export const PlausibleEvents = {
VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded',
VOICE_MEMO_TRANSCRIBED: 'Voice Memo Transcribed',
OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
TIDE_FETCHED: 'Tide Fetched',
AI_SUMMARY_GENERATED: 'AI Summary Generated',
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
PWA_BOOT_WATCHDOG_HARD: 'PWA Boot Watchdog Hard',
@@ -54,6 +55,8 @@ export const PlausibleEvents = {
/** Where a successful OpenWeatherMap API call originated (no coordinates or place names). */
export type OwmAnalyticsSource = 'live_log' | 'entry_editor' | 'entry_editor_gps_lookup'
export type TideAnalyticsSource = 'live_log' | 'entry_editor'
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
export type PlausibleEventProps = Record<string, string | number | boolean>
+22
View File
@@ -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<string, unknown> {
@@ -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<void> {
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,
+91
View File
@@ -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<Record<string, unknown>> {
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<string, unknown>
}
export async function fetchTidesNearby(
lat: string,
lon: string,
options?: { analyticsSource?: TideAnalyticsSource; locationSource?: 'gps' | 'departure' }
): Promise<Record<string, unknown>> {
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<Record<string, unknown>> {
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
}
+20 -3
View File
@@ -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). */
+10
View File
@@ -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 = [
+20
View File
@@ -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' })
})
})
+24
View File
@@ -150,6 +150,11 @@ export function sortLogEventsByTime<T extends LogEventPayload>(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<string, unknown>): LogEntryTides {
const tides = data.tides as Record<string, unknown> | 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<string, unknown> {
const payload: Record<string, unknown> = {
date: input.date,
@@ -191,6 +207,14 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string
}
}
if (input.tides) {
const highWater = parseTimeToHHMM(input.tides.highWater) ?? ''
const lowWater = parseTimeToHHMM(input.tides.lowWater) ?? ''
if (highWater || lowWater) {
payload.tides = { highWater, lowWater }
}
}
if (input.entryCrew) {
payload.selectedSkipperId = input.entryCrew.selectedSkipperId
payload.selectedCrewIds = [...input.entryCrew.selectedCrewIds]
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest'
import { LIVE_EVENT_CODES } from './liveEventCodes.js'
import { resolveTideFetchLocation } from './tideLocation.js'
const entryDate = '2026-06-11'
const nowMs = new Date('2026-06-11T12:00:00').getTime()
describe('resolveTideFetchLocation', () => {
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' })
})
})
+47
View File
@@ -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<Pick<LogEventPayload, 'remarks' | 'time' | 'gpsLat' | 'gpsLng'>>
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' }
}
+53
View File
@@ -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('')
})
})
+108
View File
@@ -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<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
function readExtrema(data: Record<string, unknown>): 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<string, unknown>): {
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<string, unknown>,
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 }
}