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:
@@ -4607,6 +4607,41 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
grid-column: 1 / -1;
|
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 {
|
.metric-range-input--compact {
|
||||||
gap: 0;
|
gap: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
Radio,
|
Radio,
|
||||||
Sailboat,
|
Sailboat,
|
||||||
Undo2,
|
Undo2,
|
||||||
|
Waves,
|
||||||
Zap
|
Zap
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
appendTankRefill as apiAppendTankRefill,
|
appendTankRefill as apiAppendTankRefill,
|
||||||
findOrCreateTodayEntry,
|
findOrCreateTodayEntry,
|
||||||
loadEntry,
|
loadEntry,
|
||||||
|
patchEntryTides,
|
||||||
removeLastEvent
|
removeLastEvent
|
||||||
} from '../services/quickEventLog.js'
|
} from '../services/quickEventLog.js'
|
||||||
import CreatorAvatar from './CreatorAvatar.tsx'
|
import CreatorAvatar from './CreatorAvatar.tsx'
|
||||||
@@ -56,6 +58,9 @@ const formatSpeedKn = (speedKn: number) =>
|
|||||||
formatAppDecimal(speedKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
formatAppDecimal(speedKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||||
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
||||||
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
||||||
|
import { fetchTidesByPlace, fetchTidesNearby, TidesApiError } from '../services/tides.js'
|
||||||
|
import { resolveTideFetchLocation } from '../utils/tideLocation.js'
|
||||||
|
import { parseTideTurtleForDate } from '../utils/tideTurtle.js'
|
||||||
import {
|
import {
|
||||||
geolocationErrorI18nKey,
|
geolocationErrorI18nKey,
|
||||||
getCurrentPosition,
|
getCurrentPosition,
|
||||||
@@ -108,6 +113,7 @@ type LiveModal =
|
|||||||
| 'sog'
|
| 'sog'
|
||||||
| 'stw'
|
| 'stw'
|
||||||
| 'position'
|
| 'position'
|
||||||
|
| 'tides'
|
||||||
| 'photo'
|
| 'photo'
|
||||||
| 'voice'
|
| 'voice'
|
||||||
|
|
||||||
@@ -190,6 +196,7 @@ export default function LiveLogView({
|
|||||||
const [entryId, setEntryId] = useState<string | null>(null)
|
const [entryId, setEntryId] = useState<string | null>(null)
|
||||||
const [dayOfTravel, setDayOfTravel] = useState('')
|
const [dayOfTravel, setDayOfTravel] = useState('')
|
||||||
const [date, setDate] = useState('')
|
const [date, setDate] = useState('')
|
||||||
|
const [departure, setDeparture] = useState('')
|
||||||
const [events, setEvents] = useState<LogEventPayload[]>([])
|
const [events, setEvents] = useState<LogEventPayload[]>([])
|
||||||
const [crewSnapshotsById, setCrewSnapshotsById] = useState<Record<string, any>>({})
|
const [crewSnapshotsById, setCrewSnapshotsById] = useState<Record<string, any>>({})
|
||||||
const [selectedSkipperId, setSelectedSkipperId] = useState<string | null>(null)
|
const [selectedSkipperId, setSelectedSkipperId] = useState<string | null>(null)
|
||||||
@@ -200,6 +207,15 @@ export default function LiveLogView({
|
|||||||
const [modal, setModal] = useState<LiveModal>('none')
|
const [modal, setModal] = useState<LiveModal>('none')
|
||||||
const [weatherExpanded, setWeatherExpanded] = useState(false)
|
const [weatherExpanded, setWeatherExpanded] = useState(false)
|
||||||
const [weatherOwmLoading, setWeatherOwmLoading] = 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 [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||||
const [commentText, setCommentText] = useState('')
|
const [commentText, setCommentText] = useState('')
|
||||||
const [valueInput, setValueInput] = useState('')
|
const [valueInput, setValueInput] = useState('')
|
||||||
@@ -301,6 +317,7 @@ export default function LiveLogView({
|
|||||||
const entryEvents = (loaded.data.events as LogEventPayload[]) || []
|
const entryEvents = (loaded.data.events as LogEventPayload[]) || []
|
||||||
setDayOfTravel(String(loaded.data.dayOfTravel || ''))
|
setDayOfTravel(String(loaded.data.dayOfTravel || ''))
|
||||||
setDate(String(loaded.data.date || ''))
|
setDate(String(loaded.data.date || ''))
|
||||||
|
setDeparture(String(loaded.data.departure || ''))
|
||||||
setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e }))))
|
setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e }))))
|
||||||
setCrewSnapshotsById((loaded.data.crewSnapshotsById as Record<string, any>) || {})
|
setCrewSnapshotsById((loaded.data.crewSnapshotsById as Record<string, any>) || {})
|
||||||
setSelectedSkipperId(typeof loaded.data.selectedSkipperId === 'string' ? loaded.data.selectedSkipperId : null)
|
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 = () => {
|
const handleUndo = () => {
|
||||||
if (!entryId || busy) return
|
if (!entryId || busy) return
|
||||||
const photoId = undoPhotoIdRef.current
|
const photoId = undoPhotoIdRef.current
|
||||||
@@ -1257,6 +1373,10 @@ export default function LiveLogView({
|
|||||||
<MapPin size={18} />
|
<MapPin size={18} />
|
||||||
{t('logs.live_position')}
|
{t('logs.live_position')}
|
||||||
</button>
|
</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}>
|
<button type="button" className="live-log-action-btn" onClick={() => { setCommentText(''); setModal('comment') }} disabled={busy}>
|
||||||
<MessageSquare size={18} />
|
<MessageSquare size={18} />
|
||||||
{t('logs.live_comment_btn')}
|
{t('logs.live_comment_btn')}
|
||||||
@@ -1455,6 +1575,56 @@ export default function LiveLogView({
|
|||||||
</div>
|
</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' && (
|
{modal === 'comment' && (
|
||||||
<div className="live-log-modal-backdrop" onClick={() => setModal('none')}>
|
<div className="live-log-modal-backdrop" onClick={() => setModal('none')}>
|
||||||
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { syncLogbook } from '../services/sync.js'
|
|||||||
import { saveEntryDraft, clearEntryDraft } from '../services/entryDraft.js'
|
import { saveEntryDraft, clearEntryDraft } from '../services/entryDraft.js'
|
||||||
import { getErrorMessage } from '../utils/errors.js'
|
import { getErrorMessage } from '../utils/errors.js'
|
||||||
import { downloadLogbookPagePdf } from '../services/pdfExport.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 PhotoCapture from './PhotoCapture.tsx'
|
||||||
import EventRemarksCell from './EventRemarksCell.tsx'
|
import EventRemarksCell from './EventRemarksCell.tsx'
|
||||||
import CreatorAvatar from './CreatorAvatar.tsx'
|
import CreatorAvatar from './CreatorAvatar.tsx'
|
||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
hasAnySignature
|
hasAnySignature
|
||||||
} from '../utils/signatures.js'
|
} from '../utils/signatures.js'
|
||||||
import type { SignatureValue } from '../types/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 EventTimeInput24h from './EventTimeInput24h.tsx'
|
||||||
import CourseDialInput from './CourseDialInput.tsx'
|
import CourseDialInput from './CourseDialInput.tsx'
|
||||||
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
||||||
@@ -43,13 +43,16 @@ import { putEntryRecord } from '../utils/entryListCache.js'
|
|||||||
import { getLogbookAccess } from '../services/logbookAccess.js'
|
import { getLogbookAccess } from '../services/logbookAccess.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
||||||
|
import { fetchTidesByPlace, fetchTidesNearby, TidesApiError } from '../services/tides.js'
|
||||||
|
import { resolveTideFetchLocation } from '../utils/tideLocation.js'
|
||||||
|
import { parseTideTurtleForDate } from '../utils/tideTurtle.js'
|
||||||
import {
|
import {
|
||||||
buildTravelDayContext,
|
buildTravelDayContext,
|
||||||
fetchTravelDaySummaryUsage,
|
fetchTravelDaySummaryUsage,
|
||||||
generateTravelDaySummary,
|
generateTravelDaySummary,
|
||||||
TravelDaySummaryApiError
|
TravelDaySummaryApiError
|
||||||
} from '../services/aiSummary.js'
|
} from '../services/aiSummary.js'
|
||||||
import { tryDecryptEntryPayload } from '../services/quickEventLog.js'
|
import { loadEntry, tryDecryptEntryPayload } from '../services/quickEventLog.js'
|
||||||
import { getAiAuthorized } from '../services/userPreferences.js'
|
import { getAiAuthorized } from '../services/userPreferences.js'
|
||||||
import {
|
import {
|
||||||
getDecryptedTrack,
|
getDecryptedTrack,
|
||||||
@@ -107,6 +110,7 @@ import {
|
|||||||
} from '../utils/tankCapacity.js'
|
} from '../utils/tankCapacity.js'
|
||||||
import {
|
import {
|
||||||
formatAppCoordinate,
|
formatAppCoordinate,
|
||||||
|
formatAppDecimal,
|
||||||
parseAppDecimal,
|
parseAppDecimal,
|
||||||
parseAppDecimalOrZero
|
parseAppDecimalOrZero
|
||||||
} from '../utils/numberFormat.js'
|
} from '../utils/numberFormat.js'
|
||||||
@@ -164,6 +168,7 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
|
|||||||
motorHoursRaw != null && motorHoursRaw !== ''
|
motorHoursRaw != null && motorHoursRaw !== ''
|
||||||
? (parseAppDecimal(String(motorHoursRaw)) ?? undefined)
|
? (parseAppDecimal(String(motorHoursRaw)) ?? undefined)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
tides: readLogEntryTides(decrypted),
|
||||||
events: (decrypted.events as LogEventPayload[]) || [],
|
events: (decrypted.events as LogEventPayload[]) || [],
|
||||||
entryCrew: entryCrewFromPreviousEntry(decrypted as Record<string, unknown>)
|
entryCrew: entryCrewFromPreviousEntry(decrypted as Record<string, unknown>)
|
||||||
})
|
})
|
||||||
@@ -298,6 +303,11 @@ export default function LogEntryEditor({
|
|||||||
|
|
||||||
const [eventsCollapsed, setEventsCollapsed] = useState(true)
|
const [eventsCollapsed, setEventsCollapsed] = useState(true)
|
||||||
const [addEventFormCollapsed, setAddEventFormCollapsed] = useState(false)
|
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 [tanksCollapsed, setTanksCollapsed] = useState(true)
|
||||||
|
|
||||||
const [columnSelectorOpen, setColumnSelectorOpen] = useState(false)
|
const [columnSelectorOpen, setColumnSelectorOpen] = useState(false)
|
||||||
@@ -430,6 +440,7 @@ export default function LogEntryEditor({
|
|||||||
consumption: parseAppDecimalOrZero(fuelConsumption)
|
consumption: parseAppDecimalOrZero(fuelConsumption)
|
||||||
},
|
},
|
||||||
greywater: { level: parseAppDecimalOrZero(greywaterLevel) },
|
greywater: { level: parseAppDecimalOrZero(greywaterLevel) },
|
||||||
|
tides: { highWater: tideHighWater, lowWater: tideLowWater },
|
||||||
trackDistanceNm: parseOptionalFormDecimal(trackDistanceNm),
|
trackDistanceNm: parseOptionalFormDecimal(trackDistanceNm),
|
||||||
trackSpeedMaxKn: parseOptionalFormDecimal(trackSpeedMaxKn),
|
trackSpeedMaxKn: parseOptionalFormDecimal(trackSpeedMaxKn),
|
||||||
trackSpeedAvgKn: parseOptionalFormDecimal(trackSpeedAvgKn),
|
trackSpeedAvgKn: parseOptionalFormDecimal(trackSpeedAvgKn),
|
||||||
@@ -442,6 +453,7 @@ export default function LogEntryEditor({
|
|||||||
fwMorning, fwRefilled, fwEvening, fwConsumption,
|
fwMorning, fwRefilled, fwEvening, fwConsumption,
|
||||||
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
|
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
|
||||||
greywaterLevel,
|
greywaterLevel,
|
||||||
|
tideHighWater, tideLowWater,
|
||||||
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
|
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
|
||||||
events,
|
events,
|
||||||
entryCrew
|
entryCrew
|
||||||
@@ -921,6 +933,11 @@ export default function LogEntryEditor({
|
|||||||
setGreywaterLevel('0')
|
setGreywaterLevel('0')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const preloadedTides = readLogEntryTides(preloadedEntry as Record<string, unknown>)
|
||||||
|
setTideHighWater(preloadedTides.highWater)
|
||||||
|
setTideLowWater(preloadedTides.lowWater)
|
||||||
|
setTideFetchHint('')
|
||||||
|
|
||||||
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
|
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
|
||||||
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
||||||
setEntryCrew(entryCrewFromPreviousEntry(preloadedEntry as Record<string, unknown>))
|
setEntryCrew(entryCrewFromPreviousEntry(preloadedEntry as Record<string, unknown>))
|
||||||
@@ -962,6 +979,11 @@ export default function LogEntryEditor({
|
|||||||
setGreywaterLevel('0')
|
setGreywaterLevel('0')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadedTides = readLogEntryTides(decrypted as Record<string, unknown>)
|
||||||
|
setTideHighWater(loadedTides.highWater)
|
||||||
|
setTideLowWater(loadedTides.lowWater)
|
||||||
|
setTideFetchHint('')
|
||||||
|
|
||||||
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
|
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
|
||||||
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
||||||
setEntryCrew(entryCrewFromPreviousEntry(decrypted as Record<string, unknown>))
|
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 () => {
|
const handleGenerateAiSummary = async () => {
|
||||||
if (!canSignSkipper || readOnly || aiSummaryLoading) return
|
if (!canSignSkipper || readOnly || aiSummaryLoading) return
|
||||||
if (!getAiAuthorized()) {
|
if (!getAiAuthorized()) {
|
||||||
@@ -2113,6 +2222,77 @@ export default function LogEntryEditor({
|
|||||||
</div>
|
</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) */}
|
{/* Section 2: Tanks (Freshwater, Fuel, and Greywater) */}
|
||||||
<div className="form-card">
|
<div className="form-card">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -190,6 +190,22 @@
|
|||||||
"departure": "Afgangshavn (rejse fra)",
|
"departure": "Afgangshavn (rejse fra)",
|
||||||
"destination": "Ankomsthavn (til)",
|
"destination": "Ankomsthavn (til)",
|
||||||
"route": "Rejse fra/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",
|
"tanks": "Tanke",
|
||||||
"customize_columns": "Tilpas kolonner",
|
"customize_columns": "Tilpas kolonner",
|
||||||
"column_selector_title": "Kolonner, der skal vises",
|
"column_selector_title": "Kolonner, der skal vises",
|
||||||
|
|||||||
@@ -190,6 +190,22 @@
|
|||||||
"departure": "Start-Hafen (Reise von)",
|
"departure": "Start-Hafen (Reise von)",
|
||||||
"destination": "Ziel-Hafen (nach)",
|
"destination": "Ziel-Hafen (nach)",
|
||||||
"route": "Reise von/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",
|
"tanks": "Tanks",
|
||||||
"customize_columns": "Spalten anpassen",
|
"customize_columns": "Spalten anpassen",
|
||||||
"column_selector_title": "Anzuzeigende Spalten",
|
"column_selector_title": "Anzuzeigende Spalten",
|
||||||
|
|||||||
@@ -190,6 +190,22 @@
|
|||||||
"departure": "Departure Port (von)",
|
"departure": "Departure Port (von)",
|
||||||
"destination": "Destination Port (nach)",
|
"destination": "Destination Port (nach)",
|
||||||
"route": "Route / Journey",
|
"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",
|
"tanks": "Tanks",
|
||||||
"customize_columns": "Customize columns",
|
"customize_columns": "Customize columns",
|
||||||
"column_selector_title": "Columns to Show",
|
"column_selector_title": "Columns to Show",
|
||||||
|
|||||||
@@ -190,6 +190,22 @@
|
|||||||
"departure": "Puerto de salida (viaje desde)",
|
"departure": "Puerto de salida (viaje desde)",
|
||||||
"destination": "Puerto de destino (a)",
|
"destination": "Puerto de destino (a)",
|
||||||
"route": "Viaje desde/hacia",
|
"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",
|
"tanks": "Depósitos",
|
||||||
"customize_columns": "Ajustar columnas",
|
"customize_columns": "Ajustar columnas",
|
||||||
"column_selector_title": "Columnas que se deben mostrar",
|
"column_selector_title": "Columnas que se deben mostrar",
|
||||||
|
|||||||
@@ -190,6 +190,22 @@
|
|||||||
"departure": "Port de départ (départ de)",
|
"departure": "Port de départ (départ de)",
|
||||||
"destination": "Port de destination (vers)",
|
"destination": "Port de destination (vers)",
|
||||||
"route": "Voyage au départ de/à destination de",
|
"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",
|
"tanks": "Réservoirs",
|
||||||
"customize_columns": "Ajuster les colonnes",
|
"customize_columns": "Ajuster les colonnes",
|
||||||
"column_selector_title": "Colonnes à afficher",
|
"column_selector_title": "Colonnes à afficher",
|
||||||
|
|||||||
@@ -190,6 +190,22 @@
|
|||||||
"departure": "Avreisehavn (reise fra)",
|
"departure": "Avreisehavn (reise fra)",
|
||||||
"destination": "Ankomsthavn (til)",
|
"destination": "Ankomsthavn (til)",
|
||||||
"route": "Reise fra/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",
|
"tanks": "Tanker",
|
||||||
"customize_columns": "Tilpass kolonner",
|
"customize_columns": "Tilpass kolonner",
|
||||||
"column_selector_title": "Kolonner som skal vises",
|
"column_selector_title": "Kolonner som skal vises",
|
||||||
|
|||||||
@@ -190,6 +190,22 @@
|
|||||||
"departure": "Avgångshamn (avresa från)",
|
"departure": "Avgångshamn (avresa från)",
|
||||||
"destination": "Ankomsthamn (till)",
|
"destination": "Ankomsthamn (till)",
|
||||||
"route": "Resa från/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",
|
"tanks": "Tankar",
|
||||||
"customize_columns": "Anpassa kolumnerna",
|
"customize_columns": "Anpassa kolumnerna",
|
||||||
"column_selector_title": "Kolumner som ska visas",
|
"column_selector_title": "Kolumner som ska visas",
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const PlausibleEvents = {
|
|||||||
VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded',
|
VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded',
|
||||||
VOICE_MEMO_TRANSCRIBED: 'Voice Memo Transcribed',
|
VOICE_MEMO_TRANSCRIBED: 'Voice Memo Transcribed',
|
||||||
OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
|
OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
|
||||||
|
TIDE_FETCHED: 'Tide Fetched',
|
||||||
AI_SUMMARY_GENERATED: 'AI Summary Generated',
|
AI_SUMMARY_GENERATED: 'AI Summary Generated',
|
||||||
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
|
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
|
||||||
PWA_BOOT_WATCHDOG_HARD: 'PWA Boot Watchdog Hard',
|
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). */
|
/** 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 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 PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
|
||||||
|
|
||||||
export type PlausibleEventProps = Record<string, string | number | boolean>
|
export type PlausibleEventProps = Record<string, string | number | boolean>
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { putEntryRecord } from '../utils/entryListCache.js'
|
|||||||
import {
|
import {
|
||||||
buildLogEntryPayload,
|
buildLogEntryPayload,
|
||||||
normalizeLogEvent,
|
normalizeLogEvent,
|
||||||
|
readLogEntryTides,
|
||||||
sortLogEventsByTime,
|
sortLogEventsByTime,
|
||||||
currentLocalTimeHHMM,
|
currentLocalTimeHHMM,
|
||||||
localDateString,
|
localDateString,
|
||||||
|
type LogEntryTides,
|
||||||
type LogEventPayload
|
type LogEventPayload
|
||||||
} from '../utils/logEntryPayload.js'
|
} from '../utils/logEntryPayload.js'
|
||||||
import {
|
import {
|
||||||
@@ -75,6 +77,7 @@ function buildEncryptedPayload(
|
|||||||
destination?: string
|
destination?: string
|
||||||
freshwater?: { morning: number; refilled: number; evening: number; consumption: number }
|
freshwater?: { morning: number; refilled: number; evening: number; consumption: number }
|
||||||
fuel?: { morning: number; refilled: number; evening: number; consumption: number }
|
fuel?: { morning: number; refilled: number; evening: number; consumption: number }
|
||||||
|
tides?: LogEntryTides
|
||||||
clearSignatures?: boolean
|
clearSignatures?: boolean
|
||||||
}
|
}
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
@@ -113,6 +116,7 @@ function buildEncryptedPayload(
|
|||||||
freshwater,
|
freshwater,
|
||||||
fuel: fuelLevels,
|
fuel: fuelLevels,
|
||||||
greywater: gw ? { level: gw.level || 0 } : undefined,
|
greywater: gw ? { level: gw.level || 0 } : undefined,
|
||||||
|
tides: options.tides ?? readLogEntryTides(data),
|
||||||
trackDistanceNm:
|
trackDistanceNm:
|
||||||
trackDistance != null && trackDistance !== ''
|
trackDistance != null && trackDistance !== ''
|
||||||
? parseFloat(String(trackDistance))
|
? parseFloat(String(trackDistance))
|
||||||
@@ -398,6 +402,24 @@ export async function appendQuickEvents(
|
|||||||
return { events: nextEvents, hadSignature }
|
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(
|
async function persistEntry(
|
||||||
logbookId: string,
|
logbookId: string,
|
||||||
entryId: string,
|
entryId: string,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -154,6 +154,9 @@ export function getLastAutoPositionMs(
|
|||||||
/** Max age of a logged position for OpenWeatherMap lookups in live log. */
|
/** 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
|
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 type LiveLogPositionSource = 'position' | 'auto_position'
|
||||||
|
|
||||||
export interface LiveLogPosition {
|
export interface LiveLogPosition {
|
||||||
@@ -176,7 +179,10 @@ export function getLatestLoggedPosition(
|
|||||||
events: Array<{ remarks: string; time: string; gpsLat?: string; gpsLng?: string }>,
|
events: Array<{ remarks: string; time: string; gpsLat?: string; gpsLng?: string }>,
|
||||||
entryDate: string
|
entryDate: string
|
||||||
): LiveLogPosition | null {
|
): 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 event = events[i]
|
||||||
const code = event.remarks.trim()
|
const code = event.remarks.trim()
|
||||||
if (!isPositionEventCode(code)) continue
|
if (!isPositionEventCode(code)) continue
|
||||||
@@ -185,14 +191,25 @@ export function getLatestLoggedPosition(
|
|||||||
if (!lat || !lng) continue
|
if (!lat || !lng) continue
|
||||||
const loggedAtMs = eventTimestampMs(entryDate, event.time)
|
const loggedAtMs = eventTimestampMs(entryDate, event.time)
|
||||||
if (loggedAtMs == null) continue
|
if (loggedAtMs == null) continue
|
||||||
return {
|
|
||||||
|
const candidate: LiveLogPosition = {
|
||||||
lat,
|
lat,
|
||||||
lng,
|
lng,
|
||||||
loggedAtMs,
|
loggedAtMs,
|
||||||
source: isManualPositionEventCode(code) ? 'position' : 'auto_position'
|
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). */
|
/** Logged position for weather if recorded within `maxAgeMs` (default 6 h). */
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ describe('live log position', () => {
|
|||||||
expect(position?.source).toBe('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', () => {
|
it('reads legacy __live:fix remarks', () => {
|
||||||
const entryDate = '2026-06-01'
|
const entryDate = '2026-06-01'
|
||||||
const events = [
|
const events = [
|
||||||
|
|||||||
@@ -72,3 +72,23 @@ describe('buildLogEntryPayload greywater', () => {
|
|||||||
expect(payload.greywater).toBeUndefined()
|
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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -150,6 +150,11 @@ export function sortLogEventsByTime<T extends LogEventPayload>(events: T[]): T[]
|
|||||||
return [...events].sort((a, b) => (a.time || '').localeCompare(b.time || ''))
|
return [...events].sort((a, b) => (a.time || '').localeCompare(b.time || ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LogEntryTides {
|
||||||
|
highWater: string
|
||||||
|
lowWater: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface LogEntryPayloadInput {
|
export interface LogEntryPayloadInput {
|
||||||
date: string
|
date: string
|
||||||
dayOfTravel: string
|
dayOfTravel: string
|
||||||
@@ -158,6 +163,7 @@ export interface LogEntryPayloadInput {
|
|||||||
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
|
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
|
||||||
fuel: { morning: number; refilled: number; evening: number; consumption: number }
|
fuel: { morning: number; refilled: number; evening: number; consumption: number }
|
||||||
greywater?: { level: number }
|
greywater?: { level: number }
|
||||||
|
tides?: LogEntryTides
|
||||||
trackDistanceNm?: number
|
trackDistanceNm?: number
|
||||||
trackSpeedMaxKn?: number
|
trackSpeedMaxKn?: number
|
||||||
trackSpeedAvgKn?: number
|
trackSpeedAvgKn?: number
|
||||||
@@ -166,6 +172,16 @@ export interface LogEntryPayloadInput {
|
|||||||
entryCrew?: EntryCrewFields
|
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> {
|
export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string, unknown> {
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
date: input.date,
|
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) {
|
if (input.entryCrew) {
|
||||||
payload.selectedSkipperId = input.entryCrew.selectedSkipperId
|
payload.selectedSkipperId = input.entryCrew.selectedSkipperId
|
||||||
payload.selectedCrewIds = [...input.entryCrew.selectedCrewIds]
|
payload.selectedCrewIds = [...input.entryCrew.selectedCrewIds]
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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' }
|
||||||
|
}
|
||||||
@@ -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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 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) |
|
| 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) |
|
| 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`) | — |
|
| 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 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` |
|
| 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)
|
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)
|
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`)
|
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
|
12. **PWA-Stabilitaet:** PWA Boot Watchdog Soft → PWA Boot Watchdog Hard → PWA Boot Watchdog Fallback → PWA Boot Watchdog Manual Repair
|
||||||
|
|
||||||
## Entwicklung
|
## Entwicklung
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import collaborationRouter from './routes/collaboration.js'
|
|||||||
import signRouter from './routes/sign.js'
|
import signRouter from './routes/sign.js'
|
||||||
import pushRouter from './routes/push.js'
|
import pushRouter from './routes/push.js'
|
||||||
import weatherRouter from './routes/weather.js'
|
import weatherRouter from './routes/weather.js'
|
||||||
|
import tidesRouter from './routes/tides.js'
|
||||||
import aiRouter from './routes/ai.js'
|
import aiRouter from './routes/ai.js'
|
||||||
import feedbackRouter from './routes/feedback.js'
|
import feedbackRouter from './routes/feedback.js'
|
||||||
import adminRouter from './routes/admin.js'
|
import adminRouter from './routes/admin.js'
|
||||||
@@ -120,6 +121,7 @@ export function createApp(): express.Express {
|
|||||||
app.use('/api/sign', signRouter)
|
app.use('/api/sign', signRouter)
|
||||||
app.use('/api/push', pushRouter)
|
app.use('/api/push', pushRouter)
|
||||||
app.use('/api/weather', weatherRouter)
|
app.use('/api/weather', weatherRouter)
|
||||||
|
app.use('/api/tides', tidesRouter)
|
||||||
app.use('/api/ai', aiRouter)
|
app.use('/api/ai', aiRouter)
|
||||||
app.use('/api/feedback', feedbackRouter)
|
app.use('/api/feedback', feedbackRouter)
|
||||||
app.use('/api/admin', adminRouter)
|
app.use('/api/admin', adminRouter)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<number | null>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeocodingResult {
|
||||||
|
name: string
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
country_code?: string
|
||||||
|
admin1?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T>(url: string): Promise<T> {
|
||||||
|
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<number | null>,
|
||||||
|
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<TideLookupResult> {
|
||||||
|
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<MarineResponse>(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<GeocodingResult | null> {
|
||||||
|
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<TideLookupResult> {
|
||||||
|
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'
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user