feat: Abfrageort bei Gezeiten speichern und anzeigen

Ort oder GPS-Koordinaten werden im Entry-Payload persistiert und im
Tiden-Accordion sowie im Live-Journal-Modal als lesbare Zeile angezeigt.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 14:37:19 +02:00
parent 5d4e498528
commit 1bad0531b5
14 changed files with 218 additions and 61 deletions
+8
View File
@@ -4628,6 +4628,14 @@ html.theme-cupertino .events-scroll-container {
line-height: 1.45;
}
.tides-panel__location {
margin: 0;
font-size: 13.5px;
font-weight: 500;
color: var(--app-text);
line-height: 1.45;
}
.tides-panel__fields {
margin: 0;
}
+11 -27
View File
@@ -59,7 +59,11 @@ const formatSpeedKn = (speedKn: number) =>
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 {
buildTideLocationMeta,
formatTideLocationLabel,
resolveTideFetchLocation
} from '../utils/tideLocation.js'
import { parseTideTurtleForDate } from '../utils/tideTurtle.js'
import {
geolocationErrorI18nKey,
@@ -211,10 +215,7 @@ export default function LiveLogView({
const [tidePreview, setTidePreview] = useState<{
highWater: string
lowWater: string
placeName?: string
distanceKm?: number
source: 'gps' | 'departure'
departureQuery?: string
location: ReturnType<typeof buildTideLocationMeta>
} | null>(null)
const [isOnline, setIsOnline] = useState(navigator.onLine)
const [commentText, setCommentText] = useState('')
@@ -851,10 +852,7 @@ export default function LiveLogView({
setTidePreview({
highWater: parsed.highWater,
lowWater: parsed.lowWater,
placeName: parsed.placeName,
distanceKm: parsed.distanceKm,
source: location.source,
departureQuery: location.mode === 'by-place' ? location.query : undefined
location: buildTideLocationMeta(location, data)
})
setModal('tides')
} catch (err) {
@@ -886,7 +884,8 @@ export default function LiveLogView({
void runQuickAction(async () => {
await patchEntryTides(logbookId, entryId, {
highWater: preview.highWater,
lowWater: preview.lowWater
lowWater: preview.lowWater,
...preview.location
})
setTidePreview(null)
setModal('none')
@@ -1585,24 +1584,9 @@ export default function LiveLogView({
<p className="live-log-modal-hint" role="note">
{t('logs.tide_disclaimer')}
</p>
{tidePreview.source === 'departure' && tidePreview.departureQuery ? (
{formatTideLocationLabel(tidePreview.location, t) ? (
<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}
{formatTideLocationLabel(tidePreview.location, t)}
</p>
) : null}
<dl className="live-log-tide-preview">
+21 -29
View File
@@ -44,7 +44,13 @@ 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 {
buildTideLocationMeta,
formatTideLocationLabel,
pickTideLocationMeta,
resolveTideFetchLocation,
type TideLocationMeta
} from '../utils/tideLocation.js'
import { parseTideTurtleForDate } from '../utils/tideTurtle.js'
import {
buildTravelDayContext,
@@ -306,8 +312,8 @@ export default function LogEntryEditor({
const [tidesCollapsed, setTidesCollapsed] = useState(true)
const [tideHighWater, setTideHighWater] = useState('')
const [tideLowWater, setTideLowWater] = useState('')
const [tideLocation, setTideLocation] = useState<TideLocationMeta>({})
const [tidesLoading, setTidesLoading] = useState(false)
const [tideFetchHint, setTideFetchHint] = useState('')
const [tanksCollapsed, setTanksCollapsed] = useState(true)
const [columnSelectorOpen, setColumnSelectorOpen] = useState(false)
@@ -440,7 +446,7 @@ export default function LogEntryEditor({
consumption: parseAppDecimalOrZero(fuelConsumption)
},
greywater: { level: parseAppDecimalOrZero(greywaterLevel) },
tides: { highWater: tideHighWater, lowWater: tideLowWater },
tides: { highWater: tideHighWater, lowWater: tideLowWater, ...tideLocation },
trackDistanceNm: parseOptionalFormDecimal(trackDistanceNm),
trackSpeedMaxKn: parseOptionalFormDecimal(trackSpeedMaxKn),
trackSpeedAvgKn: parseOptionalFormDecimal(trackSpeedAvgKn),
@@ -453,7 +459,7 @@ export default function LogEntryEditor({
fwMorning, fwRefilled, fwEvening, fwConsumption,
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
greywaterLevel,
tideHighWater, tideLowWater,
tideHighWater, tideLowWater, tideLocation,
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
events,
entryCrew
@@ -504,6 +510,11 @@ export default function LogEntryEditor({
[fuelMorning, fuelRefilled, tankCapacities.fuelCapacityL]
)
const tideLocationLabel = useMemo(
() => formatTideLocationLabel(tideLocation, t),
[tideLocation, t]
)
const currentFingerprint = useMemo(() => {
const payload = buildPayloadForSigning()
return JSON.stringify({
@@ -936,7 +947,7 @@ export default function LogEntryEditor({
const preloadedTides = readLogEntryTides(preloadedEntry as Record<string, unknown>)
setTideHighWater(preloadedTides.highWater)
setTideLowWater(preloadedTides.lowWater)
setTideFetchHint('')
setTideLocation(pickTideLocationMeta(preloadedTides))
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
@@ -982,7 +993,7 @@ export default function LogEntryEditor({
const loadedTides = readLogEntryTides(decrypted as Record<string, unknown>)
setTideHighWater(loadedTides.highWater)
setTideLowWater(loadedTides.lowWater)
setTideFetchHint('')
setTideLocation(pickTideLocationMeta(loadedTides))
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
@@ -1300,7 +1311,6 @@ export default function LogEntryEditor({
}
setTidesLoading(true)
setTideFetchHint('')
try {
const loaded = await loadEntry(logbookId, entryId)
const eventsForLocation = loaded
@@ -1339,25 +1349,7 @@ export default function LogEntryEditor({
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
)
}
setTideLocation(buildTideLocationMeta(location, data))
} catch (err) {
if (err instanceof TidesApiError) {
if (err.code === 'OFFLINE') {
@@ -2250,9 +2242,9 @@ export default function LogEntryEditor({
<p className="form-hint" role="note">
{t('logs.tide_disclaimer')}
</p>
{tideFetchHint ? (
<p className="form-hint" role="status">
{tideFetchHint}
{tideLocationLabel ? (
<p className="tides-panel__location" role="status">
{tideLocationLabel}
</p>
) : null}
</div>
+3
View File
@@ -202,6 +202,9 @@
"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_data_for_position": "Forespørgsel for position {{lat}}, {{lng}}",
"tide_data_for_place": "Forespørgsel for {{place}}",
"tide_data_for_place_and_position": "Forespørgsel for {{place}} ({{lat}}, {{lng}})",
"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”.",
+3
View File
@@ -202,6 +202,9 @@
"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_data_for_position": "Abfrage für Position {{lat}}, {{lng}}",
"tide_data_for_place": "Abfrage für {{place}}",
"tide_data_for_place_and_position": "Abfrage für {{place}} ({{lat}}, {{lng}})",
"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.",
+3
View File
@@ -202,6 +202,9 @@
"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_data_for_position": "Query for position {{lat}}, {{lng}}",
"tide_data_for_place": "Query for {{place}}",
"tide_data_for_place_and_position": "Query for {{place}} ({{lat}}, {{lng}})",
"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”.",
+3
View File
@@ -202,6 +202,9 @@
"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_data_for_position": "Consulta para la posición {{lat}}, {{lng}}",
"tide_data_for_place": "Consulta para {{place}}",
"tide_data_for_place_and_position": "Consulta para {{place}} ({{lat}}, {{lng}})",
"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».",
+3
View File
@@ -202,6 +202,9 @@
"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_data_for_position": "Requête pour la position {{lat}}, {{lng}}",
"tide_data_for_place": "Requête pour {{place}}",
"tide_data_for_place_and_position": "Requête pour {{place}} ({{lat}}, {{lng}})",
"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 ».",
+3
View File
@@ -202,6 +202,9 @@
"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_data_for_position": "Forespørsel for posisjon {{lat}}, {{lng}}",
"tide_data_for_place": "Forespørsel for {{place}}",
"tide_data_for_place_and_position": "Forespørsel for {{place}} ({{lat}}, {{lng}})",
"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».",
+3
View File
@@ -202,6 +202,9 @@
"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_data_for_position": "Förfrågan för position {{lat}}, {{lng}}",
"tide_data_for_place": "Förfrågan för {{place}}",
"tide_data_for_place_and_position": "Förfrågan för {{place}} ({{lat}}, {{lng}})",
"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”.",
+20
View File
@@ -91,4 +91,24 @@ describe('buildLogEntryPayload tides', () => {
})
expect(payload.tides).toEqual({ highWater: '18:34', lowWater: '12:05' })
})
it('persists tide location metadata', () => {
const payload = buildLogEntryPayload({
...base,
tides: {
highWater: '06:00',
lowWater: '00:04',
locationSource: 'gps',
lat: '53.624526',
lng: '7.155263'
}
})
expect(payload.tides).toEqual({
highWater: '06:00',
lowWater: '00:04',
locationSource: 'gps',
lat: '53.624526',
lng: '7.155263'
})
})
})
+31 -2
View File
@@ -150,9 +150,15 @@ export function sortLogEventsByTime<T extends LogEventPayload>(events: T[]): T[]
return [...events].sort((a, b) => (a.time || '').localeCompare(b.time || ''))
}
export type TideLocationSource = 'gps' | 'departure' | 'geocoded'
export interface LogEntryTides {
highWater: string
lowWater: string
locationSource?: TideLocationSource
placeName?: string
lat?: string
lng?: string
}
export interface LogEntryPayloadInput {
@@ -172,13 +178,28 @@ export interface LogEntryPayloadInput {
entryCrew?: EntryCrewFields
}
function readTideLocationSource(value: unknown): TideLocationSource | undefined {
const source = String(value ?? '').trim()
if (source === 'gps' || source === 'departure' || source === 'geocoded') return source
return undefined
}
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()
const placeName = String(tides?.placeName ?? '').trim()
const lat = String(tides?.lat ?? '').trim()
const lng = String(tides?.lng ?? '').trim()
const locationSource = readTideLocationSource(tides?.locationSource)
return {
highWater: parseTimeToHHMM(highRaw) ?? '',
lowWater: parseTimeToHHMM(lowRaw) ?? ''
lowWater: parseTimeToHHMM(lowRaw) ?? '',
...(locationSource ? { locationSource } : {}),
...(placeName ? { placeName } : {}),
...(lat ? { lat } : {}),
...(lng ? { lng } : {})
}
}
@@ -211,7 +232,15 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string
const highWater = parseTimeToHHMM(input.tides.highWater) ?? ''
const lowWater = parseTimeToHHMM(input.tides.lowWater) ?? ''
if (highWater || lowWater) {
payload.tides = { highWater, lowWater }
const tides: Record<string, string> = { highWater, lowWater }
if (input.tides.locationSource) tides.locationSource = input.tides.locationSource
const placeName = input.tides.placeName?.trim()
if (placeName) tides.placeName = placeName
const lat = input.tides.lat?.trim()
if (lat) tides.lat = lat
const lng = input.tides.lng?.trim()
if (lng) tides.lng = lng
payload.tides = tides
}
}
+32 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest'
import { LIVE_EVENT_CODES } from './liveEventCodes.js'
import { resolveTideFetchLocation } from './tideLocation.js'
import {
buildTideLocationMeta,
formatTideLocationLabel,
resolveTideFetchLocation
} from './tideLocation.js'
const entryDate = '2026-06-11'
const nowMs = new Date('2026-06-11T12:00:00').getTime()
@@ -108,6 +112,33 @@ describe('resolveTideFetchLocation', () => {
expect(result).toEqual({ error: 'stale' })
})
it('builds GPS location metadata from nearby fetch', () => {
const meta = buildTideLocationMeta(
{ mode: 'nearby', lat: '53.624526', lng: '7.155263', source: 'gps' },
{ location: { name: 'Norddeich', lat: 53.62, lon: 7.15, source: 'coordinates' } }
)
expect(meta).toEqual({
locationSource: 'gps',
lat: '53.624526',
lng: '7.155263',
placeName: 'Norddeich'
})
})
it('formats coordinate and place labels', () => {
const t = (key: string, options?: Record<string, string>) =>
`${key}:${JSON.stringify(options ?? {})}`
expect(
formatTideLocationLabel(
{ locationSource: 'gps', lat: '53.62', lng: '7.15', placeName: 'Norddeich' },
t
)
).toContain('tide_data_for_place_and_position')
expect(
formatTideLocationLabel({ locationSource: 'gps', lat: '53.62', lng: '7.15' }, t)
).toContain('tide_data_for_position')
})
it('returns missing without position or departure', () => {
const result = resolveTideFetchLocation({
events: [],
+74 -2
View File
@@ -3,9 +3,11 @@ import {
getLatestLoggedPosition,
LIVE_LOG_TIDE_POSITION_MAX_AGE_MS
} from './liveEventCodes.js'
import type { LogEventPayload } from './logEntryPayload.js'
import type { LogEntryTides, LogEventPayload, TideLocationSource } from './logEntryPayload.js'
export type TideLocationSource = 'gps' | 'departure'
export type { TideLocationSource }
export type TideLocationMeta = Pick<LogEntryTides, 'locationSource' | 'placeName' | 'lat' | 'lng'>
export type TideFetchLocation =
| { mode: 'nearby'; lat: string; lng: string; source: 'gps' }
@@ -45,3 +47,73 @@ export function resolveTideFetchLocation(options: {
return { error: 'missing' }
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
export function buildTideLocationMeta(
fetchLocation: TideFetchLocation,
apiData: Record<string, unknown>
): TideLocationMeta {
const apiLocation = asRecord(apiData.location)
if (fetchLocation.mode === 'nearby') {
return {
locationSource: 'gps',
lat: fetchLocation.lat,
lng: fetchLocation.lng,
placeName: apiLocation?.name ? String(apiLocation.name) : undefined
}
}
const placeName = apiLocation?.name ? String(apiLocation.name) : fetchLocation.query
const lat = apiLocation?.lat != null && apiLocation.lat !== '' ? String(apiLocation.lat) : undefined
const lng = apiLocation?.lon != null && apiLocation.lon !== '' ? String(apiLocation.lon) : undefined
return {
locationSource: apiLocation?.source === 'geocoded' ? 'geocoded' : 'departure',
placeName,
lat,
lng
}
}
type TideLocationLabelT = (
key: string,
options?: Record<string, string | undefined>
) => string
export function formatTideLocationLabel(
tides: TideLocationMeta,
t: TideLocationLabelT
): string {
const placeName = tides.placeName?.trim()
const lat = tides.lat?.trim()
const lng = tides.lng?.trim()
if (placeName && lat && lng) {
return t('logs.tide_data_for_place_and_position', { place: placeName, lat, lng })
}
if (lat && lng) {
return t('logs.tide_data_for_position', { lat, lng })
}
if (placeName) {
if (tides.locationSource === 'departure') {
return t('logs.tide_fetched_from_departure', { place: placeName })
}
return t('logs.tide_data_for_place', { place: placeName })
}
return ''
}
export function pickTideLocationMeta(tides: LogEntryTides): TideLocationMeta {
return {
locationSource: tides.locationSource,
placeName: tides.placeName,
lat: tides.lat,
lng: tides.lng
}
}