Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 932a73ab0c | |||
| 5b9c1e3220 | |||
| abd5fe1ac8 | |||
| e03163735e | |||
| 0e0f045e84 | |||
| 4f519e34b4 | |||
| 7d6c908f65 | |||
| 0b46154696 | |||
| 9634370a08 | |||
| 1bad0531b5 | |||
| 5d4e498528 | |||
| d667062ec2 | |||
| 0bfc38f290 | |||
| 943ce838af | |||
| f7ad7001d7 |
@@ -64,3 +64,8 @@ NTFY_TOKEN=tk_example_ntfy_access_token
|
|||||||
# Staging: PLAUSIBLE_ENABLED=false (default in docker-compose.staging.yml)
|
# Staging: PLAUSIBLE_ENABLED=false (default in docker-compose.staging.yml)
|
||||||
PLAUSIBLE_ENABLED=true
|
PLAUSIBLE_ENABLED=true
|
||||||
PLAUSIBLE_HOST=https://plausible.elpatron.me
|
PLAUSIBLE_HOST=https://plausible.elpatron.me
|
||||||
|
|
||||||
|
# SEO (frontend container — robots.txt, X-Robots-Tag)
|
||||||
|
# Production: ROBOTS_NOINDEX=false (default)
|
||||||
|
# Staging: ROBOTS_NOINDEX=true (default in docker-compose.staging.yml)
|
||||||
|
# ROBOTS_NOINDEX=false
|
||||||
|
|||||||
@@ -16,8 +16,22 @@ case "$(printf '%s' "$PLAUSIBLE_ENABLED" | tr '[:upper:]' '[:lower:]')" in
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
ROBOTS_NOINDEX="${ROBOTS_NOINDEX:-false}"
|
||||||
|
case "$(printf '%s' "$ROBOTS_NOINDEX" | tr '[:upper:]' '[:lower:]')" in
|
||||||
|
true|1|yes)
|
||||||
|
export ROBOTS_NOINDEX_HEADER=' add_header X-Robots-Tag "noindex, nofollow" always;'
|
||||||
|
cat > /usr/share/nginx/html/robots.txt <<'EOF'
|
||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
|
EOF
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
export ROBOTS_NOINDEX_HEADER=''
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
export PLAUSIBLE_CSP
|
export PLAUSIBLE_CSP
|
||||||
envsubst '${PLAUSIBLE_CSP}' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf
|
envsubst '${PLAUSIBLE_CSP} ${ROBOTS_NOINDEX_HEADER}' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
cat > /usr/share/nginx/html/runtime-config.json <<EOF
|
cat > /usr/share/nginx/html/runtime-config.json <<EOF
|
||||||
{"plausibleEnabled":${PLAUSIBLE_ENABLED_JSON},"plausibleHost":"${PLAUSIBLE_HOST}"}
|
{"plausibleEnabled":${PLAUSIBLE_ENABLED_JSON},"plausibleHost":"${PLAUSIBLE_HOST}"}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ server {
|
|||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
|
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||||
|
${ROBOTS_NOINDEX_HEADER}
|
||||||
|
|
||||||
# Service worker and app shell must revalidate so PWA updates are detected
|
# Service worker and app shell must revalidate so PWA updates are detected
|
||||||
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
|
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://kapteins-daagbok.eu/sitemap.xml
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://kapteins-daagbok.eu/</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
@@ -3881,6 +3881,45 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tide-station-picker__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: min(50vh, 320px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tide-station-picker__option {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--app-border, rgba(255, 255, 255, 0.12));
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--app-surface-elevated, rgba(255, 255, 255, 0.04));
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tide-station-picker__option:hover {
|
||||||
|
border-color: var(--app-accent, #2dd4bf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tide-station-picker__name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tide-station-picker__meta {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.live-log-sail-pills {
|
.live-log-sail-pills {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
@@ -4607,6 +4646,49 @@ 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__location {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--app-text);
|
||||||
|
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;
|
||||||
|
|||||||
@@ -852,7 +852,6 @@ function App() {
|
|||||||
{activeTab === 'settings' && (
|
{activeTab === 'settings' && (
|
||||||
<SettingsForm
|
<SettingsForm
|
||||||
logbookId={activeLogbookId}
|
logbookId={activeLogbookId}
|
||||||
onLogbookRestored={selectLogbook}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -10,18 +10,23 @@ interface EventTimeInput24hProps {
|
|||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
'aria-label'?: string
|
'aria-label'?: string
|
||||||
|
fallback?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EventTimeInput24h({
|
export default function EventTimeInput24h({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
'aria-label': ariaLabel
|
'aria-label': ariaLabel,
|
||||||
|
fallback
|
||||||
}: EventTimeInput24hProps) {
|
}: EventTimeInput24hProps) {
|
||||||
const baseId = useId()
|
const baseId = useId()
|
||||||
const useNativePicker = preferNativeCameraPicker()
|
const useNativePicker = preferNativeCameraPicker()
|
||||||
const { hours, minutes } = useMemo(() => splitTimeHHMM(value), [value])
|
const { hours, minutes } = useMemo(() => splitTimeHHMM(value, fallback), [value, fallback])
|
||||||
const timeValue = useMemo(() => joinTimeHHMM(hours, minutes), [hours, minutes])
|
const timeValue = useMemo(() => {
|
||||||
|
if (!value.trim()) return ''
|
||||||
|
return joinTimeHHMM(hours, minutes)
|
||||||
|
}, [value, hours, minutes])
|
||||||
|
|
||||||
if (useNativePicker) {
|
if (useNativePicker) {
|
||||||
return (
|
return (
|
||||||
@@ -34,7 +39,7 @@ export default function EventTimeInput24h({
|
|||||||
value={timeValue}
|
value={timeValue}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = e.target.value
|
const next = e.target.value
|
||||||
if (next) onChange(next.slice(0, 5))
|
onChange(next ? next.slice(0, 5) : '')
|
||||||
}}
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
|
|||||||
@@ -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,23 @@ 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 { TidesApiError, type TideStation } from '../services/tides.js'
|
||||||
|
import { TideStationPickerModal } from './TideStationPickerModal.tsx'
|
||||||
|
import { TideLocationPickerModal } from './TideLocationPickerModal.tsx'
|
||||||
|
import {
|
||||||
|
buildTideLocationMeta,
|
||||||
|
formatTideLocationLabel,
|
||||||
|
getAvailableTideLocations,
|
||||||
|
type TideLocationOption,
|
||||||
|
type TideFetchLocation
|
||||||
|
} from '../utils/tideLocation.js'
|
||||||
|
import type { TideRole } from '../utils/logEntryPayload.js'
|
||||||
|
import {
|
||||||
|
fetchTidesForEntry,
|
||||||
|
fetchTidesForStationChoice,
|
||||||
|
type TideFetchNeedsStationPick,
|
||||||
|
type TideFetchResult
|
||||||
|
} from '../utils/tideFetch.js'
|
||||||
import {
|
import {
|
||||||
geolocationErrorI18nKey,
|
geolocationErrorI18nKey,
|
||||||
getCurrentPosition,
|
getCurrentPosition,
|
||||||
@@ -108,6 +127,8 @@ type LiveModal =
|
|||||||
| 'sog'
|
| 'sog'
|
||||||
| 'stw'
|
| 'stw'
|
||||||
| 'position'
|
| 'position'
|
||||||
|
| 'tides'
|
||||||
|
| 'tides_picker'
|
||||||
| 'photo'
|
| 'photo'
|
||||||
| 'voice'
|
| 'voice'
|
||||||
|
|
||||||
@@ -190,6 +211,8 @@ 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 [destination, setDestination] = 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 +223,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
|
||||||
|
location: ReturnType<typeof buildTideLocationMeta>
|
||||||
|
role: TideRole
|
||||||
|
} | null>(null)
|
||||||
|
const [tideStationPicker, setTideStationPicker] = useState<TideFetchNeedsStationPick | null>(null)
|
||||||
|
const [tideLocationPickerOptions, setTideLocationPickerOptions] = useState<TideLocationOption[] | 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 +333,8 @@ 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 || ''))
|
||||||
|
setDestination(String(loaded.data.destination || ''))
|
||||||
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 +818,145 @@ export default function LiveLogView({
|
|||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getRoleForLocationSource = (source: string): TideRole => {
|
||||||
|
if (source === 'gps') return 'gps'
|
||||||
|
if (source === 'destination') return 'destination'
|
||||||
|
return 'departure'
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTideStationPick = (pick: TideFetchNeedsStationPick, station: TideStation) => {
|
||||||
|
setTidesLoading(true)
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const result = await fetchTidesForStationChoice({
|
||||||
|
stationId: station.id,
|
||||||
|
entryDate: pick.entryDate,
|
||||||
|
fetchLocation: pick.fetchLocation,
|
||||||
|
queryLat: pick.queryLat,
|
||||||
|
queryLng: pick.queryLng,
|
||||||
|
analyticsSource: 'live_log'
|
||||||
|
})
|
||||||
|
setTideStationPicker(null)
|
||||||
|
setTidePreview({
|
||||||
|
highWater: result.highWater,
|
||||||
|
lowWater: result.lowWater,
|
||||||
|
location: result.location,
|
||||||
|
role: getRoleForLocationSource(pick.fetchLocation.source)
|
||||||
|
})
|
||||||
|
setModal('tides')
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TidesApiError && err.code === 'NO_DATA_FOR_DATE') {
|
||||||
|
void showAlert(t('logs.tide_no_data_for_date', { date: pick.entryDate }), t('logs.tides'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.error('Live log tide station fetch failed:', err)
|
||||||
|
void showAlert(t('logs.tide_fetch_failed'), t('logs.tides'))
|
||||||
|
} finally {
|
||||||
|
setTidesLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTideFetchForLocation = (fetchLocation: TideFetchLocation) => {
|
||||||
|
setTidesLoading(true)
|
||||||
|
setError(null)
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const outcome = await fetchTidesForEntry({
|
||||||
|
fetchLocation,
|
||||||
|
entryDate: date,
|
||||||
|
analyticsSource: 'live_log'
|
||||||
|
})
|
||||||
|
|
||||||
|
if ('kind' in outcome && outcome.kind === 'pick_station') {
|
||||||
|
setTideStationPicker(outcome as TideFetchNeedsStationPick)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = outcome as TideFetchResult
|
||||||
|
setTidePreview({
|
||||||
|
highWater: result.highWater,
|
||||||
|
lowWater: result.lowWater,
|
||||||
|
location: result.location,
|
||||||
|
role: getRoleForLocationSource(fetchLocation.source)
|
||||||
|
})
|
||||||
|
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') {
|
||||||
|
const query = fetchLocation.mode === 'by-place' ? fetchLocation.query : ''
|
||||||
|
void showAlert(t('logs.tide_place_not_found', { place: query.trim() }), t('logs.tides'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (err.code === 'NO_DATA_FOR_DATE') {
|
||||||
|
void showAlert(t('logs.tide_no_data_for_date', { date }), t('logs.tides'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (err.code === 'NOT_FOUND') {
|
||||||
|
void showAlert(t('logs.tide_no_data'), t('logs.tides'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.error('Live log tide fetch failed:', err)
|
||||||
|
void showAlert(t('logs.tide_fetch_failed'), t('logs.tides'))
|
||||||
|
} finally {
|
||||||
|
setTidesLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFetchTides = () => {
|
||||||
|
if (!entryId || busy || tidesLoading) return
|
||||||
|
if (!isOnline) {
|
||||||
|
void showAlert(t('logs.weather_offline'), t('logs.tides'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const available = getAvailableTideLocations({
|
||||||
|
departure,
|
||||||
|
destination,
|
||||||
|
events,
|
||||||
|
entryDate: date
|
||||||
|
})
|
||||||
|
|
||||||
|
if (available.length === 0) {
|
||||||
|
void showAlert(t('logs.tide_location_required'), t('logs.tides'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (available.length === 1) {
|
||||||
|
startTideFetchForLocation(available[0].fetchLocation)
|
||||||
|
} else {
|
||||||
|
setTideLocationPickerOptions(available)
|
||||||
|
setModal('tides_picker')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmTides = () => {
|
||||||
|
if (!entryId || !tidePreview || busy) return
|
||||||
|
const preview = tidePreview
|
||||||
|
void runQuickAction(async () => {
|
||||||
|
await patchEntryTides(logbookId, entryId, preview.role, {
|
||||||
|
highWater: preview.highWater,
|
||||||
|
lowWater: preview.lowWater,
|
||||||
|
...preview.location
|
||||||
|
})
|
||||||
|
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 +1430,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 +1632,70 @@ export default function LiveLogView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tideStationPicker ? (
|
||||||
|
<TideStationPickerModal
|
||||||
|
title={t('logs.tide_pick_station_title')}
|
||||||
|
hint={t('logs.tide_pick_station_hint')}
|
||||||
|
cancelLabel={t('logs.live_cancel')}
|
||||||
|
stations={tideStationPicker.stations}
|
||||||
|
onCancel={() => setTideStationPicker(null)}
|
||||||
|
onSelect={(station) => handleTideStationPick(tideStationPicker, station)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{modal === 'tides_picker' && tideLocationPickerOptions ? (
|
||||||
|
<TideLocationPickerModal
|
||||||
|
title={t('logs.tide_location_picker_title')}
|
||||||
|
hint={t('logs.tide_location_picker_hint')}
|
||||||
|
cancelLabel={t('logs.live_cancel')}
|
||||||
|
options={tideLocationPickerOptions}
|
||||||
|
onCancel={() => {
|
||||||
|
setTideLocationPickerOptions(null)
|
||||||
|
closeModal()
|
||||||
|
}}
|
||||||
|
onSelect={(option) => {
|
||||||
|
setTideLocationPickerOptions(null)
|
||||||
|
closeModal()
|
||||||
|
startTideFetchForLocation(option.fetchLocation)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
{formatTideLocationLabel(tidePreview.location, t) ? (
|
||||||
|
<p className="live-log-modal-hint" role="status">
|
||||||
|
{formatTideLocationLabel(tidePreview.location, t)}
|
||||||
|
</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,12 +8,12 @@ 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'
|
||||||
import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js'
|
import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js'
|
||||||
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
|
import { parseLiveVoiceRemark, getLastLoggedPositionWithin } from '../utils/liveEventCodes.js'
|
||||||
import { deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
|
import { deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
|
||||||
import type { PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx'
|
import type { PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx'
|
||||||
import SignatureSection from './SignatureSection.tsx'
|
import SignatureSection from './SignatureSection.tsx'
|
||||||
@@ -33,7 +33,18 @@ 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,
|
||||||
|
readLogEntryTidesMap,
|
||||||
|
sortLogEventsByTime,
|
||||||
|
normalizeLogEvent,
|
||||||
|
hasUnsavedEventDraft,
|
||||||
|
currentLocalTimeHHMM,
|
||||||
|
isValidTimeHHMM,
|
||||||
|
type LogEventPayload,
|
||||||
|
type LogEntryTidesMap,
|
||||||
|
type TideRole
|
||||||
|
} 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,6 +54,22 @@ 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 { TidesApiError, type TideStation } from '../services/tides.js'
|
||||||
|
import { TideStationPickerModal } from './TideStationPickerModal.tsx'
|
||||||
|
import { TideLocationPickerModal } from './TideLocationPickerModal.tsx'
|
||||||
|
import {
|
||||||
|
formatTideLocationLabel,
|
||||||
|
getAvailableTideLocations,
|
||||||
|
type TideLocationMeta,
|
||||||
|
type TideLocationOption,
|
||||||
|
type TideFetchLocation
|
||||||
|
} from '../utils/tideLocation.js'
|
||||||
|
import {
|
||||||
|
fetchTidesForEntry,
|
||||||
|
fetchTidesForStationChoice,
|
||||||
|
type TideFetchNeedsStationPick,
|
||||||
|
type TideFetchResult
|
||||||
|
} from '../utils/tideFetch.js'
|
||||||
import {
|
import {
|
||||||
buildTravelDayContext,
|
buildTravelDayContext,
|
||||||
fetchTravelDaySummaryUsage,
|
fetchTravelDaySummaryUsage,
|
||||||
@@ -164,6 +191,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: readLogEntryTidesMap(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 +326,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 [tidesMap, setTidesMap] = useState<LogEntryTidesMap>({})
|
||||||
|
const [tideLocationPickerOptions, setTideLocationPickerOptions] = useState<TideLocationOption[] | null>(null)
|
||||||
|
const [tidesLoading, setTidesLoading] = useState(false)
|
||||||
|
const [tideStationPicker, setTideStationPicker] = useState<TideFetchNeedsStationPick | null>(null)
|
||||||
const [tanksCollapsed, setTanksCollapsed] = useState(true)
|
const [tanksCollapsed, setTanksCollapsed] = useState(true)
|
||||||
|
|
||||||
const [columnSelectorOpen, setColumnSelectorOpen] = useState(false)
|
const [columnSelectorOpen, setColumnSelectorOpen] = useState(false)
|
||||||
@@ -411,7 +444,7 @@ export default function LogEntryEditor({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildPayloadForSigning = useCallback((eventsOverride?: LogEvent[]) => {
|
const buildPayloadForSigning = useCallback((eventsOverride?: LogEvent[], tidesOverride?: LogEntryTidesMap) => {
|
||||||
return buildLogEntryPayload({
|
return buildLogEntryPayload({
|
||||||
date,
|
date,
|
||||||
dayOfTravel,
|
dayOfTravel,
|
||||||
@@ -430,6 +463,7 @@ export default function LogEntryEditor({
|
|||||||
consumption: parseAppDecimalOrZero(fuelConsumption)
|
consumption: parseAppDecimalOrZero(fuelConsumption)
|
||||||
},
|
},
|
||||||
greywater: { level: parseAppDecimalOrZero(greywaterLevel) },
|
greywater: { level: parseAppDecimalOrZero(greywaterLevel) },
|
||||||
|
tides: tidesOverride ?? tidesMap,
|
||||||
trackDistanceNm: parseOptionalFormDecimal(trackDistanceNm),
|
trackDistanceNm: parseOptionalFormDecimal(trackDistanceNm),
|
||||||
trackSpeedMaxKn: parseOptionalFormDecimal(trackSpeedMaxKn),
|
trackSpeedMaxKn: parseOptionalFormDecimal(trackSpeedMaxKn),
|
||||||
trackSpeedAvgKn: parseOptionalFormDecimal(trackSpeedAvgKn),
|
trackSpeedAvgKn: parseOptionalFormDecimal(trackSpeedAvgKn),
|
||||||
@@ -442,6 +476,7 @@ export default function LogEntryEditor({
|
|||||||
fwMorning, fwRefilled, fwEvening, fwConsumption,
|
fwMorning, fwRefilled, fwEvening, fwConsumption,
|
||||||
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
|
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
|
||||||
greywaterLevel,
|
greywaterLevel,
|
||||||
|
tidesMap,
|
||||||
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
|
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
|
||||||
events,
|
events,
|
||||||
entryCrew
|
entryCrew
|
||||||
@@ -492,6 +527,15 @@ export default function LogEntryEditor({
|
|||||||
[fuelMorning, fuelRefilled, tankCapacities.fuelCapacityL]
|
[fuelMorning, fuelRefilled, tankCapacities.fuelCapacityL]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const getTideLocationLabel = useCallback(
|
||||||
|
(role: TideRole) => {
|
||||||
|
const tideData = tidesMap[role]
|
||||||
|
if (!tideData) return ''
|
||||||
|
return formatTideLocationLabel(tideData, t)
|
||||||
|
},
|
||||||
|
[tidesMap, t]
|
||||||
|
)
|
||||||
|
|
||||||
const currentFingerprint = useMemo(() => {
|
const currentFingerprint = useMemo(() => {
|
||||||
const payload = buildPayloadForSigning()
|
const payload = buildPayloadForSigning()
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
@@ -575,12 +619,14 @@ export default function LogEntryEditor({
|
|||||||
signCrew?: SignatureValue | ''
|
signCrew?: SignatureValue | ''
|
||||||
aiSummary?: string
|
aiSummary?: string
|
||||||
aiSummaryGeneratedAt?: string
|
aiSummaryGeneratedAt?: string
|
||||||
|
tidesOverride?: LogEntryTidesMap
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
if (readOnly) return
|
if (readOnly) return
|
||||||
|
|
||||||
const normalized = Array.isArray(options) ? { eventsOverride: options } : (options ?? {})
|
const normalized = Array.isArray(options) ? { eventsOverride: options } : (options ?? {})
|
||||||
const eventsOverride = normalized.eventsOverride
|
const eventsOverride = normalized.eventsOverride
|
||||||
|
const tidesOverride = normalized.tidesOverride
|
||||||
const skipperToSave = normalized.signSkipper !== undefined ? normalized.signSkipper : signSkipper
|
const skipperToSave = normalized.signSkipper !== undefined ? normalized.signSkipper : signSkipper
|
||||||
const crewToSave = normalized.signCrew !== undefined ? normalized.signCrew : signCrew
|
const crewToSave = normalized.signCrew !== undefined ? normalized.signCrew : signCrew
|
||||||
let summaryToSave = normalized.aiSummary !== undefined ? normalized.aiSummary : aiSummary
|
let summaryToSave = normalized.aiSummary !== undefined ? normalized.aiSummary : aiSummary
|
||||||
@@ -608,7 +654,7 @@ export default function LogEntryEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const entryData: Record<string, unknown> = {
|
const entryData: Record<string, unknown> = {
|
||||||
...buildPayloadForSigning(eventsOverride),
|
...buildPayloadForSigning(eventsOverride, tidesOverride),
|
||||||
signSkipper: normalizedSerializedSignature(skipperToSave),
|
signSkipper: normalizedSerializedSignature(skipperToSave),
|
||||||
signCrew: normalizedSerializedSignature(crewToSave)
|
signCrew: normalizedSerializedSignature(crewToSave)
|
||||||
}
|
}
|
||||||
@@ -921,6 +967,9 @@ export default function LogEntryEditor({
|
|||||||
setGreywaterLevel('0')
|
setGreywaterLevel('0')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const preloadedTides = readLogEntryTidesMap(preloadedEntry as Record<string, unknown>)
|
||||||
|
setTidesMap(preloadedTides)
|
||||||
|
|
||||||
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 +1011,9 @@ export default function LogEntryEditor({
|
|||||||
setGreywaterLevel('0')
|
setGreywaterLevel('0')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadedTides = readLogEntryTidesMap(decrypted as Record<string, unknown>)
|
||||||
|
setTidesMap(loadedTides)
|
||||||
|
|
||||||
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 +1323,131 @@ export default function LogEntryEditor({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getRoleForLocationSource = (source: string): TideRole => {
|
||||||
|
if (source === 'gps') return 'gps'
|
||||||
|
if (source === 'destination') return 'destination'
|
||||||
|
return 'departure'
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyTideFetchResult = async (role: TideRole, result: {
|
||||||
|
highWater: string
|
||||||
|
lowWater: string
|
||||||
|
location: TideLocationMeta
|
||||||
|
}) => {
|
||||||
|
const nextTides = {
|
||||||
|
highWater: result.highWater,
|
||||||
|
lowWater: result.lowWater,
|
||||||
|
...result.location
|
||||||
|
}
|
||||||
|
const nextTidesMap = {
|
||||||
|
...tidesMap,
|
||||||
|
[role]: nextTides
|
||||||
|
}
|
||||||
|
setTidesMap(nextTidesMap)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await persistEntryToDb({ tidesOverride: nextTidesMap })
|
||||||
|
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to auto-save after tide fetch:', err)
|
||||||
|
showAlert(t('logs.tide_fetch_failed'), t('logs.tide_fetch_btn'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTideStationPick = async (pick: TideFetchNeedsStationPick, station: TideStation) => {
|
||||||
|
setTidesLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await fetchTidesForStationChoice({
|
||||||
|
stationId: station.id,
|
||||||
|
entryDate: pick.entryDate,
|
||||||
|
fetchLocation: pick.fetchLocation,
|
||||||
|
queryLat: pick.queryLat,
|
||||||
|
queryLng: pick.queryLng,
|
||||||
|
analyticsSource: 'entry_editor'
|
||||||
|
})
|
||||||
|
const role = getRoleForLocationSource(pick.fetchLocation.source)
|
||||||
|
await applyTideFetchResult(role, result)
|
||||||
|
setTideStationPicker(null)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TidesApiError && err.code === 'NO_DATA_FOR_DATE') {
|
||||||
|
showAlert(t('logs.tide_no_data_for_date', { date: pick.entryDate }), t('logs.tide_fetch_btn'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.error('Tide station fetch failed:', err)
|
||||||
|
showAlert(t('logs.tide_fetch_failed'), t('logs.tide_fetch_btn'))
|
||||||
|
} finally {
|
||||||
|
setTidesLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTideFetchForLocation = async (fetchLocation: TideFetchLocation) => {
|
||||||
|
setTidesLoading(true)
|
||||||
|
try {
|
||||||
|
const outcome = await fetchTidesForEntry({
|
||||||
|
fetchLocation,
|
||||||
|
entryDate: date,
|
||||||
|
analyticsSource: 'entry_editor'
|
||||||
|
})
|
||||||
|
|
||||||
|
if ('kind' in outcome && outcome.kind === 'pick_station') {
|
||||||
|
setTideStationPicker(outcome as TideFetchNeedsStationPick)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = getRoleForLocationSource(fetchLocation.source)
|
||||||
|
await applyTideFetchResult(role, outcome as TideFetchResult)
|
||||||
|
} 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') {
|
||||||
|
const query = fetchLocation.mode === 'by-place' ? fetchLocation.query : ''
|
||||||
|
showAlert(t('logs.tide_place_not_found', { place: query.trim() }), t('logs.tide_fetch_btn'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (err.code === 'NO_DATA_FOR_DATE') {
|
||||||
|
showAlert(t('logs.tide_no_data_for_date', { date }), t('logs.tide_fetch_btn'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (err.code === 'NOT_FOUND') {
|
||||||
|
showAlert(t('logs.tide_no_data'), t('logs.tide_fetch_btn'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.error('Tide fetch failed:', err)
|
||||||
|
showAlert(t('logs.tide_fetch_failed'), t('logs.tide_fetch_btn'))
|
||||||
|
} finally {
|
||||||
|
setTidesLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFetchTides = async () => {
|
||||||
|
if (!isOnline) {
|
||||||
|
showAlert(t('logs.weather_offline'), t('logs.tide_fetch_btn'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const available = getAvailableTideLocations({
|
||||||
|
departure,
|
||||||
|
destination,
|
||||||
|
events,
|
||||||
|
entryDate: date
|
||||||
|
})
|
||||||
|
|
||||||
|
if (available.length === 0) {
|
||||||
|
showAlert(t('logs.tide_location_required'), t('logs.tide_fetch_btn'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (available.length === 1) {
|
||||||
|
await startTideFetchForLocation(available[0].fetchLocation)
|
||||||
|
} else {
|
||||||
|
setTideLocationPickerOptions(available)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleGenerateAiSummary = async () => {
|
const handleGenerateAiSummary = async () => {
|
||||||
if (!canSignSkipper || readOnly || aiSummaryLoading) return
|
if (!canSignSkipper || readOnly || aiSummaryLoading) return
|
||||||
if (!getAiAuthorized()) {
|
if (!getAiAuthorized()) {
|
||||||
@@ -2113,6 +2290,122 @@ 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" style={{ marginBottom: '16px' }}>
|
||||||
|
<p className="form-hint" role="note">
|
||||||
|
{t('logs.tide_disclaimer')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(['departure', 'destination', 'gps'] as TideRole[]).map((role) => {
|
||||||
|
const tideData = tidesMap[role] || { highWater: '', lowWater: '' }
|
||||||
|
const label = getTideLocationLabel(role)
|
||||||
|
|
||||||
|
const isAvailable = (role === 'departure' && departure.trim()) ||
|
||||||
|
(role === 'destination' && destination.trim()) ||
|
||||||
|
(role === 'gps' && getLastLoggedPositionWithin(events, date) != null)
|
||||||
|
|
||||||
|
const hasData = Boolean(tidesMap[role]?.highWater || tidesMap[role]?.lowWater)
|
||||||
|
|
||||||
|
if (!isAvailable && !hasData) return null
|
||||||
|
|
||||||
|
const roleTitle = role === 'departure'
|
||||||
|
? t('logs.tide_role_departure')
|
||||||
|
: role === 'destination'
|
||||||
|
? t('logs.tide_role_destination')
|
||||||
|
: t('logs.tide_role_gps')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={role} className="tide-role-section mb-6" style={{ borderBottom: '1px solid var(--border-color, #eee)', paddingBottom: '16px', marginBottom: '16px' }}>
|
||||||
|
<h4 style={{ margin: '0 0 8px 0', display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--text-color-primary, #333)' }}>
|
||||||
|
<Waves size={16} />
|
||||||
|
{roleTitle}
|
||||||
|
</h4>
|
||||||
|
{label ? (
|
||||||
|
<p className="tides-panel__location" role="status" style={{ fontSize: '0.85em', color: 'var(--text-color-secondary, #666)', margin: '0 0 12px 0' }}>
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="form-grid tides-panel__fields">
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('logs.tide_high_water')}</label>
|
||||||
|
<EventTimeInput24h
|
||||||
|
value={tideData.highWater}
|
||||||
|
onChange={(val) => {
|
||||||
|
const nextTidesMap = {
|
||||||
|
...tidesMap,
|
||||||
|
[role]: { ...tideData, highWater: val }
|
||||||
|
}
|
||||||
|
setTidesMap(nextTidesMap)
|
||||||
|
void persistEntryToDb({ tidesOverride: nextTidesMap })
|
||||||
|
}}
|
||||||
|
disabled={readOnly || saving || tidesLoading}
|
||||||
|
aria-label={`${roleTitle} - ${t('logs.tide_high_water')}`}
|
||||||
|
fallback="00:00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('logs.tide_low_water')}</label>
|
||||||
|
<EventTimeInput24h
|
||||||
|
value={tideData.lowWater}
|
||||||
|
onChange={(val) => {
|
||||||
|
const nextTidesMap = {
|
||||||
|
...tidesMap,
|
||||||
|
[role]: { ...tideData, lowWater: val }
|
||||||
|
}
|
||||||
|
setTidesMap(nextTidesMap)
|
||||||
|
void persistEntryToDb({ tidesOverride: nextTidesMap })
|
||||||
|
}}
|
||||||
|
disabled={readOnly || saving || tidesLoading}
|
||||||
|
aria-label={`${roleTitle} - ${t('logs.tide_low_water')}`}
|
||||||
|
fallback="00:00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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
|
||||||
@@ -2930,6 +3223,33 @@ export default function LogEntryEditor({
|
|||||||
nmeaArchive={nmeaArchive}
|
nmeaArchive={nmeaArchive}
|
||||||
onImport={handleNmeaImport}
|
onImport={handleNmeaImport}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{tideStationPicker ? (
|
||||||
|
<TideStationPickerModal
|
||||||
|
title={t('logs.tide_pick_station_title')}
|
||||||
|
hint={t('logs.tide_pick_station_hint')}
|
||||||
|
cancelLabel={t('logs.live_cancel')}
|
||||||
|
stations={tideStationPicker.stations}
|
||||||
|
onCancel={() => setTideStationPicker(null)}
|
||||||
|
onSelect={(station) => {
|
||||||
|
void handleTideStationPick(tideStationPicker, station)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{tideLocationPickerOptions ? (
|
||||||
|
<TideLocationPickerModal
|
||||||
|
title={t('logs.tide_location_picker_title')}
|
||||||
|
hint={t('logs.tide_location_picker_hint')}
|
||||||
|
cancelLabel={t('logs.live_cancel')}
|
||||||
|
options={tideLocationPickerOptions}
|
||||||
|
onCancel={() => setTideLocationPickerOptions(null)}
|
||||||
|
onSelect={async (option) => {
|
||||||
|
setTideLocationPickerOptions(null)
|
||||||
|
await startTideFetchForLocation(option.fetchLocation)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,14 @@
|
|||||||
import { useRef, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Archive, Download, Upload, Check, AlertTriangle } from 'lucide-react'
|
import { Archive, Download, Check, AlertTriangle } from 'lucide-react'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
|
||||||
import {
|
import {
|
||||||
downloadBackupBlob,
|
downloadBackupBlob,
|
||||||
exportLogbookBackup,
|
exportLogbookBackup
|
||||||
formatBackupBytes,
|
|
||||||
parseLogbookBackupFile,
|
|
||||||
previewLogbookBackup,
|
|
||||||
restoreLogbookBackup,
|
|
||||||
BACKUP_SIZE_CONFIRM_BYTES,
|
|
||||||
type ParsedLogbookBackup,
|
|
||||||
type LogbookBackupPreview
|
|
||||||
} from '../services/logbookBackup.js'
|
} from '../services/logbookBackup.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
|
|
||||||
|
|
||||||
interface LogbookBackupPanelProps {
|
interface LogbookBackupPanelProps {
|
||||||
logbookId: string
|
logbookId: string
|
||||||
onRestored?: (logbookId: string, title: string) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapBackupError(code: string, t: (key: string) => string): string {
|
function mapBackupError(code: string, t: (key: string) => string): string {
|
||||||
@@ -49,21 +39,12 @@ function mapBackupError(code: string, t: (key: string) => string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBackupPanelProps) {
|
export default function LogbookBackupPanel({ logbookId }: LogbookBackupPanelProps) {
|
||||||
const { t, i18n } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { showConfirm } = useDialog()
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
|
|
||||||
const [exportPassphrase, setExportPassphrase] = useState('')
|
const [exportPassphrase, setExportPassphrase] = useState('')
|
||||||
const [exportConfirm, setExportConfirm] = useState('')
|
const [exportConfirm, setExportConfirm] = useState('')
|
||||||
const [exporting, setExporting] = useState(false)
|
const [exporting, setExporting] = useState(false)
|
||||||
|
|
||||||
const [importPassphrase, setImportPassphrase] = useState('')
|
|
||||||
const [importFile, setImportFile] = useState<File | null>(null)
|
|
||||||
const [importPreview, setImportPreview] = useState<LogbookBackupPreview | null>(null)
|
|
||||||
const [parsedBackup, setParsedBackup] = useState<ParsedLogbookBackup | null>(null)
|
|
||||||
const [importing, setImporting] = useState(false)
|
|
||||||
const [previewing, setPreviewing] = useState(false)
|
|
||||||
const [exportProgress, setExportProgress] = useState<string | null>(null)
|
const [exportProgress, setExportProgress] = useState<string | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [success, setSuccess] = useState<string | null>(null)
|
const [success, setSuccess] = useState<string | null>(null)
|
||||||
@@ -76,11 +57,6 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
await handleExport()
|
await handleExport()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleImportSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
await handleRestore()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
setError(null)
|
setError(null)
|
||||||
setSuccess(null)
|
setSuccess(null)
|
||||||
@@ -128,105 +104,6 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setError(null)
|
|
||||||
setSuccess(null)
|
|
||||||
setImportPreview(null)
|
|
||||||
setParsedBackup(null)
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
setImportFile(file ?? null)
|
|
||||||
if (!file) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
const backup = await parseLogbookBackupFile(file)
|
|
||||||
setParsedBackup(backup)
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
|
||||||
setError(mapBackupError(message, t))
|
|
||||||
setImportFile(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePreviewImport = async () => {
|
|
||||||
if (!parsedBackup || !importPassphrase) return
|
|
||||||
setPreviewing(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const preview = await previewLogbookBackup(parsedBackup, importPassphrase)
|
|
||||||
setImportPreview(preview)
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setImportPreview(null)
|
|
||||||
setError(t('settings.backup_wrong_passphrase'))
|
|
||||||
} finally {
|
|
||||||
setPreviewing(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => {
|
|
||||||
if (!parsedBackup || !importPassphrase) return
|
|
||||||
|
|
||||||
if (parsedBackup.manifest.totalUncompressedBytes > BACKUP_SIZE_CONFIRM_BYTES) {
|
|
||||||
const ok = await showConfirm(
|
|
||||||
t('settings.backup_import_size_confirm', {
|
|
||||||
size: formatBackupBytes(parsedBackup.manifest.totalUncompressedBytes)
|
|
||||||
}),
|
|
||||||
t('settings.backup_restore_title'),
|
|
||||||
t('logs.confirm_yes'),
|
|
||||||
t('logs.confirm_no')
|
|
||||||
)
|
|
||||||
if (!ok) return
|
|
||||||
}
|
|
||||||
|
|
||||||
setImporting(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const result = await restoreLogbookBackup(parsedBackup, importPassphrase, options)
|
|
||||||
setSuccess(t('settings.backup_restore_success', { title: result.title }))
|
|
||||||
setImportFile(null)
|
|
||||||
setImportPassphrase('')
|
|
||||||
setImportPreview(null)
|
|
||||||
setParsedBackup(null)
|
|
||||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
|
||||||
trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, {
|
|
||||||
entries: parsedBackup.manifest.counts.entries,
|
|
||||||
photos: parsedBackup.manifest.counts.photos,
|
|
||||||
voiceMemos: parsedBackup.manifest.counts.voiceMemos,
|
|
||||||
bytes: parsedBackup.manifest.totalUncompressedBytes,
|
|
||||||
mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id'
|
|
||||||
})
|
|
||||||
onRestored?.(result.logbookId, result.title)
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
|
||||||
if (message === 'BACKUP_ID_CONFLICT') {
|
|
||||||
const overwrite = await showConfirm(
|
|
||||||
t('settings.backup_overwrite_confirm'),
|
|
||||||
t('settings.backup_restore_title'),
|
|
||||||
t('logs.confirm_yes'),
|
|
||||||
t('logs.confirm_no')
|
|
||||||
)
|
|
||||||
if (overwrite) {
|
|
||||||
setImporting(false)
|
|
||||||
return handleRestore({ overwrite: true })
|
|
||||||
}
|
|
||||||
const asNew = await showConfirm(
|
|
||||||
t('settings.backup_new_id_confirm'),
|
|
||||||
t('settings.backup_restore_title'),
|
|
||||||
t('logs.confirm_yes'),
|
|
||||||
t('logs.confirm_no')
|
|
||||||
)
|
|
||||||
if (asNew) {
|
|
||||||
setImporting(false)
|
|
||||||
return handleRestore({ assignNewId: true })
|
|
||||||
}
|
|
||||||
setError(t('settings.backup_restore_cancelled'))
|
|
||||||
} else {
|
|
||||||
setError(mapBackupError(message, t))
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setImporting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="member-editor-card glass mt-6 backup-panel" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}>
|
<div className="member-editor-card glass mt-6 backup-panel" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
|
||||||
@@ -306,93 +183,6 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="backup-section backup-section--import" aria-labelledby="backup-import-heading">
|
|
||||||
<h4 id="backup-import-heading" className="backup-section-title">
|
|
||||||
<Upload size={16} aria-hidden="true" />
|
|
||||||
{t('settings.backup_restore_title')}
|
|
||||||
</h4>
|
|
||||||
<p className="text-muted backup-section-desc">{t('settings.backup_restore_desc')}</p>
|
|
||||||
|
|
||||||
<form onSubmit={handleImportSubmit} className="backup-import-form">
|
|
||||||
<div className="input-group">
|
|
||||||
<label htmlFor="backup-import-file">{t('settings.backup_file_label')}</label>
|
|
||||||
<input
|
|
||||||
id="backup-import-file"
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".daagbok,application/zip"
|
|
||||||
className="input-text"
|
|
||||||
onChange={handleFileChange}
|
|
||||||
disabled={importing}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{importFile && (
|
|
||||||
<>
|
|
||||||
<div className="input-group">
|
|
||||||
<label htmlFor="backup-import-passphrase">{t('settings.backup_passphrase')}</label>
|
|
||||||
<input
|
|
||||||
id="backup-import-passphrase"
|
|
||||||
name="backup-import-passphrase"
|
|
||||||
type="password"
|
|
||||||
className="input-text"
|
|
||||||
value={importPassphrase}
|
|
||||||
onChange={(e) => {
|
|
||||||
setImportPassphrase(e.target.value)
|
|
||||||
setImportPreview(null)
|
|
||||||
}}
|
|
||||||
autoComplete="current-password"
|
|
||||||
disabled={importing}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="backup-actions-row">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn secondary"
|
|
||||||
onClick={handlePreviewImport}
|
|
||||||
disabled={previewing || importing || !importPassphrase}
|
|
||||||
>
|
|
||||||
{previewing ? t('settings.backup_previewing') : t('settings.backup_preview_btn')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn primary"
|
|
||||||
disabled={importing || !importPassphrase}
|
|
||||||
>
|
|
||||||
<Upload size={16} />
|
|
||||||
{importing ? t('settings.backup_restoring') : t('settings.backup_restore_btn')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{importPreview && (
|
|
||||||
<div className="backup-preview glass">
|
|
||||||
<p className="backup-preview-title">{importPreview.title}</p>
|
|
||||||
<ul className="backup-preview-stats">
|
|
||||||
<li>{t('settings.backup_stat_entries', { count: importPreview.counts.entries })}</li>
|
|
||||||
<li>{t('settings.backup_stat_photos', { count: importPreview.counts.photos })}</li>
|
|
||||||
<li>{t('settings.backup_stat_voice', { count: importPreview.counts.voiceMemos })}</li>
|
|
||||||
<li>{t('settings.backup_stat_crew', { count: importPreview.counts.crews })}</li>
|
|
||||||
<li>{t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}</li>
|
|
||||||
<li className="text-muted">
|
|
||||||
{t('settings.backup_stat_size', {
|
|
||||||
size: formatBackupBytes(importPreview.totalUncompressedBytes)
|
|
||||||
})}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p className="text-muted backup-preview-date">
|
|
||||||
{t('settings.backup_exported_at', {
|
|
||||||
date: formatAppDateTime(importPreview.exportedAt, i18n.language)
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
|||||||
import { getErrorMessage } from '../utils/errors.js'
|
import { getErrorMessage } from '../utils/errors.js'
|
||||||
import { logoutUser } from '../services/auth.js'
|
import { logoutUser } from '../services/auth.js'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { BookOpen, Plus, Trash2, LogOut, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
|
import { BookOpen, Plus, Trash2, LogOut, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown, Upload } from 'lucide-react'
|
||||||
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
||||||
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
||||||
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
|
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
|
||||||
import AdminHeaderButton from './AdminHeaderButton.tsx'
|
import AdminHeaderButton from './AdminHeaderButton.tsx'
|
||||||
|
import LogbookRestorePanel from './LogbookRestorePanel.tsx'
|
||||||
|
|
||||||
interface LogbookDashboardProps {
|
interface LogbookDashboardProps {
|
||||||
onSelectLogbook: (id: string, title: string) => void
|
onSelectLogbook: (id: string, title: string) => void
|
||||||
@@ -67,6 +68,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
|
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
|
||||||
const filterInputRef = useRef<HTMLInputElement>(null)
|
const filterInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [online, setOnline] = useState(navigator.onLine)
|
const [online, setOnline] = useState(navigator.onLine)
|
||||||
|
const [showRestore, setShowRestore] = useState(false)
|
||||||
|
|
||||||
const { pendingCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator()
|
const { pendingCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator()
|
||||||
|
|
||||||
@@ -434,6 +436,24 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{error && <div className="auth-error mt-4">{error}</div>}
|
{error && <div className="auth-error mt-4">{error}</div>}
|
||||||
|
|
||||||
|
<div style={{ marginTop: '20px', borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: '16px', textAlign: 'center' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-link"
|
||||||
|
style={{ fontSize: '13.5px', color: 'var(--app-text-muted)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: '6px' }}
|
||||||
|
onClick={() => setShowRestore(!showRestore)}
|
||||||
|
>
|
||||||
|
<Upload size={14} />
|
||||||
|
{t('settings.backup_restore_title')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showRestore && (
|
||||||
|
<div style={{ marginTop: '16px', textAlign: 'left' }}>
|
||||||
|
<LogbookRestorePanel onRestored={onSelectLogbook} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Right Side: Logbooks list */}
|
{/* Right Side: Logbooks list */}
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Upload, Check, AlertTriangle } from 'lucide-react'
|
||||||
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
|
import {
|
||||||
|
parseLogbookBackupFile,
|
||||||
|
previewLogbookBackup,
|
||||||
|
restoreLogbookBackup,
|
||||||
|
formatBackupBytes,
|
||||||
|
BACKUP_SIZE_CONFIRM_BYTES,
|
||||||
|
type ParsedLogbookBackup,
|
||||||
|
type LogbookBackupPreview
|
||||||
|
} from '../services/logbookBackup.js'
|
||||||
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
|
||||||
|
|
||||||
|
interface LogbookRestorePanelProps {
|
||||||
|
onRestored?: (logbookId: string, title: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBackupError(code: string, t: (key: string) => string): string {
|
||||||
|
switch (code) {
|
||||||
|
case 'BACKUP_PASSPHRASE_TOO_SHORT':
|
||||||
|
return t('settings.backup_passphrase_short')
|
||||||
|
case 'BACKUP_NOT_OWNER':
|
||||||
|
return t('settings.backup_not_owner')
|
||||||
|
case 'BACKUP_INVALID_JSON':
|
||||||
|
return t('settings.backup_invalid_json')
|
||||||
|
case 'BACKUP_INVALID_ARCHIVE':
|
||||||
|
return t('settings.backup_invalid_archive')
|
||||||
|
case 'BACKUP_VERSION_UNSUPPORTED':
|
||||||
|
return t('settings.backup_version_unsupported')
|
||||||
|
case 'BACKUP_WRONG_PASSPHRASE':
|
||||||
|
return t('settings.backup_wrong_passphrase')
|
||||||
|
case 'BACKUP_INVALID_FORMAT':
|
||||||
|
return t('settings.backup_invalid_format')
|
||||||
|
case 'BACKUP_NOT_AUTHENTICATED':
|
||||||
|
return t('settings.backup_not_authenticated')
|
||||||
|
case 'BACKUP_ID_CONFLICT':
|
||||||
|
return t('settings.backup_id_conflict')
|
||||||
|
default:
|
||||||
|
if (code.includes('decrypt') || code.includes('operation')) {
|
||||||
|
return t('settings.backup_wrong_passphrase')
|
||||||
|
}
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LogbookRestorePanel({ onRestored }: LogbookRestorePanelProps) {
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
|
const { showConfirm } = useDialog()
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const [importPassphrase, setImportPassphrase] = useState('')
|
||||||
|
const [importFile, setImportFile] = useState<File | null>(null)
|
||||||
|
const [importPreview, setImportPreview] = useState<LogbookBackupPreview | null>(null)
|
||||||
|
const [parsedBackup, setParsedBackup] = useState<ParsedLogbookBackup | null>(null)
|
||||||
|
const [importing, setImporting] = useState(false)
|
||||||
|
const [previewing, setPreviewing] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [success, setSuccess] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleImportSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
await handleRestore()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setError(null)
|
||||||
|
setSuccess(null)
|
||||||
|
setImportPreview(null)
|
||||||
|
setParsedBackup(null)
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
setImportFile(file ?? null)
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const backup = await parseLogbookBackupFile(file)
|
||||||
|
setParsedBackup(backup)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
setError(mapBackupError(message, t))
|
||||||
|
setImportFile(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePreviewImport = async () => {
|
||||||
|
if (!parsedBackup || !importPassphrase) return
|
||||||
|
setPreviewing(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const preview = await previewLogbookBackup(parsedBackup, importPassphrase)
|
||||||
|
setImportPreview(preview)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setImportPreview(null)
|
||||||
|
setError(t('settings.backup_wrong_passphrase'))
|
||||||
|
} finally {
|
||||||
|
setPreviewing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => {
|
||||||
|
if (!parsedBackup || !importPassphrase) return
|
||||||
|
|
||||||
|
if (parsedBackup.manifest.totalUncompressedBytes > BACKUP_SIZE_CONFIRM_BYTES) {
|
||||||
|
const ok = await showConfirm(
|
||||||
|
t('settings.backup_import_size_confirm', {
|
||||||
|
size: formatBackupBytes(parsedBackup.manifest.totalUncompressedBytes)
|
||||||
|
}),
|
||||||
|
t('settings.backup_restore_title'),
|
||||||
|
t('logs.confirm_yes'),
|
||||||
|
t('logs.confirm_no')
|
||||||
|
)
|
||||||
|
if (!ok) return
|
||||||
|
}
|
||||||
|
|
||||||
|
setImporting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const result = await restoreLogbookBackup(parsedBackup, importPassphrase, options)
|
||||||
|
setSuccess(t('settings.backup_restore_success', { title: result.title }))
|
||||||
|
setImportFile(null)
|
||||||
|
setImportPassphrase('')
|
||||||
|
setImportPreview(null)
|
||||||
|
setParsedBackup(null)
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||||
|
trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, {
|
||||||
|
entries: parsedBackup.manifest.counts.entries,
|
||||||
|
photos: parsedBackup.manifest.counts.photos,
|
||||||
|
voiceMemos: parsedBackup.manifest.counts.voiceMemos,
|
||||||
|
bytes: parsedBackup.manifest.totalUncompressedBytes,
|
||||||
|
mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id'
|
||||||
|
})
|
||||||
|
onRestored?.(result.logbookId, result.title)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
if (message === 'BACKUP_ID_CONFLICT') {
|
||||||
|
const overwrite = await showConfirm(
|
||||||
|
t('settings.backup_overwrite_confirm'),
|
||||||
|
t('settings.backup_restore_title'),
|
||||||
|
t('logs.confirm_yes'),
|
||||||
|
t('logs.confirm_no')
|
||||||
|
)
|
||||||
|
if (overwrite) {
|
||||||
|
setImporting(false)
|
||||||
|
return handleRestore({ overwrite: true })
|
||||||
|
}
|
||||||
|
const asNew = await showConfirm(
|
||||||
|
t('settings.backup_new_id_confirm'),
|
||||||
|
t('settings.backup_restore_title'),
|
||||||
|
t('logs.confirm_yes'),
|
||||||
|
t('logs.confirm_no')
|
||||||
|
)
|
||||||
|
if (asNew) {
|
||||||
|
setImporting(false)
|
||||||
|
return handleRestore({ assignNewId: true })
|
||||||
|
}
|
||||||
|
setError(t('settings.backup_restore_cancelled'))
|
||||||
|
} else {
|
||||||
|
setError(mapBackupError(message, t))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setImporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="backup-section backup-section--import" aria-labelledby="backup-import-heading" style={{ marginTop: '8px' }}>
|
||||||
|
<p className="text-muted backup-section-desc" style={{ fontSize: '13px', margin: '0 0 16px 0', textAlign: 'left', lineHeight: '1.4' }}>
|
||||||
|
{t('settings.backup_restore_desc')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="auth-error mb-4" role="alert" style={{ textAlign: 'left' }}>
|
||||||
|
<AlertTriangle size={16} style={{ display: 'inline', marginRight: 6, verticalAlign: 'text-bottom' }} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="success-toast mb-4" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<Check size={16} />
|
||||||
|
<span>{success}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleImportSubmit} className="backup-import-form" style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
<div className="input-group" style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||||
|
<label htmlFor="backup-import-file" style={{ fontSize: '12px', fontWeight: 600, color: 'var(--app-text-muted)', textAlign: 'left' }}>
|
||||||
|
{t('settings.backup_file_label')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="backup-import-file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".daagbok,application/zip"
|
||||||
|
className="input-text"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
disabled={importing}
|
||||||
|
style={{ width: '100%', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{importFile && (
|
||||||
|
<>
|
||||||
|
<div className="input-group" style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||||
|
<label htmlFor="backup-import-passphrase" style={{ fontSize: '12px', fontWeight: 600, color: 'var(--app-text-muted)', textAlign: 'left' }}>
|
||||||
|
{t('settings.backup_passphrase')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="backup-import-passphrase"
|
||||||
|
name="backup-import-passphrase"
|
||||||
|
type="password"
|
||||||
|
className="input-text"
|
||||||
|
value={importPassphrase}
|
||||||
|
onChange={(e) => {
|
||||||
|
setImportPassphrase(e.target.value)
|
||||||
|
setImportPreview(null)
|
||||||
|
}}
|
||||||
|
autoComplete="current-password"
|
||||||
|
disabled={importing}
|
||||||
|
required
|
||||||
|
style={{ width: '100%', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="backup-actions-row" style={{ display: 'flex', gap: '10px' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={handlePreviewImport}
|
||||||
|
disabled={previewing || importing || !importPassphrase}
|
||||||
|
style={{ flex: 1, padding: '10px' }}
|
||||||
|
>
|
||||||
|
{previewing ? t('settings.backup_previewing') : t('settings.backup_preview_btn')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn primary"
|
||||||
|
disabled={importing || !importPassphrase}
|
||||||
|
style={{ flex: 1, padding: '10px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: '6px' }}
|
||||||
|
>
|
||||||
|
<Upload size={16} />
|
||||||
|
{importing ? t('settings.backup_restoring') : t('settings.backup_restore_btn')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{importPreview && (
|
||||||
|
<div className="backup-preview glass" style={{ marginTop: '16px', padding: '16px', borderRadius: '12px', border: '1px solid var(--app-border-subtle)', background: 'var(--app-surface-inset, rgba(0, 0, 0, 0.2))', textAlign: 'left' }}>
|
||||||
|
<p className="backup-preview-title" style={{ fontWeight: 600, margin: '0 0 10px 0', fontSize: '14px', color: 'var(--app-text-heading)' }}>{importPreview.title}</p>
|
||||||
|
<ul className="backup-preview-stats" style={{ listStyle: 'none', padding: 0, margin: '0 0 10px 0', display: 'flex', flexDirection: 'column', gap: '6px', fontSize: '13px', color: 'var(--app-text)' }}>
|
||||||
|
<li>{t('settings.backup_stat_entries', { count: importPreview.counts.entries })}</li>
|
||||||
|
<li>{t('settings.backup_stat_photos', { count: importPreview.counts.photos })}</li>
|
||||||
|
<li>{t('settings.backup_stat_voice', { count: importPreview.counts.voiceMemos })}</li>
|
||||||
|
<li>{t('settings.backup_stat_crew', { count: importPreview.counts.crews })}</li>
|
||||||
|
<li>{t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}</li>
|
||||||
|
<li style={{ color: 'var(--app-text-muted)' }}>
|
||||||
|
{t('settings.backup_stat_size', {
|
||||||
|
size: formatBackupBytes(importPreview.totalUncompressedBytes)
|
||||||
|
})}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-muted backup-preview-date" style={{ fontSize: '11px', margin: 0, color: 'var(--app-text-muted)' }}>
|
||||||
|
{t('settings.backup_exported_at', {
|
||||||
|
date: formatAppDateTime(importPreview.exportedAt, i18n.language)
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,7 +17,6 @@ import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
|||||||
|
|
||||||
interface SettingsFormProps {
|
interface SettingsFormProps {
|
||||||
logbookId?: string | null
|
logbookId?: string | null
|
||||||
onLogbookRestored?: (logbookId: string, title: string) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Collaborator {
|
interface Collaborator {
|
||||||
@@ -34,7 +33,7 @@ const bufferToHex = (buffer: ArrayBuffer): string => {
|
|||||||
.join('')
|
.join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsFormProps) {
|
export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { showConfirm, showAlert } = useDialog()
|
const { showConfirm, showAlert } = useDialog()
|
||||||
|
|
||||||
@@ -374,7 +373,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{logbookId && isOwner && (
|
{logbookId && isOwner && (
|
||||||
<LogbookBackupPanel logbookId={logbookId} onRestored={onLogbookRestored} />
|
<LogbookBackupPanel logbookId={logbookId} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{logbookId && isOwner && (
|
{logbookId && isOwner && (
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { TideLocationOption } from '../utils/tideLocation.js'
|
||||||
|
|
||||||
|
type TideLocationPickerModalProps = {
|
||||||
|
title: string
|
||||||
|
hint: string
|
||||||
|
cancelLabel: string
|
||||||
|
options: TideLocationOption[]
|
||||||
|
onSelect: (option: TideLocationOption) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TideLocationPickerModal({
|
||||||
|
title,
|
||||||
|
hint,
|
||||||
|
cancelLabel,
|
||||||
|
options,
|
||||||
|
onSelect,
|
||||||
|
onCancel
|
||||||
|
}: TideLocationPickerModalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="live-log-modal-backdrop"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onCancel()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="live-log-modal tide-station-picker" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<p className="live-log-modal-hint" role="note">
|
||||||
|
{hint}
|
||||||
|
</p>
|
||||||
|
<ul className="tide-station-picker__list">
|
||||||
|
{options.map((option) => (
|
||||||
|
<li key={option.role}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tide-station-picker__option"
|
||||||
|
onClick={() => onSelect(option)}
|
||||||
|
>
|
||||||
|
<span className="tide-station-picker__name">{option.displayLabel}</span>
|
||||||
|
<span className="tide-station-picker__meta">
|
||||||
|
{t(option.labelKey)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="live-log-modal-actions">
|
||||||
|
<button type="button" className="btn secondary" onClick={onCancel}>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { TideStation } from '../services/tides.js'
|
||||||
|
|
||||||
|
type TideStationPickerModalProps = {
|
||||||
|
title: string
|
||||||
|
hint: string
|
||||||
|
cancelLabel: string
|
||||||
|
stations: TideStation[]
|
||||||
|
onSelect: (station: TideStation) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TideStationPickerModal({
|
||||||
|
title,
|
||||||
|
hint,
|
||||||
|
cancelLabel,
|
||||||
|
stations,
|
||||||
|
onSelect,
|
||||||
|
onCancel
|
||||||
|
}: TideStationPickerModalProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="live-log-modal-backdrop"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onCancel()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="live-log-modal tide-station-picker" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<p className="live-log-modal-hint" role="note">
|
||||||
|
{hint}
|
||||||
|
</p>
|
||||||
|
<ul className="tide-station-picker__list">
|
||||||
|
{stations.map((station) => (
|
||||||
|
<li key={station.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tide-station-picker__option"
|
||||||
|
onClick={() => onSelect(station)}
|
||||||
|
>
|
||||||
|
<span className="tide-station-picker__name">{station.name}</span>
|
||||||
|
<span className="tide-station-picker__meta">
|
||||||
|
{station.distanceKm} km
|
||||||
|
{station.area ? ` · ${station.area}` : ''}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="live-log-modal-actions">
|
||||||
|
<button type="button" className="btn secondary" onClick={onCancel}>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -190,6 +190,29 @@
|
|||||||
"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": "BSH-vandstandprognose — kontrollér tidskritiske manøvrer 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_no_data_for_date": "Ingen BSH-prognose for rejsedagen {{date}} (kun fremtidige tidspunkter).",
|
||||||
|
"tide_pick_station_title": "Vælg tidevandsmåler",
|
||||||
|
"tide_pick_station_hint": "Vælg den nærmeste BSH-måler for tidevandsprognosen.",
|
||||||
|
"tide_place_not_found": "“{{place}}” kunne ikke findes — angiv en kystby eller havn.",
|
||||||
|
"tide_fetched_at_position": "Officiel BSH-prognose fra nærmeste tidevandsmåler.",
|
||||||
|
"tide_open_meteo_fallback": "Modelprognose (Open-Meteo) — ingen BSH-station inden for rækkevidde.",
|
||||||
|
"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”.",
|
||||||
|
"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",
|
||||||
@@ -535,7 +558,13 @@
|
|||||||
"revoke": "Fjern",
|
"revoke": "Fjern",
|
||||||
"revoke_confirm": "Er du sikker på, at du vil fjerne dette besætningsmedlems adgang?",
|
"revoke_confirm": "Er du sikker på, at du vil fjerne dette besætningsmedlems adgang?",
|
||||||
"invite_role": "Rolle",
|
"invite_role": "Rolle",
|
||||||
"invite_expires": "Linket er gyldigt i 48 timer"
|
"invite_expires": "Linket er gyldigt i 48 timer",
|
||||||
|
"tide_fetched_from_destination": "Tides based on destination “{{place}}”.",
|
||||||
|
"tide_role_departure": "Departure Port",
|
||||||
|
"tide_role_destination": "Destination Port",
|
||||||
|
"tide_role_gps": "GPS Position",
|
||||||
|
"tide_location_picker_title": "Select Tide Position",
|
||||||
|
"tide_location_picker_hint": "Select the position to fetch tides for:"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dine logbøger",
|
"title": "Dine logbøger",
|
||||||
|
|||||||
@@ -190,6 +190,35 @@
|
|||||||
"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": "BSH-Wasserstandsvorhersage — überprüfe zeitkritische Manöver 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_no_data_for_date": "Für den Reisetag {{date}} liegt keine BSH-Vorhersage vor (nur zukünftige Termine).",
|
||||||
|
"tide_pick_station_title": "Pegel auswählen",
|
||||||
|
"tide_pick_station_hint": "Wähle den nächstgelegenen BSH-Pegel für die Gezeiten-Vorhersage.",
|
||||||
|
"tide_place_not_found": "„{{place}}“ konnte nicht geortet werden — bitte einen Küstenort oder Hafen angeben.",
|
||||||
|
"tide_fetched_at_position": "Amtliche BSH-Vorhersage vom nächstgelegenen Pegel.",
|
||||||
|
"tide_open_meteo_fallback": "Modellprognose (Open-Meteo) — keine BSH-Station in Reichweite.",
|
||||||
|
"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_fetched_from_destination": "Gezeiten basierend auf Zielort „{{place}}“.",
|
||||||
|
"tide_role_departure": "Abfahrthafen",
|
||||||
|
"tide_role_destination": "Ankunftshafen",
|
||||||
|
"tide_role_gps": "GPS-Position",
|
||||||
|
"tide_location_picker_title": "Gezeiten-Position auswählen",
|
||||||
|
"tide_location_picker_hint": "Wähle die Position aus, für die die Gezeiten ermittelt werden sollen:",
|
||||||
|
"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,35 @@
|
|||||||
"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": "BSH water level forecast — verify time-critical manoeuvres 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_no_data_for_date": "No BSH forecast for travel day {{date}} (future dates only).",
|
||||||
|
"tide_pick_station_title": "Select tide gauge",
|
||||||
|
"tide_pick_station_hint": "Choose the nearest BSH gauge for the tide forecast.",
|
||||||
|
"tide_place_not_found": "“{{place}}” could not be geocoded — please use a coastal place or harbour name.",
|
||||||
|
"tide_fetched_at_position": "Official BSH forecast from the nearest tide gauge.",
|
||||||
|
"tide_open_meteo_fallback": "Model forecast (Open-Meteo) — no BSH station within range.",
|
||||||
|
"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_fetched_from_destination": "Tides based on destination “{{place}}”.",
|
||||||
|
"tide_role_departure": "Departure Port",
|
||||||
|
"tide_role_destination": "Destination Port",
|
||||||
|
"tide_role_gps": "GPS Position",
|
||||||
|
"tide_location_picker_title": "Select Tide Position",
|
||||||
|
"tide_location_picker_hint": "Select the position to fetch tides for:",
|
||||||
|
"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,29 @@
|
|||||||
"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": "Pronóstico BSH — comprueba maniobras críticas 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_no_data_for_date": "Sin pronóstico BSH para el día de viaje {{date}} (solo fechas futuras).",
|
||||||
|
"tide_pick_station_title": "Elegir marégrafo",
|
||||||
|
"tide_pick_station_hint": "Elige el marégrafo BSH más cercano para el pronóstico de mareas.",
|
||||||
|
"tide_place_not_found": "«{{place}}» no se encontró — indica un lugar costero o puerto.",
|
||||||
|
"tide_fetched_at_position": "Pronóstico oficial del BSH desde la marégrafo más cercana.",
|
||||||
|
"tide_open_meteo_fallback": "Pronóstico modelo (Open-Meteo) — sin estación BSH en el área.",
|
||||||
|
"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».",
|
||||||
|
"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",
|
||||||
@@ -535,7 +558,13 @@
|
|||||||
"revoke": "Eliminar",
|
"revoke": "Eliminar",
|
||||||
"revoke_confirm": "¿Estás seguro de que quieres revocar el acceso a este miembro del equipo?",
|
"revoke_confirm": "¿Estás seguro de que quieres revocar el acceso a este miembro del equipo?",
|
||||||
"invite_role": "Papel",
|
"invite_role": "Papel",
|
||||||
"invite_expires": "El enlace es válido durante 48 horas"
|
"invite_expires": "El enlace es válido durante 48 horas",
|
||||||
|
"tide_fetched_from_destination": "Tides based on destination “{{place}}”.",
|
||||||
|
"tide_role_departure": "Departure Port",
|
||||||
|
"tide_role_destination": "Destination Port",
|
||||||
|
"tide_role_gps": "GPS Position",
|
||||||
|
"tide_location_picker_title": "Select Tide Position",
|
||||||
|
"tide_location_picker_hint": "Select the position to fetch tides for:"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Tus diarios de a bordo",
|
"title": "Tus diarios de a bordo",
|
||||||
|
|||||||
@@ -190,6 +190,29 @@
|
|||||||
"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": "Prévision BSH — vérifiez les manœuvres critiques 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_no_data_for_date": "Pas de prévision BSH pour le jour de voyage {{date}} (dates futures uniquement).",
|
||||||
|
"tide_pick_station_title": "Choisir un marégraphe",
|
||||||
|
"tide_pick_station_hint": "Choisissez le marégraphe BSH le plus proche pour la prévision.",
|
||||||
|
"tide_place_not_found": "« {{place}} » introuvable — indiquez un lieu côtier ou un port.",
|
||||||
|
"tide_fetched_at_position": "Prévision officielle BSH depuis la marégraphe la plus proche.",
|
||||||
|
"tide_open_meteo_fallback": "Prévision modèle (Open-Meteo) — aucune station BSH à proximité.",
|
||||||
|
"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 ».",
|
||||||
|
"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",
|
||||||
@@ -535,7 +558,13 @@
|
|||||||
"revoke": "Supprimer",
|
"revoke": "Supprimer",
|
||||||
"revoke_confirm": "Es-tu sûr de vouloir retirer l'accès à ce membre de l'équipe ?",
|
"revoke_confirm": "Es-tu sûr de vouloir retirer l'accès à ce membre de l'équipe ?",
|
||||||
"invite_role": "rôle",
|
"invite_role": "rôle",
|
||||||
"invite_expires": "Le lien est valable pendant 48 heures"
|
"invite_expires": "Le lien est valable pendant 48 heures",
|
||||||
|
"tide_fetched_from_destination": "Tides based on destination “{{place}}”.",
|
||||||
|
"tide_role_departure": "Departure Port",
|
||||||
|
"tide_role_destination": "Destination Port",
|
||||||
|
"tide_role_gps": "GPS Position",
|
||||||
|
"tide_location_picker_title": "Select Tide Position",
|
||||||
|
"tide_location_picker_hint": "Select the position to fetch tides for:"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Tes carnets de bord",
|
"title": "Tes carnets de bord",
|
||||||
|
|||||||
@@ -190,6 +190,29 @@
|
|||||||
"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": "BSH vannstandprognose — kontroller tidskritiske manøvrer 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_no_data_for_date": "Ingen BSH-prognose for reisedagen {{date}} (kun fremtidige tidspunkter).",
|
||||||
|
"tide_pick_station_title": "Velg tidevannsmåler",
|
||||||
|
"tide_pick_station_hint": "Velg nærmeste BSH-måler for tidevannsprognosen.",
|
||||||
|
"tide_place_not_found": "«{{place}}» ble ikke funnet — oppgi en kyststad eller havn.",
|
||||||
|
"tide_fetched_at_position": "Offisiell BSH-prognose fra nærmeste tidevannsmåler.",
|
||||||
|
"tide_open_meteo_fallback": "Modellprognose (Open-Meteo) — ingen BSH-stasjon innen rekkevidde.",
|
||||||
|
"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».",
|
||||||
|
"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",
|
||||||
@@ -535,7 +558,13 @@
|
|||||||
"revoke": "Fjern",
|
"revoke": "Fjern",
|
||||||
"revoke_confirm": "Er du sikker på at du vil fjerne tilgangen til dette besetningsmedlemmet?",
|
"revoke_confirm": "Er du sikker på at du vil fjerne tilgangen til dette besetningsmedlemmet?",
|
||||||
"invite_role": "Rolle",
|
"invite_role": "Rolle",
|
||||||
"invite_expires": "Koblingen er gyldig i 48 timer"
|
"invite_expires": "Koblingen er gyldig i 48 timer",
|
||||||
|
"tide_fetched_from_destination": "Tides based on destination “{{place}}”.",
|
||||||
|
"tide_role_departure": "Departure Port",
|
||||||
|
"tide_role_destination": "Destination Port",
|
||||||
|
"tide_role_gps": "GPS Position",
|
||||||
|
"tide_location_picker_title": "Select Tide Position",
|
||||||
|
"tide_location_picker_hint": "Select the position to fetch tides for:"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Loggbøkene dine",
|
"title": "Loggbøkene dine",
|
||||||
|
|||||||
@@ -190,6 +190,29 @@
|
|||||||
"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": "BSH vattenståndsprognos — verifiera tidskritiska manövrer 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_no_data_for_date": "Ingen BSH-prognos för resedagen {{date}} (endast framtida tider).",
|
||||||
|
"tide_pick_station_title": "Välj tidvattensmätare",
|
||||||
|
"tide_pick_station_hint": "Välj närmaste BSH-mätare för tidvattenprognosen.",
|
||||||
|
"tide_place_not_found": "“{{place}}” kunde inte hittas — ange en kustort eller hamn.",
|
||||||
|
"tide_fetched_at_position": "Officiell BSH-prognos från närmaste tidvattensmätare.",
|
||||||
|
"tide_open_meteo_fallback": "Modellprognos (Open-Meteo) — ingen BSH-station inom räckhåll.",
|
||||||
|
"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”.",
|
||||||
|
"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",
|
||||||
@@ -535,7 +558,13 @@
|
|||||||
"revoke": "Ta bort",
|
"revoke": "Ta bort",
|
||||||
"revoke_confirm": "Är du säker på att du vill ta bort åtkomsten för den här medarbetaren?",
|
"revoke_confirm": "Är du säker på att du vill ta bort åtkomsten för den här medarbetaren?",
|
||||||
"invite_role": "Roll",
|
"invite_role": "Roll",
|
||||||
"invite_expires": "Länken är giltig i 48 timmar"
|
"invite_expires": "Länken är giltig i 48 timmar",
|
||||||
|
"tide_fetched_from_destination": "Tides based on destination “{{place}}”.",
|
||||||
|
"tide_role_departure": "Departure Port",
|
||||||
|
"tide_role_destination": "Destination Port",
|
||||||
|
"tide_role_gps": "GPS Position",
|
||||||
|
"tide_location_picker_title": "Select Tide Position",
|
||||||
|
"tide_location_picker_hint": "Select the position to fetch tides for:"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dina loggböcker",
|
"title": "Dina loggböcker",
|
||||||
|
|||||||
@@ -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,10 +7,14 @@ import { putEntryRecord } from '../utils/entryListCache.js'
|
|||||||
import {
|
import {
|
||||||
buildLogEntryPayload,
|
buildLogEntryPayload,
|
||||||
normalizeLogEvent,
|
normalizeLogEvent,
|
||||||
|
readLogEntryTidesMap,
|
||||||
sortLogEventsByTime,
|
sortLogEventsByTime,
|
||||||
currentLocalTimeHHMM,
|
currentLocalTimeHHMM,
|
||||||
localDateString,
|
localDateString,
|
||||||
type LogEventPayload
|
type LogEntryTides,
|
||||||
|
type LogEntryTidesMap,
|
||||||
|
type LogEventPayload,
|
||||||
|
type TideRole
|
||||||
} from '../utils/logEntryPayload.js'
|
} from '../utils/logEntryPayload.js'
|
||||||
import {
|
import {
|
||||||
carryOverFromPreviousDay,
|
carryOverFromPreviousDay,
|
||||||
@@ -75,6 +79,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?: LogEntryTidesMap
|
||||||
clearSignatures?: boolean
|
clearSignatures?: boolean
|
||||||
}
|
}
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
@@ -113,6 +118,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 ?? readLogEntryTidesMap(data),
|
||||||
trackDistanceNm:
|
trackDistanceNm:
|
||||||
trackDistance != null && trackDistance !== ''
|
trackDistance != null && trackDistance !== ''
|
||||||
? parseFloat(String(trackDistance))
|
? parseFloat(String(trackDistance))
|
||||||
@@ -398,6 +404,31 @@ export async function appendQuickEvents(
|
|||||||
return { events: nextEvents, hadSignature }
|
return { events: nextEvents, hadSignature }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function patchEntryTides(
|
||||||
|
logbookId: string,
|
||||||
|
entryId: string,
|
||||||
|
role: TideRole,
|
||||||
|
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[]) || []
|
||||||
|
|
||||||
|
const currentTidesMap = readLogEntryTidesMap(loaded.data)
|
||||||
|
const nextTidesMap = {
|
||||||
|
...currentTidesMap,
|
||||||
|
[role]: tides
|
||||||
|
}
|
||||||
|
|
||||||
|
await persistEntry(logbookId, entryId, loaded.data, {
|
||||||
|
events: currentEvents,
|
||||||
|
tides: nextTidesMap,
|
||||||
|
clearSignatures: hadSignature
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function persistEntry(
|
async function persistEntry(
|
||||||
logbookId: string,
|
logbookId: string,
|
||||||
entryId: string,
|
entryId: string,
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { apiFetch } from './api.js'
|
||||||
|
import {
|
||||||
|
type TideAnalyticsSource,
|
||||||
|
PlausibleEvents,
|
||||||
|
trackPlausibleEvent
|
||||||
|
} from './analytics.js'
|
||||||
|
|
||||||
|
export interface TideStation {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
lat: number
|
||||||
|
lon: number
|
||||||
|
distanceKm: number
|
||||||
|
area?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TidesApiError extends Error {
|
||||||
|
code:
|
||||||
|
| 'OFFLINE'
|
||||||
|
| 'NOT_FOUND'
|
||||||
|
| 'NO_DATA_FOR_DATE'
|
||||||
|
| 'PLACE_NOT_FOUND'
|
||||||
|
| 'BAD_REQUEST'
|
||||||
|
| 'REQUEST_FAILED'
|
||||||
|
stations?: TideStation[]
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
code:
|
||||||
|
| 'OFFLINE'
|
||||||
|
| 'NOT_FOUND'
|
||||||
|
| 'NO_DATA_FOR_DATE'
|
||||||
|
| 'PLACE_NOT_FOUND'
|
||||||
|
| 'BAD_REQUEST'
|
||||||
|
| 'REQUEST_FAILED' = 'REQUEST_FAILED',
|
||||||
|
stations?: TideStation[]
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'TidesApiError'
|
||||||
|
this.code = code
|
||||||
|
this.stations = stations
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIDES_FETCH_TIMEOUT_MS = 20_000
|
||||||
|
|
||||||
|
function readStations(data: Record<string, unknown>): TideStation[] | undefined {
|
||||||
|
if (!Array.isArray(data.stations)) return undefined
|
||||||
|
const stations: TideStation[] = []
|
||||||
|
for (const item of data.stations) {
|
||||||
|
if (!item || typeof item !== 'object') continue
|
||||||
|
const row = item as Record<string, unknown>
|
||||||
|
const id = String(row.id ?? '').trim()
|
||||||
|
const name = String(row.name ?? '').trim()
|
||||||
|
const lat = Number(row.lat)
|
||||||
|
const lon = Number(row.lon)
|
||||||
|
const distanceKm = Number(row.distanceKm)
|
||||||
|
if (!id || !name || Number.isNaN(lat) || Number.isNaN(lon) || Number.isNaN(distanceKm)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stations.push({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
distanceKm,
|
||||||
|
area: row.area ? String(row.area) : undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return stations.length > 0 ? stations : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
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 stations = readStations(data as Record<string, unknown>)
|
||||||
|
const code =
|
||||||
|
typeof data?.error === 'string' && data.error === 'place_not_found'
|
||||||
|
? 'PLACE_NOT_FOUND'
|
||||||
|
: 'NOT_FOUND'
|
||||||
|
throw new TidesApiError('Tide data not found', code, stations)
|
||||||
|
}
|
||||||
|
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 fetchNearbyTideStations(
|
||||||
|
lat: string,
|
||||||
|
lon: string,
|
||||||
|
limit = 8
|
||||||
|
): Promise<TideStation[]> {
|
||||||
|
const searchParams = new URLSearchParams({ lat, lon, limit: String(limit) })
|
||||||
|
const data = await fetchTides(`/api/tides/stations/nearby?${searchParams.toString()}`)
|
||||||
|
return readStations(data) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
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 fetchTidesByStation(
|
||||||
|
stationId: string,
|
||||||
|
options?: {
|
||||||
|
queryLat?: string
|
||||||
|
queryLng?: string
|
||||||
|
analyticsSource?: TideAnalyticsSource
|
||||||
|
}
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const searchParams = new URLSearchParams()
|
||||||
|
if (options?.queryLat) searchParams.set('lat', options.queryLat)
|
||||||
|
if (options?.queryLng) searchParams.set('lon', options.queryLng)
|
||||||
|
const suffix = searchParams.toString() ? `?${searchParams.toString()}` : ''
|
||||||
|
const data = await fetchTides(`/api/tides/station/${encodeURIComponent(stationId)}${suffix}`)
|
||||||
|
if (options?.analyticsSource) {
|
||||||
|
trackPlausibleEvent(PlausibleEvents.TIDE_FETCHED, {
|
||||||
|
source: options.analyticsSource,
|
||||||
|
location_source: '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 = [
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
isLogEventDraftEmpty,
|
isLogEventDraftEmpty,
|
||||||
localDateString,
|
localDateString,
|
||||||
normalizeLogEvent,
|
normalizeLogEvent,
|
||||||
|
splitTimeHHMM,
|
||||||
|
readLogEntryTidesMap,
|
||||||
type LogEventPayload
|
type LogEventPayload
|
||||||
} from './logEntryPayload.js'
|
} from './logEntryPayload.js'
|
||||||
|
|
||||||
@@ -72,3 +74,140 @@ describe('buildLogEntryPayload greywater', () => {
|
|||||||
expect(payload.greywater).toBeUndefined()
|
expect(payload.greywater).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('buildLogEntryPayload tides map', () => {
|
||||||
|
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 multiple tide roles (departure and destination)', () => {
|
||||||
|
const payload = buildLogEntryPayload({
|
||||||
|
...base,
|
||||||
|
tides: {
|
||||||
|
departure: { highWater: '18:34', lowWater: '12:05' },
|
||||||
|
destination: { highWater: '19:00', lowWater: '12:30' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(payload.tides).toEqual({
|
||||||
|
departure: { highWater: '18:34', lowWater: '12:05' },
|
||||||
|
destination: { highWater: '19:00', lowWater: '12:30' }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists tide location metadata', () => {
|
||||||
|
const payload = buildLogEntryPayload({
|
||||||
|
...base,
|
||||||
|
tides: {
|
||||||
|
gps: {
|
||||||
|
highWater: '06:00',
|
||||||
|
lowWater: '00:04',
|
||||||
|
locationSource: 'gps',
|
||||||
|
lat: '53.624526',
|
||||||
|
lng: '7.155263'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(payload.tides).toEqual({
|
||||||
|
gps: {
|
||||||
|
highWater: '06:00',
|
||||||
|
lowWater: '00:04',
|
||||||
|
locationSource: 'gps',
|
||||||
|
lat: '53.624526',
|
||||||
|
lng: '7.155263'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('readLogEntryTidesMap backward compatibility', () => {
|
||||||
|
it('reads old flat schema as departure role', () => {
|
||||||
|
const oldData = {
|
||||||
|
tides: {
|
||||||
|
highWater: '12:30',
|
||||||
|
lowWater: '06:15',
|
||||||
|
locationSource: 'departure',
|
||||||
|
placeName: 'Kiel'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const map = readLogEntryTidesMap(oldData)
|
||||||
|
expect(map.departure).toEqual({
|
||||||
|
highWater: '12:30',
|
||||||
|
lowWater: '06:15',
|
||||||
|
locationSource: 'departure',
|
||||||
|
placeName: 'Kiel'
|
||||||
|
})
|
||||||
|
expect(map.gps).toBeUndefined()
|
||||||
|
expect(map.destination).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads old flat schema with gps locationSource as gps role', () => {
|
||||||
|
const oldData = {
|
||||||
|
tides: {
|
||||||
|
highWater: '12:30',
|
||||||
|
lowWater: '06:15',
|
||||||
|
locationSource: 'gps',
|
||||||
|
lat: '54.3',
|
||||||
|
lng: '10.1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const map = readLogEntryTidesMap(oldData)
|
||||||
|
expect(map.gps).toEqual({
|
||||||
|
highWater: '12:30',
|
||||||
|
lowWater: '06:15',
|
||||||
|
locationSource: 'gps',
|
||||||
|
lat: '54.3',
|
||||||
|
lng: '10.1'
|
||||||
|
})
|
||||||
|
expect(map.departure).toBeUndefined()
|
||||||
|
expect(map.destination).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads new nested schema correctly', () => {
|
||||||
|
const newData = {
|
||||||
|
tides: {
|
||||||
|
departure: { highWater: '12:00', lowWater: '06:00', placeName: 'Kiel' },
|
||||||
|
gps: { highWater: '13:00', lowWater: '07:00', lat: '54.3' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const map = readLogEntryTidesMap(newData)
|
||||||
|
expect(map.departure).toEqual({
|
||||||
|
highWater: '12:00',
|
||||||
|
lowWater: '06:00',
|
||||||
|
placeName: 'Kiel'
|
||||||
|
})
|
||||||
|
expect(map.gps).toEqual({
|
||||||
|
highWater: '13:00',
|
||||||
|
lowWater: '07:00',
|
||||||
|
lat: '54.3'
|
||||||
|
})
|
||||||
|
expect(map.destination).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('splitTimeHHMM', () => {
|
||||||
|
it('splits valid time HH:MM correctly', () => {
|
||||||
|
const result = splitTimeHHMM('15:45')
|
||||||
|
expect(result).toEqual({ hours: '15', minutes: '45' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses fallback value when time is empty', () => {
|
||||||
|
const result = splitTimeHHMM('', '00:00')
|
||||||
|
expect(result).toEqual({ hours: '00', minutes: '00' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to current local time when empty and no fallback is specified', () => {
|
||||||
|
const result = splitTimeHHMM('')
|
||||||
|
const hours = parseInt(result.hours, 10)
|
||||||
|
const minutes = parseInt(result.minutes, 10)
|
||||||
|
expect(hours).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(hours).toBeLessThanOrEqual(23)
|
||||||
|
expect(minutes).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(minutes).toBeLessThanOrEqual(59)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -72,8 +72,8 @@ export function isValidTimeHHMM(value: string): boolean {
|
|||||||
return parseTimeToHHMM(value) !== null
|
return parseTimeToHHMM(value) !== null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function splitTimeHHMM(value: string): { hours: string; minutes: string } {
|
export function splitTimeHHMM(value: string, fallback?: string): { hours: string; minutes: string } {
|
||||||
const parsed = parseTimeToHHMM(value) ?? currentLocalTimeHHMM()
|
const parsed = parseTimeToHHMM(value) ?? fallback ?? currentLocalTimeHHMM()
|
||||||
return { hours: parsed.slice(0, 2), minutes: parsed.slice(3, 5) }
|
return { hours: parsed.slice(0, 2), minutes: parsed.slice(3, 5) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +150,23 @@ 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 type TideRole = 'departure' | 'destination' | 'gps'
|
||||||
|
|
||||||
|
export type TideLocationSource = 'gps' | 'departure' | 'geocoded' | 'destination'
|
||||||
|
|
||||||
|
export interface LogEntryTides {
|
||||||
|
highWater: string
|
||||||
|
lowWater: string
|
||||||
|
locationSource?: TideLocationSource
|
||||||
|
placeName?: string
|
||||||
|
lat?: string
|
||||||
|
lng?: string
|
||||||
|
distanceKm?: string
|
||||||
|
tideFallback?: 'open_meteo'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LogEntryTidesMap = Partial<Record<TideRole, LogEntryTides>>
|
||||||
|
|
||||||
export interface LogEntryPayloadInput {
|
export interface LogEntryPayloadInput {
|
||||||
date: string
|
date: string
|
||||||
dayOfTravel: string
|
dayOfTravel: string
|
||||||
@@ -158,6 +175,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?: LogEntryTidesMap
|
||||||
trackDistanceNm?: number
|
trackDistanceNm?: number
|
||||||
trackSpeedMaxKn?: number
|
trackSpeedMaxKn?: number
|
||||||
trackSpeedAvgKn?: number
|
trackSpeedAvgKn?: number
|
||||||
@@ -166,6 +184,64 @@ export interface LogEntryPayloadInput {
|
|||||||
entryCrew?: EntryCrewFields
|
entryCrew?: EntryCrewFields
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readTideLocationSource(value: unknown): TideLocationSource | undefined {
|
||||||
|
const source = String(value ?? '').trim()
|
||||||
|
if (source === 'gps' || source === 'departure' || source === 'geocoded' || source === 'destination') 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 distanceKm = String(tides?.distanceKm ?? '').trim()
|
||||||
|
const locationSource = readTideLocationSource(tides?.locationSource)
|
||||||
|
const tideFallback = tides?.tideFallback === 'open_meteo' ? 'open_meteo' as const : undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
highWater: parseTimeToHHMM(highRaw) ?? '',
|
||||||
|
lowWater: parseTimeToHHMM(lowRaw) ?? '',
|
||||||
|
...(locationSource ? { locationSource } : {}),
|
||||||
|
...(placeName ? { placeName } : {}),
|
||||||
|
...(lat ? { lat } : {}),
|
||||||
|
...(lng ? { lng } : {}),
|
||||||
|
...(distanceKm ? { distanceKm } : {}),
|
||||||
|
...(tideFallback ? { tideFallback } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readLogEntryTidesMap(data: Record<string, unknown>): LogEntryTidesMap {
|
||||||
|
const tidesRaw = data.tides as Record<string, unknown> | undefined
|
||||||
|
if (!tidesRaw) return {}
|
||||||
|
|
||||||
|
// Check if it's the old schema (flat object with highWater/lowWater)
|
||||||
|
const isOldSchema = ('highWater' in tidesRaw || 'lowWater' in tidesRaw)
|
||||||
|
|
||||||
|
if (isOldSchema) {
|
||||||
|
const parsedOld = readLogEntryTides({ tides: tidesRaw })
|
||||||
|
let role: TideRole = 'departure'
|
||||||
|
if (parsedOld.locationSource === 'gps') {
|
||||||
|
role = 'gps'
|
||||||
|
} else if (parsedOld.locationSource === 'destination') {
|
||||||
|
role = 'destination'
|
||||||
|
}
|
||||||
|
return { [role]: parsedOld }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, it's the new schema mapping roles to tide values
|
||||||
|
const map: LogEntryTidesMap = {}
|
||||||
|
const roles: TideRole[] = ['departure', 'destination', 'gps']
|
||||||
|
for (const role of roles) {
|
||||||
|
if (tidesRaw[role] && typeof tidesRaw[role] === 'object') {
|
||||||
|
map[role] = readLogEntryTides({ tides: tidesRaw[role] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
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 +267,35 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.tides) {
|
||||||
|
const serializedMap: Record<string, unknown> = {}
|
||||||
|
const roles: TideRole[] = ['departure', 'destination', 'gps']
|
||||||
|
for (const role of roles) {
|
||||||
|
const tideData = input.tides[role]
|
||||||
|
if (tideData) {
|
||||||
|
const highWater = parseTimeToHHMM(tideData.highWater) ?? ''
|
||||||
|
const lowWater = parseTimeToHHMM(tideData.lowWater) ?? ''
|
||||||
|
if (highWater || lowWater) {
|
||||||
|
const tidesObj: Record<string, string> = { highWater, lowWater }
|
||||||
|
if (tideData.locationSource) tidesObj.locationSource = tideData.locationSource
|
||||||
|
const placeName = tideData.placeName?.trim()
|
||||||
|
if (placeName) tidesObj.placeName = placeName
|
||||||
|
const lat = tideData.lat?.trim()
|
||||||
|
if (lat) tidesObj.lat = lat
|
||||||
|
const lng = tideData.lng?.trim()
|
||||||
|
if (lng) tidesObj.lng = lng
|
||||||
|
const distanceKm = tideData.distanceKm?.trim()
|
||||||
|
if (distanceKm) tidesObj.distanceKm = distanceKm
|
||||||
|
if (tideData.tideFallback === 'open_meteo') tidesObj.tideFallback = 'open_meteo'
|
||||||
|
serializedMap[role] = tidesObj
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(serializedMap).length > 0) {
|
||||||
|
payload.tides = serializedMap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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,113 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import * as tidesService from '../services/tides.js'
|
||||||
|
import { fetchTidesForEntry } from './tideFetch.js'
|
||||||
|
|
||||||
|
describe('fetchTidesForEntry', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns tide times when nearby fetch succeeds for entry date', async () => {
|
||||||
|
vi.spyOn(tidesService, 'fetchTidesNearby').mockResolvedValue({
|
||||||
|
distanceKm: 8,
|
||||||
|
location: { name: 'Norderney, Riffgat', source: 'bsh_station' },
|
||||||
|
tides: {
|
||||||
|
data: {
|
||||||
|
timezone: 'Europe/Berlin',
|
||||||
|
extrema: [
|
||||||
|
{
|
||||||
|
time: '2026-06-12T07:20:00.000Z',
|
||||||
|
date: '2026-06-12',
|
||||||
|
height: 6.16,
|
||||||
|
isHigh: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
time: '2026-06-12T13:39:00.000Z',
|
||||||
|
date: '2026-06-12',
|
||||||
|
height: 4.03,
|
||||||
|
isHigh: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const outcome = await fetchTidesForEntry({
|
||||||
|
fetchLocation: { mode: 'nearby', lat: '53.624526', lng: '7.155263', source: 'gps' },
|
||||||
|
entryDate: '2026-06-12',
|
||||||
|
analyticsSource: 'entry_editor'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(outcome).toMatchObject({
|
||||||
|
highWater: '09:20',
|
||||||
|
lowWater: '15:39'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers station picker when fetch succeeds but entry date has no extrema', async () => {
|
||||||
|
vi.spyOn(tidesService, 'fetchTidesNearby').mockResolvedValue({
|
||||||
|
tides: {
|
||||||
|
data: {
|
||||||
|
timezone: 'Europe/Berlin',
|
||||||
|
extrema: [
|
||||||
|
{
|
||||||
|
time: '2026-06-12T07:20:00.000Z',
|
||||||
|
date: '2026-06-12',
|
||||||
|
height: 6.16,
|
||||||
|
isHigh: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
fetchTidesForEntry({
|
||||||
|
fetchLocation: { mode: 'nearby', lat: '53.62', lng: '7.15', source: 'gps' },
|
||||||
|
entryDate: '2026-06-01',
|
||||||
|
analyticsSource: 'entry_editor'
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: 'NO_DATA_FOR_DATE' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers station picker when nearby fetch returns not found', async () => {
|
||||||
|
vi.spyOn(tidesService, 'fetchTidesNearby').mockRejectedValue(
|
||||||
|
new tidesService.TidesApiError('Tide data not found', 'NOT_FOUND', [
|
||||||
|
{
|
||||||
|
id: 'norderney_riffgat',
|
||||||
|
name: 'Norderney, Riffgat',
|
||||||
|
lat: 53.69,
|
||||||
|
lon: 7.15,
|
||||||
|
distanceKm: 8
|
||||||
|
}
|
||||||
|
])
|
||||||
|
)
|
||||||
|
|
||||||
|
const outcome = await fetchTidesForEntry({
|
||||||
|
fetchLocation: { mode: 'nearby', lat: '53.624526', lng: '7.155263', source: 'gps' },
|
||||||
|
entryDate: '2026-06-12',
|
||||||
|
analyticsSource: 'entry_editor'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(outcome).toEqual({
|
||||||
|
kind: 'pick_station',
|
||||||
|
entryDate: '2026-06-12',
|
||||||
|
fetchLocation: { mode: 'nearby', lat: '53.624526', lng: '7.155263', source: 'gps' },
|
||||||
|
stations: [
|
||||||
|
{
|
||||||
|
id: 'norderney_riffgat',
|
||||||
|
name: 'Norderney, Riffgat',
|
||||||
|
lat: 53.69,
|
||||||
|
lon: 7.15,
|
||||||
|
distanceKm: 8
|
||||||
|
}
|
||||||
|
],
|
||||||
|
lat: '53.624526',
|
||||||
|
lng: '7.155263'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import {
|
||||||
|
fetchNearbyTideStations,
|
||||||
|
fetchTidesByPlace,
|
||||||
|
fetchTidesByStation,
|
||||||
|
fetchTidesNearby,
|
||||||
|
type TideStation,
|
||||||
|
TidesApiError
|
||||||
|
} from '../services/tides.js'
|
||||||
|
import type { TideFetchLocation } from './tideLocation.js'
|
||||||
|
import { buildTideLocationMeta, type TideLocationMeta } from './tideLocation.js'
|
||||||
|
import { extractTideTurtlePayload, parseTideTurtleForDate } from './tideTurtle.js'
|
||||||
|
|
||||||
|
export type TideFetchResult = {
|
||||||
|
highWater: string
|
||||||
|
lowWater: string
|
||||||
|
location: TideLocationMeta
|
||||||
|
apiData: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TideFetchNeedsStationPick = {
|
||||||
|
kind: 'pick_station'
|
||||||
|
entryDate: string
|
||||||
|
fetchLocation: TideFetchLocation
|
||||||
|
stations: TideStation[]
|
||||||
|
queryLat?: string
|
||||||
|
queryLng?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TideFetchOutcome = TideFetchResult | TideFetchNeedsStationPick
|
||||||
|
|
||||||
|
function readQueryCoords(fetchLocation: TideFetchLocation): { lat?: string; lng?: string } {
|
||||||
|
if (fetchLocation.mode === 'nearby') {
|
||||||
|
return { lat: fetchLocation.lat, lng: fetchLocation.lng }
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTideTimesForDate(data: Record<string, unknown>, entryDate: string): boolean {
|
||||||
|
const parsed = parseTideTurtleForDate(data, entryDate)
|
||||||
|
return Boolean(parsed.highWater || parsed.lowWater)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toResult(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
entryDate: string,
|
||||||
|
fetchLocation: TideFetchLocation
|
||||||
|
): TideFetchResult | null {
|
||||||
|
const parsed = parseTideTurtleForDate(data, entryDate)
|
||||||
|
if (!parsed.highWater && !parsed.lowWater) return null
|
||||||
|
return {
|
||||||
|
highWater: parsed.highWater,
|
||||||
|
lowWater: parsed.lowWater,
|
||||||
|
location: buildTideLocationMeta(fetchLocation, data),
|
||||||
|
apiData: data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNearbyStations(
|
||||||
|
fetchLocation: TideFetchLocation,
|
||||||
|
stationsFromError?: TideStation[]
|
||||||
|
): Promise<TideStation[]> {
|
||||||
|
if (stationsFromError && stationsFromError.length > 0) {
|
||||||
|
return stationsFromError
|
||||||
|
}
|
||||||
|
if (fetchLocation.mode !== 'nearby') return []
|
||||||
|
return fetchNearbyTideStations(fetchLocation.lat, fetchLocation.lng)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTidesForEntry(options: {
|
||||||
|
fetchLocation: TideFetchLocation
|
||||||
|
entryDate: string
|
||||||
|
analyticsSource: 'entry_editor' | 'live_log'
|
||||||
|
}): Promise<TideFetchOutcome> {
|
||||||
|
const { fetchLocation, entryDate, analyticsSource } = options
|
||||||
|
const queryCoords = readQueryCoords(fetchLocation)
|
||||||
|
let stationsFromError: TideStation[] | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data =
|
||||||
|
fetchLocation.mode === 'nearby'
|
||||||
|
? await fetchTidesNearby(fetchLocation.lat, fetchLocation.lng, {
|
||||||
|
analyticsSource,
|
||||||
|
locationSource: fetchLocation.source
|
||||||
|
})
|
||||||
|
: await fetchTidesByPlace(fetchLocation.query, { analyticsSource })
|
||||||
|
|
||||||
|
const result = toResult(data, entryDate, fetchLocation)
|
||||||
|
if (result) return result
|
||||||
|
|
||||||
|
const { extrema } = extractTideTurtlePayload(data)
|
||||||
|
if (extrema.length > 0) {
|
||||||
|
throw new TidesApiError('No tide data for entry date', 'NO_DATA_FOR_DATE')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TidesApiError && error.code === 'NO_DATA_FOR_DATE') {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (error instanceof TidesApiError && error.stations?.length) {
|
||||||
|
stationsFromError = error.stations
|
||||||
|
} else if (!(error instanceof TidesApiError) || error.code !== 'NOT_FOUND') {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stations = await loadNearbyStations(fetchLocation, stationsFromError)
|
||||||
|
if (stations.length > 0) {
|
||||||
|
return {
|
||||||
|
kind: 'pick_station',
|
||||||
|
entryDate,
|
||||||
|
fetchLocation,
|
||||||
|
stations,
|
||||||
|
...queryCoords
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TidesApiError('Tide data not found', 'NOT_FOUND')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTidesForStationChoice(options: {
|
||||||
|
stationId: string
|
||||||
|
entryDate: string
|
||||||
|
fetchLocation: TideFetchLocation
|
||||||
|
queryLat?: string
|
||||||
|
queryLng?: string
|
||||||
|
analyticsSource: 'entry_editor' | 'live_log'
|
||||||
|
}): Promise<TideFetchResult> {
|
||||||
|
const data = await fetchTidesByStation(options.stationId, {
|
||||||
|
queryLat: options.queryLat,
|
||||||
|
queryLng: options.queryLng,
|
||||||
|
analyticsSource: options.analyticsSource
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = toResult(data, options.entryDate, options.fetchLocation)
|
||||||
|
if (!result) {
|
||||||
|
throw new TidesApiError('Tide data not found', 'NOT_FOUND')
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tideDataHasForecastForDate(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
entryDate: string
|
||||||
|
): boolean {
|
||||||
|
return hasTideTimesForDate(data, entryDate)
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { LIVE_EVENT_CODES } from './liveEventCodes.js'
|
||||||
|
import {
|
||||||
|
buildTideLocationMeta,
|
||||||
|
formatTideLocationLabel,
|
||||||
|
resolveTideFetchLocation,
|
||||||
|
getAvailableTideLocations
|
||||||
|
} 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('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 | undefined>) =>
|
||||||
|
`${key}:${JSON.stringify(options ?? {})}`
|
||||||
|
expect(
|
||||||
|
formatTideLocationLabel(
|
||||||
|
{
|
||||||
|
locationSource: 'gps',
|
||||||
|
lat: '53.62',
|
||||||
|
lng: '7.15',
|
||||||
|
placeName: 'Norderney, Riffgat',
|
||||||
|
distanceKm: '8'
|
||||||
|
},
|
||||||
|
t
|
||||||
|
)
|
||||||
|
).toContain('tide_fetched_from')
|
||||||
|
expect(
|
||||||
|
formatTideLocationLabel({ locationSource: 'gps', lat: '53.62', lng: '7.15' }, t)
|
||||||
|
).toContain('tide_data_for_position')
|
||||||
|
expect(
|
||||||
|
formatTideLocationLabel({ locationSource: 'gps', tideFallback: 'open_meteo' }, t)
|
||||||
|
).toContain('tide_open_meteo_fallback')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores distance from BSH API metadata', () => {
|
||||||
|
const meta = buildTideLocationMeta(
|
||||||
|
{ mode: 'nearby', lat: '53.624526', lng: '7.155263', source: 'gps' },
|
||||||
|
{
|
||||||
|
distanceKm: 8.1,
|
||||||
|
location: {
|
||||||
|
name: 'Norderney, Riffgat',
|
||||||
|
lat: 53.696389,
|
||||||
|
lon: 7.157778,
|
||||||
|
source: 'bsh_station'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
expect(meta.distanceKm).toBe('8.1')
|
||||||
|
expect(meta.placeName).toBe('Norderney, Riffgat')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns missing without position or departure', () => {
|
||||||
|
const result = resolveTideFetchLocation({
|
||||||
|
events: [],
|
||||||
|
entryDate,
|
||||||
|
departure: '',
|
||||||
|
nowMs
|
||||||
|
})
|
||||||
|
expect(result).toEqual({ error: 'missing' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getAvailableTideLocations', () => {
|
||||||
|
it('returns empty list when no locations are available', () => {
|
||||||
|
const list = getAvailableTideLocations({
|
||||||
|
departure: '',
|
||||||
|
destination: '',
|
||||||
|
events: [],
|
||||||
|
entryDate
|
||||||
|
})
|
||||||
|
expect(list).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns departure and destination when they are non-empty', () => {
|
||||||
|
const list = getAvailableTideLocations({
|
||||||
|
departure: 'Büsum',
|
||||||
|
destination: 'Helgoland',
|
||||||
|
events: [],
|
||||||
|
entryDate
|
||||||
|
})
|
||||||
|
expect(list).toHaveLength(2)
|
||||||
|
expect(list[0]).toEqual({
|
||||||
|
role: 'departure',
|
||||||
|
labelKey: 'logs.tide_role_departure',
|
||||||
|
displayLabel: 'Büsum',
|
||||||
|
fetchLocation: { mode: 'by-place', query: 'Büsum', source: 'departure' }
|
||||||
|
})
|
||||||
|
expect(list[1]).toEqual({
|
||||||
|
role: 'destination',
|
||||||
|
labelKey: 'logs.tide_role_destination',
|
||||||
|
displayLabel: 'Helgoland',
|
||||||
|
fetchLocation: { mode: 'by-place', query: 'Helgoland', source: 'destination' }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns gps when fresh position is present in events', () => {
|
||||||
|
const list = getAvailableTideLocations({
|
||||||
|
departure: 'Büsum',
|
||||||
|
destination: '',
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
time: '11:30',
|
||||||
|
remarks: LIVE_EVENT_CODES.POSITION,
|
||||||
|
gpsLat: '54.1',
|
||||||
|
gpsLng: '8.8'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
entryDate,
|
||||||
|
nowMs
|
||||||
|
})
|
||||||
|
expect(list).toHaveLength(2)
|
||||||
|
expect(list[0].role).toBe('departure')
|
||||||
|
expect(list[1]).toEqual({
|
||||||
|
role: 'gps',
|
||||||
|
labelKey: 'logs.tide_role_gps',
|
||||||
|
displayLabel: '54.1, 8.8',
|
||||||
|
fetchLocation: { mode: 'nearby', lat: '54.1', lng: '8.8', source: 'gps' }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import {
|
||||||
|
getLastLoggedPositionWithin,
|
||||||
|
getLatestLoggedPosition,
|
||||||
|
LIVE_LOG_TIDE_POSITION_MAX_AGE_MS
|
||||||
|
} from './liveEventCodes.js'
|
||||||
|
import type { LogEntryTides, LogEventPayload, TideLocationSource, TideRole } from './logEntryPayload.js'
|
||||||
|
|
||||||
|
export type { TideLocationSource }
|
||||||
|
|
||||||
|
export type TideLocationMeta = Pick<
|
||||||
|
LogEntryTides,
|
||||||
|
'locationSource' | 'placeName' | 'lat' | 'lng' | 'distanceKm' | 'tideFallback'
|
||||||
|
>
|
||||||
|
|
||||||
|
export type TideFetchLocation =
|
||||||
|
| { mode: 'nearby'; lat: string; lng: string; source: 'gps' }
|
||||||
|
| { mode: 'by-place'; query: string; source: 'departure' | 'destination' }
|
||||||
|
|
||||||
|
export interface TideLocationOption {
|
||||||
|
role: TideRole
|
||||||
|
labelKey: string
|
||||||
|
displayLabel: string
|
||||||
|
fetchLocation: TideFetchLocation
|
||||||
|
}
|
||||||
|
|
||||||
|
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' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDistanceKm(apiData: Record<string, unknown>): string | undefined {
|
||||||
|
if (apiData.distanceKm == null || apiData.distanceKm === '') return undefined
|
||||||
|
const km = Number(apiData.distanceKm)
|
||||||
|
if (Number.isNaN(km)) return undefined
|
||||||
|
return String(Math.round(km * 10) / 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTideFallback(apiData: Record<string, unknown>): 'open_meteo' | undefined {
|
||||||
|
return apiData.fallback === 'open_meteo' ? 'open_meteo' : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTideLocationMeta(
|
||||||
|
fetchLocation: TideFetchLocation,
|
||||||
|
apiData: Record<string, unknown>
|
||||||
|
): TideLocationMeta {
|
||||||
|
const apiLocation = asRecord(apiData.location)
|
||||||
|
const distanceKm = readDistanceKm(apiData)
|
||||||
|
const tideFallback = readTideFallback(apiData)
|
||||||
|
|
||||||
|
if (fetchLocation.mode === 'nearby') {
|
||||||
|
return {
|
||||||
|
locationSource: 'gps',
|
||||||
|
lat: fetchLocation.lat,
|
||||||
|
lng: fetchLocation.lng,
|
||||||
|
placeName: apiLocation?.name ? String(apiLocation.name) : undefined,
|
||||||
|
...(distanceKm ? { distanceKm } : {}),
|
||||||
|
...(tideFallback ? { tideFallback } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
...(distanceKm ? { distanceKm } : {}),
|
||||||
|
...(tideFallback ? { tideFallback } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
const distanceKm = tides.distanceKm?.trim()
|
||||||
|
|
||||||
|
if (tides.tideFallback === 'open_meteo') {
|
||||||
|
return t('logs.tide_open_meteo_fallback')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (placeName && distanceKm) {
|
||||||
|
return t('logs.tide_fetched_from', { place: placeName, distance: distanceKm })
|
||||||
|
}
|
||||||
|
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
if (tides.locationSource === 'destination') {
|
||||||
|
return t('logs.tide_fetched_from_destination', { 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,
|
||||||
|
distanceKm: tides.distanceKm,
|
||||||
|
tideFallback: tides.tideFallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvailableTideLocations(options: {
|
||||||
|
departure: string
|
||||||
|
destination: string
|
||||||
|
events: Array<Pick<LogEventPayload, 'remarks' | 'time' | 'gpsLat' | 'gpsLng'>>
|
||||||
|
entryDate: string
|
||||||
|
maxAgeMs?: number
|
||||||
|
nowMs?: number
|
||||||
|
}): TideLocationOption[] {
|
||||||
|
const optionsList: TideLocationOption[] = []
|
||||||
|
|
||||||
|
const departure = options.departure.trim()
|
||||||
|
if (departure) {
|
||||||
|
optionsList.push({
|
||||||
|
role: 'departure',
|
||||||
|
labelKey: 'logs.tide_role_departure',
|
||||||
|
displayLabel: departure,
|
||||||
|
fetchLocation: { mode: 'by-place', query: departure, source: 'departure' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const destination = options.destination.trim()
|
||||||
|
if (destination) {
|
||||||
|
optionsList.push({
|
||||||
|
role: 'destination',
|
||||||
|
labelKey: 'logs.tide_role_destination',
|
||||||
|
displayLabel: destination,
|
||||||
|
fetchLocation: { mode: 'by-place', query: destination, source: 'destination' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxAgeMs = options.maxAgeMs ?? LIVE_LOG_TIDE_POSITION_MAX_AGE_MS
|
||||||
|
const nowMs = options.nowMs ?? Date.now()
|
||||||
|
const freshGps = getLastLoggedPositionWithin(options.events, options.entryDate, maxAgeMs, nowMs)
|
||||||
|
if (freshGps) {
|
||||||
|
optionsList.push({
|
||||||
|
role: 'gps',
|
||||||
|
labelKey: 'logs.tide_role_gps',
|
||||||
|
displayLabel: `${freshGps.lat}, ${freshGps.lng}`,
|
||||||
|
fetchLocation: { mode: 'nearby', lat: freshGps.lat, lng: freshGps.lng, source: 'gps' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return optionsList
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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 BSH coordinate response with distance to nearest station', () => {
|
||||||
|
const parsed = parseTideTurtleForDate(
|
||||||
|
{
|
||||||
|
distanceKm: 8,
|
||||||
|
location: {
|
||||||
|
source: 'bsh_station',
|
||||||
|
name: 'Norderney, Riffgat',
|
||||||
|
lat: 53.696389,
|
||||||
|
lon: 7.157778,
|
||||||
|
stationId: 'norderney_riffgat'
|
||||||
|
},
|
||||||
|
tides: sampleNearby.tides
|
||||||
|
},
|
||||||
|
'2026-06-11'
|
||||||
|
)
|
||||||
|
expect(parsed.highWater).toBe('10:50')
|
||||||
|
expect(parsed.distanceKm).toBe(8)
|
||||||
|
expect(parsed.placeName).toBe('Norderney, Riffgat')
|
||||||
|
})
|
||||||
|
|
||||||
|
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,106 @@
|
|||||||
|
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 =
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -60,6 +60,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PLAUSIBLE_ENABLED: ${PLAUSIBLE_ENABLED:-false}
|
PLAUSIBLE_ENABLED: ${PLAUSIBLE_ENABLED:-false}
|
||||||
PLAUSIBLE_HOST: ${PLAUSIBLE_HOST:-https://plausible.elpatron.me}
|
PLAUSIBLE_HOST: ${PLAUSIBLE_HOST:-https://plausible.elpatron.me}
|
||||||
|
ROBOTS_NOINDEX: ${ROBOTS_NOINDEX:-true}
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ Notfall ohne Checks: `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-remotes.sh -dest s
|
|||||||
| Forward Port | `80` |
|
| Forward Port | `80` |
|
||||||
| SSL | Let's Encrypt |
|
| SSL | Let's Encrypt |
|
||||||
|
|
||||||
Empfohlen: Custom Header `X-Robots-Tag: noindex, nofollow` (Staging nicht indexieren).
|
Staging ist per Default nicht indexierbar: `ROBOTS_NOINDEX=true` im Frontend-Container setzt `X-Robots-Tag: noindex, nofollow` und liefert `robots.txt` mit `Disallow: /` (siehe `docker-compose.staging.yml`).
|
||||||
|
|
||||||
Details zu Proxy-Headern und Security: [npm-security.md](npm-security.md).
|
Details zu Proxy-Headern und Security: [npm-security.md](npm-security.md).
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ Das Script wird über `plausible-bootstrap.js` geladen; `data-domain` ist der ak
|
|||||||
| Voice Memo Uploaded | Sprachnotiz gespeichert (`voiceAttachments.ts`) | `context`: `logbook` \| `live_log` |
|
| Voice Memo 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
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ else
|
|||||||
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
|
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
|
||||||
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-prod-backend}"
|
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-prod-backend}"
|
||||||
APP_URL="${APP_URL:-https://kapteins-daagbok.eu}"
|
APP_URL="${APP_URL:-https://kapteins-daagbok.eu}"
|
||||||
DEPLOY_BRANCH=""
|
DEPLOY_BRANCH="none"
|
||||||
ENV_LABEL="Production"
|
ENV_LABEL="Production"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
|
{"type": "Feature", "id": "norderney_riffgat", "geometry": {"type": "Point", "coordinates": [7.157778, 53.696389]}, "properties": {"gauge_label": "Norderney, Riffgat", "latitude": 53.696389, "longitude": 7.157778, "area": "Jade und Ostfriesland", "forecast_timestamp": "2026-06-12 08:09:54+02:00", "high_water_low_water": [{"event_timestamp": "2026-06-12 09:20:00+02:00", "event": "HW", "tidal_prediction_value": "606", "forecast_value": 616, "forecast_uncertainty": 10.0, "forecast_deviation": "-0,1 m", "forecast_automated_event_warning": "Wasserstandsvorhersage", "forecast_event_forecast_timestamp": "2026-06-12 08:09:54+02:00"}, {"event_timestamp": "2026-06-12 15:39:00+02:00", "event": "NW", "tidal_prediction_value": "377", "forecast_value": 403, "forecast_uncertainty": 10.0, "forecast_deviation": "+0,2 m", "forecast_automated_event_warning": "Wasserstandsvorhersage", "forecast_event_forecast_timestamp": "2026-06-12 08:09:54+02:00", "mos_forecast_r0_value": 415, "mos_forecast_r0_deviation": "+0,3 m", "mos_forecast_r1_value": 409, "mos_forecast_r1_deviation": "+0,3 m", "mos_forecast_r2_value": 412, "mos_forecast_r2_deviation": "+0,3 m", "mos_forecast_r3_value": 414, "mos_forecast_r3_deviation": "+0,3 m", "mos_forecast_r4_value": 411, "mos_forecast_r4_deviation": "+0,3 m", "mos_forecast_r5_value": 400, "mos_forecast_r5_deviation": "+0,2 m", "mos_forecast_event_forecast_timestamp": "2026-06-12 10:30:00+02:00"}, {"event_timestamp": "2026-06-12 21:41:00+02:00", "event": "HW", "tidal_prediction_value": "629", "forecast_value": 666, "forecast_uncertainty": 10.0, "forecast_deviation": "+0,4 m", "forecast_automated_event_warning": "Wasserstandsvorhersage", "forecast_event_forecast_timestamp": "2026-06-12 08:09:54+02:00", "mos_forecast_r0_value": 653, "mos_forecast_r0_deviation": "+0,3 m", "mos_forecast_r1_value": 653, "mos_forecast_r1_deviation": "+0,3 m", "mos_forecast_r2_value": 658, "mos_forecast_r2_deviation": "+0,3 m", "mos_forecast_r3_value": 657, "mos_forecast_r3_deviation": "+0,3 m", "mos_forecast_r4_value": 653, "mos_forecast_r4_deviation": "+0,3 m", "mos_forecast_r5_value": 651, "mos_forecast_r5_deviation": "+0,2 m", "mos_forecast_event_forecast_timestamp": "2026-06-12 10:30:00+02:00"}, {"event_timestamp": "2026-06-13 04:14:00+02:00", "event": "NW", "tidal_prediction_value": "362", "forecast_value": 393, "forecast_uncertainty": 10.0, "forecast_deviation": "+0,1 m", "forecast_automated_event_warning": "Wasserstandsvorhersage", "forecast_event_forecast_timestamp": "2026-06-12 08:09:54+02:00", "mos_forecast_r0_value": 403, "mos_forecast_r0_deviation": "+0,2 m", "mos_forecast_r1_value": 395, "mos_forecast_r1_deviation": "+0,1 m", "mos_forecast_r2_value": 404, "mos_forecast_r2_deviation": "+0,2 m", "mos_forecast_r3_value": 400, "mos_forecast_r3_deviation": "+0,2 m", "mos_forecast_r4_value": 394, "mos_forecast_r4_deviation": "+0,1 m", "mos_forecast_r5_value": 388, "mos_forecast_r5_deviation": "+/-0,0 m", "mos_forecast_event_forecast_timestamp": "2026-06-12 10:30:00+02:00"}, {"event_timestamp": "2026-06-13 10:21:00+02:00", "event": "HW", "tidal_prediction_value": "617", "mos_forecast_r0_value": 655, "mos_forecast_r0_deviation": "+0,3 m", "mos_forecast_r1_value": 649, "mos_forecast_r1_deviation": "+0,2 m", "mos_forecast_r2_value": 656, "mos_forecast_r2_deviation": "+0,3 m", "mos_forecast_r3_value": 657, "mos_forecast_r3_deviation": "+0,3 m", "mos_forecast_r4_value": 649, "mos_forecast_r4_deviation": "+0,2 m", "mos_forecast_r5_value": 652, "mos_forecast_r5_deviation": "+0,3 m", "mos_forecast_event_forecast_timestamp": "2026-06-12 10:30:00+02:00"}, {"event_timestamp": "2026-06-13 16:47:00+02:00", "event": "NW", "tidal_prediction_value": "366", "mos_forecast_r0_value": 421, "mos_forecast_r0_deviation": "+0,4 m", "mos_forecast_r1_value": 416, "mos_forecast_r1_deviation": "+0,3 m", "mos_forecast_r2_value": 424, "mos_forecast_r2_deviation": "+0,4 m", "mos_forecast_r3_value": 410, "mos_forecast_r3_deviation": "+0,3 m", "mos_forecast_r4_value": 436, "mos_forecast_r4_deviation": "+0,5 m", "mos_forecast_r5_value": 405, "mos_forecast_r5_deviation": "+0,2 m", "mos_forecast_event_forecast_timestamp": "2026-06-12 10:30:00+02:00"}], "copyright": {"de": "@Bundesamt für Seeschifffahrt und Hydrographie (BSH). Das BSH übernimmt für die angegebenen Informationen keine Gewähr. Amtliche Wasserstandsvorhersage des Bundes gemäß §1 SeeAufG.", "en": "@Federal Maritime and Hydrographic Agency (BSH). The BSH accepts no liability for the information provided here. Official water level forecast of the federal government according to §1 SeeAufG."}}}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "bensersiel",
|
||||||
|
"name": "Bensersiel",
|
||||||
|
"lat": 53.674722,
|
||||||
|
"lon": 7.575,
|
||||||
|
"area": "Jade und Ostfriesland",
|
||||||
|
"hasHwnw": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "emden_grosse_seeschleuse",
|
||||||
|
"name": "Emden, Ems, Große Seeschleuse",
|
||||||
|
"lat": 53.336667,
|
||||||
|
"lon": 7.186389,
|
||||||
|
"area": "Ems",
|
||||||
|
"hasHwnw": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kiel-holtenau",
|
||||||
|
"name": "Kiel-Holtenau",
|
||||||
|
"lat": 54.3720866822911,
|
||||||
|
"lon": 10.1570496121807,
|
||||||
|
"area": "Kieler Bucht",
|
||||||
|
"hasHwnw": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "leyhoern_leybucht",
|
||||||
|
"name": "Leyhörn, Leybucht",
|
||||||
|
"lat": 53.549167,
|
||||||
|
"lon": 7.036111,
|
||||||
|
"area": "Jade und Ostfriesland",
|
||||||
|
"hasHwnw": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "norderney_riffgat",
|
||||||
|
"name": "Norderney, Riffgat",
|
||||||
|
"lat": 53.696389,
|
||||||
|
"lon": 7.157778,
|
||||||
|
"area": "Jade und Ostfriesland",
|
||||||
|
"hasHwnw": true
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { requireUser } from '../middleware/auth.js'
|
||||||
|
import {
|
||||||
|
fetchTidesForCoordinates,
|
||||||
|
fetchTidesForPlace,
|
||||||
|
fetchTidesForStation,
|
||||||
|
listNearbyTideStations
|
||||||
|
} from '../utils/tideProvider.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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLimit(value: unknown, fallback = 8): number {
|
||||||
|
const n = Number(value)
|
||||||
|
if (Number.isNaN(n)) return fallback
|
||||||
|
return Math.min(20, Math.max(1, Math.floor(n)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function noTideDataResponse(lat: number, lon: number) {
|
||||||
|
const stations = await listNearbyTideStations(lat, lon, 8)
|
||||||
|
if (stations.length > 0) {
|
||||||
|
return { error: 'no_tide_data', stations }
|
||||||
|
}
|
||||||
|
return { error: 'no_tide_data' }
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/stations/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 stations = await listNearbyTideStations(coords.lat, coords.lon, parseLimit(req.query.limit))
|
||||||
|
return res.json({ stations })
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('Error listing nearby tide stations:', error)
|
||||||
|
return res.status(502).json({ error: 'station_list_failed' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/station/:stationId', requireUser, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const stationId = String(req.params.stationId ?? '').trim()
|
||||||
|
if (!stationId) {
|
||||||
|
return res.status(400).json({ error: 'stationId is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const coords = parseLatLon(req.query.lat, req.query.lon)
|
||||||
|
const data = await fetchTidesForStation(
|
||||||
|
stationId,
|
||||||
|
coords ? { queryLat: coords.lat, queryLon: coords.lon } : undefined
|
||||||
|
)
|
||||||
|
return res.json(data)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Tide lookup failed'
|
||||||
|
if (message === 'bsh_invalid_station') {
|
||||||
|
return res.status(404).json({ error: 'station_not_found' })
|
||||||
|
}
|
||||||
|
if (message === 'no_tide_data') {
|
||||||
|
return res.status(404).json({ error: 'no_tide_data' })
|
||||||
|
}
|
||||||
|
console.error('Error fetching station tides:', error)
|
||||||
|
return res.status(502).json({ error: message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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') {
|
||||||
|
const coords = parseLatLon(req.query.lat, req.query.lon)
|
||||||
|
if (coords) {
|
||||||
|
return res.status(404).json(await noTideDataResponse(coords.lat, coords.lon))
|
||||||
|
}
|
||||||
|
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,85 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
findNearestBshStation,
|
||||||
|
findNearestBshStations,
|
||||||
|
haversineKm,
|
||||||
|
parseBshFeatureToExtrema,
|
||||||
|
parseBshHwnwForecast,
|
||||||
|
setBshStationCacheForTests,
|
||||||
|
type BshStation
|
||||||
|
} from './bshTides.js'
|
||||||
|
|
||||||
|
const fixturesDir = join(dirname(fileURLToPath(import.meta.url)), '../fixtures')
|
||||||
|
|
||||||
|
function loadJson<T>(name: string): T {
|
||||||
|
return JSON.parse(readFileSync(join(fixturesDir, name), 'utf8')) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
const stationIndex = loadJson<BshStation[]>('bsh-station-index.json')
|
||||||
|
|
||||||
|
describe('haversineKm', () => {
|
||||||
|
it('returns zero for identical points', () => {
|
||||||
|
expect(haversineKm(53.62, 7.15, 53.62, 7.15)).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('findNearestBshStations', () => {
|
||||||
|
it('returns multiple ranked stations', () => {
|
||||||
|
const nearest = findNearestBshStations(53.624526, 7.155263, stationIndex, 3)
|
||||||
|
expect(nearest).toHaveLength(3)
|
||||||
|
expect(nearest[0].id).toBe('norderney_riffgat')
|
||||||
|
expect(nearest[1].distanceKm).toBeGreaterThanOrEqual(nearest[0].distanceKm)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('findNearestBshStation', () => {
|
||||||
|
it('picks Norderney Riffgat for Norddeich coordinates', () => {
|
||||||
|
const nearest = findNearestBshStation(53.624526, 7.155263, stationIndex)
|
||||||
|
expect(nearest?.station.id).toBe('norderney_riffgat')
|
||||||
|
expect(nearest?.distanceKm).toBeGreaterThan(5)
|
||||||
|
expect(nearest?.distanceKm).toBeLessThan(12)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('picks Kiel-Holtenau for Kiel coordinates', () => {
|
||||||
|
const nearest = findNearestBshStation(54.32, 10.14, stationIndex)
|
||||||
|
expect(nearest?.station.id).toBe('kiel-holtenau')
|
||||||
|
expect(nearest?.distanceKm).toBeLessThan(10)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseBshHwnwForecast', () => {
|
||||||
|
it('maps HW/NW events to extrema with Europe/Berlin dates', () => {
|
||||||
|
const feature = loadJson<{ properties: Record<string, unknown> }>('bsh-norderney_riffgat.json')
|
||||||
|
const extrema = parseBshHwnwForecast(feature)
|
||||||
|
|
||||||
|
expect(extrema.length).toBeGreaterThan(0)
|
||||||
|
const high = extrema.find((e) => e.isHigh)
|
||||||
|
const low = extrema.find((e) => !e.isHigh)
|
||||||
|
expect(high?.date).toMatch(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
expect(low?.date).toMatch(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
expect(high?.time).toContain('T')
|
||||||
|
expect(high?.height).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseBshFeatureToExtrema', () => {
|
||||||
|
it('uses hwnw_forecast when available', () => {
|
||||||
|
const feature = loadJson('bsh-norderney_riffgat.json')
|
||||||
|
const extrema = parseBshFeatureToExtrema(feature)
|
||||||
|
expect(extrema.some((e) => e.isHigh)).toBe(true)
|
||||||
|
expect(extrema.some((e) => !e.isHigh)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('setBshStationCacheForTests', () => {
|
||||||
|
it('allows injecting station cache', () => {
|
||||||
|
setBshStationCacheForTests(stationIndex)
|
||||||
|
expect(findNearestBshStation(53.624526, 7.155263, stationIndex)?.station.id).toBe(
|
||||||
|
'norderney_riffgat'
|
||||||
|
)
|
||||||
|
setBshStationCacheForTests(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
import type { TideExtreme, TideLookupResult } from './openMeteoTides.js'
|
||||||
|
|
||||||
|
export const MAX_BSH_DISTANCE_KM = 75
|
||||||
|
export const BSH_TIMEZONE = 'Europe/Berlin'
|
||||||
|
|
||||||
|
const API_BASE =
|
||||||
|
'https://gdi.bsh.de/ldproxy/rest/services/WaterLevelForecast/collections/waterlevelforecastdata/items'
|
||||||
|
const LIST_LIMIT = 1000
|
||||||
|
const MAX_PAGES = 20
|
||||||
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
||||||
|
const FETCH_TIMEOUT_MS = 15_000
|
||||||
|
|
||||||
|
export interface BshStation {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
lat: number
|
||||||
|
lon: number
|
||||||
|
area?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OgcFeatureCollection {
|
||||||
|
features?: OgcFeature[]
|
||||||
|
links?: Array<{ rel?: string; href?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OgcFeature {
|
||||||
|
type?: string
|
||||||
|
id?: string
|
||||||
|
geometry?: { coordinates?: [number, number] }
|
||||||
|
properties?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HwnwEvent {
|
||||||
|
event?: string
|
||||||
|
event_timestamp?: string
|
||||||
|
forecast_value?: number | string | null
|
||||||
|
tidal_prediction_value?: number | string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CurvePoint {
|
||||||
|
timestamp?: string
|
||||||
|
automated_curve_forecast?: number | string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
let stationCache: { stations: BshStation[]; loadedAt: number } | null = null
|
||||||
|
|
||||||
|
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, redirect: 'follow' })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`BSH HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
return data as T
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNum(value: unknown): number | null {
|
||||||
|
if (value == null || value === '') return null
|
||||||
|
if (typeof value === 'number') return value
|
||||||
|
const n = Number(value)
|
||||||
|
return Number.isNaN(n) ? null : n
|
||||||
|
}
|
||||||
|
|
||||||
|
function stationFromFeature(feature: OgcFeature): BshStation | null {
|
||||||
|
const id = feature.id
|
||||||
|
const props = feature.properties
|
||||||
|
if (!id || !props) return null
|
||||||
|
|
||||||
|
const name = String(props.gauge_label ?? '').trim()
|
||||||
|
if (!name) return null
|
||||||
|
|
||||||
|
const geom = feature.geometry?.coordinates
|
||||||
|
const lat = parseNum(props.latitude) ?? (geom ? geom[1] : null)
|
||||||
|
const lon = parseNum(props.longitude) ?? (geom ? geom[0] : null)
|
||||||
|
if (lat == null || lon == null) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
area: props.area ? String(props.area) : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const R = 6371
|
||||||
|
const p = Math.PI / 180
|
||||||
|
const dLat = (lat2 - lat1) * p
|
||||||
|
const dLon = (lon2 - lon1) * p
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(lat1 * p) * Math.cos(lat2 * p) * Math.sin(dLon / 2) ** 2
|
||||||
|
return 2 * R * Math.asin(Math.sqrt(a))
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BshStationSuggestion {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
lat: number
|
||||||
|
lon: number
|
||||||
|
distanceKm: number
|
||||||
|
area?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findNearestBshStations(
|
||||||
|
lat: number,
|
||||||
|
lon: number,
|
||||||
|
stations: BshStation[],
|
||||||
|
limit = 8
|
||||||
|
): BshStationSuggestion[] {
|
||||||
|
const ranked = stations
|
||||||
|
.map((station) => ({
|
||||||
|
id: station.id,
|
||||||
|
name: station.name,
|
||||||
|
lat: station.lat,
|
||||||
|
lon: station.lon,
|
||||||
|
area: station.area,
|
||||||
|
distanceKm: Number(haversineKm(lat, lon, station.lat, station.lon).toFixed(1))
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.distanceKm - b.distanceKm)
|
||||||
|
|
||||||
|
return ranked.slice(0, Math.max(1, limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findNearestBshStation(
|
||||||
|
lat: number,
|
||||||
|
lon: number,
|
||||||
|
stations: BshStation[]
|
||||||
|
): { station: BshStation; distanceKm: number } | null {
|
||||||
|
const nearest = findNearestBshStations(lat, lon, stations, 1)[0]
|
||||||
|
if (!nearest) return null
|
||||||
|
return {
|
||||||
|
station: {
|
||||||
|
id: nearest.id,
|
||||||
|
name: nearest.name,
|
||||||
|
lat: nearest.lat,
|
||||||
|
lon: nearest.lon,
|
||||||
|
area: nearest.area
|
||||||
|
},
|
||||||
|
distanceKm: nearest.distanceKm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadBshStationIndex(): Promise<BshStation[]> {
|
||||||
|
if (stationCache && Date.now() - stationCache.loadedAt < CACHE_TTL_MS) {
|
||||||
|
return stationCache.stations
|
||||||
|
}
|
||||||
|
|
||||||
|
const stations: BshStation[] = []
|
||||||
|
let nextUrl: string | null = `${API_BASE}?f=json&limit=${LIST_LIMIT}`
|
||||||
|
|
||||||
|
for (let page = 0; page < MAX_PAGES && nextUrl; page += 1) {
|
||||||
|
const currentUrl = nextUrl
|
||||||
|
const payload: OgcFeatureCollection = await fetchJson<OgcFeatureCollection>(currentUrl)
|
||||||
|
const features = payload.features ?? []
|
||||||
|
for (const feature of features) {
|
||||||
|
const station = stationFromFeature(feature)
|
||||||
|
if (station) stations.push(station)
|
||||||
|
}
|
||||||
|
|
||||||
|
nextUrl = null
|
||||||
|
const links = payload.links ?? []
|
||||||
|
for (let i = 0; i < links.length; i += 1) {
|
||||||
|
const link = links[i]
|
||||||
|
if (link.rel === 'next' && link.href) {
|
||||||
|
nextUrl = link.href
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stations.length === 0) {
|
||||||
|
throw new Error('bsh_empty_station_list')
|
||||||
|
}
|
||||||
|
|
||||||
|
stationCache = { stations, loadedAt: Date.now() }
|
||||||
|
return stations
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test helper: inject a pre-built station list and skip live index fetch. */
|
||||||
|
export function setBshStationCacheForTests(stations: BshStation[] | null): void {
|
||||||
|
stationCache = stations ? { stations, loadedAt: Date.now() } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
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 bshTimestampToIso(timestamp: string): string {
|
||||||
|
const normalized = timestamp.trim().replace(' ', 'T')
|
||||||
|
const date = new Date(normalized)
|
||||||
|
if (Number.isNaN(date.getTime())) return ''
|
||||||
|
return date.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function heightMetresFromCm(value: unknown): number {
|
||||||
|
const cm = parseNum(value)
|
||||||
|
if (cm == null) return 0
|
||||||
|
return Number((cm / 100).toFixed(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBshHwnwForecast(
|
||||||
|
feature: OgcFeature,
|
||||||
|
timeZone = BSH_TIMEZONE
|
||||||
|
): TideExtreme[] {
|
||||||
|
const props = feature.properties ?? {}
|
||||||
|
const hwnw = props.high_water_low_water
|
||||||
|
if (!Array.isArray(hwnw) || hwnw.length === 0) return []
|
||||||
|
|
||||||
|
const extrema: TideExtreme[] = []
|
||||||
|
for (const raw of hwnw as HwnwEvent[]) {
|
||||||
|
const event = String(raw.event ?? '').toUpperCase()
|
||||||
|
const timestamp = String(raw.event_timestamp ?? '').trim()
|
||||||
|
if (!timestamp || (event !== 'HW' && event !== 'NW')) continue
|
||||||
|
|
||||||
|
const iso = bshTimestampToIso(timestamp)
|
||||||
|
if (!iso) continue
|
||||||
|
|
||||||
|
const value = raw.forecast_value ?? raw.tidal_prediction_value
|
||||||
|
extrema.push({
|
||||||
|
time: iso,
|
||||||
|
date: localDateFromIso(iso, timeZone),
|
||||||
|
height: heightMetresFromCm(value),
|
||||||
|
isHigh: event === 'HW'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return extrema
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBshCurveForecast(
|
||||||
|
feature: OgcFeature,
|
||||||
|
timeZone = BSH_TIMEZONE
|
||||||
|
): TideExtreme[] {
|
||||||
|
const curve = feature.properties?.curve
|
||||||
|
if (!Array.isArray(curve) || curve.length < 3) return []
|
||||||
|
|
||||||
|
const points = (curve as CurvePoint[])
|
||||||
|
.map((p) => ({
|
||||||
|
timestamp: String(p.timestamp ?? '').trim(),
|
||||||
|
level: parseNum(p.automated_curve_forecast)
|
||||||
|
}))
|
||||||
|
.filter((p) => p.timestamp && p.level != null) as Array<{
|
||||||
|
timestamp: string
|
||||||
|
level: number
|
||||||
|
}>
|
||||||
|
|
||||||
|
const extrema: TideExtreme[] = []
|
||||||
|
for (let i = 1; i < points.length - 1; i += 1) {
|
||||||
|
const prev = points[i - 1].level
|
||||||
|
const curr = points[i].level
|
||||||
|
const next = points[i + 1].level
|
||||||
|
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 iso = bshTimestampToIso(points[i].timestamp)
|
||||||
|
if (!iso) continue
|
||||||
|
extrema.push({
|
||||||
|
time: iso,
|
||||||
|
date: localDateFromIso(iso, timeZone),
|
||||||
|
height: Number((curr / 100).toFixed(2)),
|
||||||
|
isHigh
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return extrema
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBshFeatureToExtrema(feature: OgcFeature): TideExtreme[] {
|
||||||
|
const hwnw = parseBshHwnwForecast(feature)
|
||||||
|
if (hwnw.length > 0) return hwnw
|
||||||
|
return parseBshCurveForecast(feature)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBshStationFeature(stationId: string): Promise<OgcFeature> {
|
||||||
|
const feature = await fetchJson<OgcFeature>(`${API_BASE}/${stationId}?f=json`)
|
||||||
|
if (feature.type !== 'Feature' || !feature.properties) {
|
||||||
|
throw new Error('bsh_invalid_station')
|
||||||
|
}
|
||||||
|
return feature
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BshTideLookupResult extends TideLookupResult {
|
||||||
|
distanceKm: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listNearbyBshStations(
|
||||||
|
lat: number,
|
||||||
|
lon: number,
|
||||||
|
limit = 8
|
||||||
|
): Promise<BshStationSuggestion[]> {
|
||||||
|
const stations = await loadBshStationIndex()
|
||||||
|
return findNearestBshStations(lat, lon, stations, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBshTideResult(
|
||||||
|
station: BshStation,
|
||||||
|
distanceKm: number,
|
||||||
|
feature: OgcFeature
|
||||||
|
): BshTideLookupResult {
|
||||||
|
const extrema = parseBshFeatureToExtrema(feature)
|
||||||
|
if (extrema.length === 0) {
|
||||||
|
throw new Error('no_tide_data')
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyright = feature.properties?.copyright
|
||||||
|
let sourceNote = 'BSH Wasserstandsvorhersage (© BSH, CC BY 4.0)'
|
||||||
|
if (copyright && typeof copyright === 'object' && copyright !== null) {
|
||||||
|
const cr = copyright as Record<string, string>
|
||||||
|
sourceNote = cr.de || cr.en || sourceNote
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
distanceKm: Number(distanceKm.toFixed(1)),
|
||||||
|
location: {
|
||||||
|
name: station.name,
|
||||||
|
lat: station.lat,
|
||||||
|
lon: station.lon,
|
||||||
|
source: 'bsh_station',
|
||||||
|
stationId: station.id
|
||||||
|
},
|
||||||
|
tides: {
|
||||||
|
data: {
|
||||||
|
timezone: BSH_TIMEZONE,
|
||||||
|
datum: 'gauge',
|
||||||
|
source: sourceNote,
|
||||||
|
extrema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBshTidesForStation(
|
||||||
|
stationId: string,
|
||||||
|
options?: { queryLat?: number; queryLon?: number }
|
||||||
|
): Promise<BshTideLookupResult> {
|
||||||
|
const stations = await loadBshStationIndex()
|
||||||
|
const station = stations.find((item) => item.id === stationId)
|
||||||
|
if (!station) {
|
||||||
|
throw new Error('bsh_invalid_station')
|
||||||
|
}
|
||||||
|
|
||||||
|
const feature = await fetchBshStationFeature(stationId)
|
||||||
|
const distanceKm =
|
||||||
|
options?.queryLat != null && options?.queryLon != null
|
||||||
|
? haversineKm(options.queryLat, options.queryLon, station.lat, station.lon)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return buildBshTideResult(station, distanceKm, feature)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBshTidesForCoordinates(
|
||||||
|
lat: number,
|
||||||
|
lon: number
|
||||||
|
): Promise<BshTideLookupResult> {
|
||||||
|
const stations = await loadBshStationIndex()
|
||||||
|
const nearest = findNearestBshStation(lat, lon, stations)
|
||||||
|
if (!nearest) {
|
||||||
|
throw new Error('no_bsh_station')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nearest.distanceKm > MAX_BSH_DISTANCE_KM) {
|
||||||
|
const err = new Error('bsh_station_too_far') as Error & { distanceKm?: number }
|
||||||
|
err.distanceKm = nearest.distanceKm
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
const feature = await fetchBshStationFeature(nearest.station.id)
|
||||||
|
return buildBshTideResult(nearest.station, nearest.distanceKm, feature)
|
||||||
|
}
|
||||||
@@ -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,285 @@
|
|||||||
|
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 type TideLocationSource = 'coordinates' | 'geocoded' | 'bsh_station'
|
||||||
|
|
||||||
|
export interface TideLookupResult {
|
||||||
|
location: {
|
||||||
|
name?: string
|
||||||
|
lat: number
|
||||||
|
lon: number
|
||||||
|
source: TideLocationSource
|
||||||
|
stationId?: string
|
||||||
|
}
|
||||||
|
tides: {
|
||||||
|
data: {
|
||||||
|
timezone: string
|
||||||
|
datum: 'MSL' | 'gauge'
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceGermanDigraphs(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/ae/g, 'ä')
|
||||||
|
.replace(/oe/g, 'ö')
|
||||||
|
.replace(/ue/g, 'ü')
|
||||||
|
.replace(/Ae/g, 'Ä')
|
||||||
|
.replace(/Oe/g, 'Ö')
|
||||||
|
.replace(/Ue/g, 'Ü')
|
||||||
|
.replace(/AE/g, 'Ä')
|
||||||
|
.replace(/OE/g, 'Ö')
|
||||||
|
.replace(/UE/g, 'Ü');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doGeocode(q: string): Promise<GeocodingResult | null> {
|
||||||
|
const url = new URL(GEOCODING_API)
|
||||||
|
url.searchParams.set('name', q.trim())
|
||||||
|
url.searchParams.set('count', '10')
|
||||||
|
url.searchParams.set('language', 'de')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchJson<{ results?: GeocodingResult[] }>(url.toString())
|
||||||
|
const results = data.results ?? []
|
||||||
|
if (results.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const sorted = [...results].sort((a, b) => scoreGeocodingResult(q, b) - scoreGeocodingResult(q, a))
|
||||||
|
return sorted[0]
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[geocodePlace] Geocoding API request failed for "${q}":`, err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function geocodePlace(query: string): Promise<GeocodingResult | null> {
|
||||||
|
let match = await doGeocode(query)
|
||||||
|
if (!match) {
|
||||||
|
const fallbackQuery = replaceGermanDigraphs(query)
|
||||||
|
if (fallbackQuery !== query) {
|
||||||
|
match = await doGeocode(fallbackQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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'
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import * as bshTides from './bshTides.js'
|
||||||
|
import * as openMeteoTides from './openMeteoTides.js'
|
||||||
|
import { fetchTidesForCoordinates, fetchTidesForPlace } from './tideProvider.js'
|
||||||
|
|
||||||
|
describe('fetchTidesForCoordinates', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns BSH data when station is within range', async () => {
|
||||||
|
vi.spyOn(bshTides, 'fetchBshTidesForCoordinates').mockResolvedValue({
|
||||||
|
distanceKm: 8,
|
||||||
|
location: {
|
||||||
|
name: 'Norderney, Riffgat',
|
||||||
|
lat: 53.696389,
|
||||||
|
lon: 7.157778,
|
||||||
|
source: 'bsh_station',
|
||||||
|
stationId: 'norderney_riffgat'
|
||||||
|
},
|
||||||
|
tides: {
|
||||||
|
data: {
|
||||||
|
timezone: 'Europe/Berlin',
|
||||||
|
datum: 'gauge',
|
||||||
|
source: 'BSH',
|
||||||
|
extrema: [
|
||||||
|
{
|
||||||
|
time: '2026-06-12T07:20:00.000Z',
|
||||||
|
date: '2026-06-12',
|
||||||
|
height: 6.16,
|
||||||
|
isHigh: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await fetchTidesForCoordinates(53.62, 7.15)
|
||||||
|
expect(result.distanceKm).toBe(8)
|
||||||
|
expect(result.location.source).toBe('bsh_station')
|
||||||
|
expect(result.fallback).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to Open-Meteo when BSH station is too far', async () => {
|
||||||
|
vi.spyOn(bshTides, 'fetchBshTidesForCoordinates').mockRejectedValue(
|
||||||
|
Object.assign(new Error('bsh_station_too_far'), { distanceKm: 120 })
|
||||||
|
)
|
||||||
|
vi.spyOn(openMeteoTides, 'fetchTidesForCoordinates').mockResolvedValue({
|
||||||
|
location: { lat: 62, lon: 5, source: 'coordinates' },
|
||||||
|
tides: {
|
||||||
|
data: {
|
||||||
|
timezone: 'Europe/Oslo',
|
||||||
|
datum: 'MSL',
|
||||||
|
source: 'Open-Meteo Marine',
|
||||||
|
extrema: [
|
||||||
|
{
|
||||||
|
time: '2026-06-12T10:00:00.000Z',
|
||||||
|
date: '2026-06-12',
|
||||||
|
height: 1.2,
|
||||||
|
isHigh: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await fetchTidesForCoordinates(62, 5)
|
||||||
|
expect(result.fallback).toBe('open_meteo')
|
||||||
|
expect(result.tides.data.source).toContain('Fallback')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fetchTidesForPlace', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches BSH station directly by name startsWith', async () => {
|
||||||
|
vi.spyOn(bshTides, 'loadBshStationIndex').mockResolvedValue([
|
||||||
|
{ id: 'buesum_schleuse', name: 'Büsum, Schleuse', lat: 54.12, lon: 8.85 }
|
||||||
|
])
|
||||||
|
const fetchSpy = vi.spyOn(bshTides, 'fetchBshTidesForStation').mockResolvedValue({
|
||||||
|
distanceKm: 0,
|
||||||
|
location: { name: 'Büsum, Schleuse', lat: 54.12, lon: 8.85, source: 'bsh_station', stationId: 'buesum_schleuse' },
|
||||||
|
tides: { data: { timezone: 'Europe/Berlin', datum: 'gauge', source: 'BSH', extrema: [] } }
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await fetchTidesForPlace('Buesum')
|
||||||
|
expect(fetchSpy).toHaveBeenCalledWith('buesum_schleuse', undefined)
|
||||||
|
expect(result.location.name).toBe('Büsum, Schleuse')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to geocoding if BSH station index does not match', async () => {
|
||||||
|
vi.spyOn(bshTides, 'loadBshStationIndex').mockResolvedValue([
|
||||||
|
{ id: 'buesum_schleuse', name: 'Büsum, Schleuse', lat: 54.12, lon: 8.85 }
|
||||||
|
])
|
||||||
|
vi.spyOn(openMeteoTides, 'geocodePlace').mockResolvedValue({
|
||||||
|
name: 'Kiel',
|
||||||
|
latitude: 54.32,
|
||||||
|
longitude: 10.13
|
||||||
|
})
|
||||||
|
const coordSpy = vi.spyOn(bshTides, 'fetchBshTidesForCoordinates').mockResolvedValue({
|
||||||
|
distanceKm: 0,
|
||||||
|
location: { name: 'Kiel-Holtenau', lat: 54.37, lon: 10.15, source: 'bsh_station', stationId: 'kiel_holtenau' },
|
||||||
|
tides: { data: { timezone: 'Europe/Berlin', datum: 'gauge', source: 'BSH', extrema: [] } }
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await fetchTidesForPlace('Kiel')
|
||||||
|
expect(coordSpy).toHaveBeenCalledWith(54.32, 10.13)
|
||||||
|
expect(result.location.name).toBe('Kiel-Holtenau')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import {
|
||||||
|
fetchBshTidesForCoordinates,
|
||||||
|
fetchBshTidesForStation,
|
||||||
|
listNearbyBshStations,
|
||||||
|
loadBshStationIndex,
|
||||||
|
MAX_BSH_DISTANCE_KM,
|
||||||
|
type BshStationSuggestion
|
||||||
|
} from './bshTides.js'
|
||||||
|
import {
|
||||||
|
fetchTidesForCoordinates as fetchOpenMeteoTidesForCoordinates,
|
||||||
|
fetchTidesForPlace as fetchOpenMeteoTidesForPlace,
|
||||||
|
geocodePlace,
|
||||||
|
type TideLookupResult
|
||||||
|
} from './openMeteoTides.js'
|
||||||
|
|
||||||
|
export type TideProviderResult = TideLookupResult & {
|
||||||
|
distanceKm?: number
|
||||||
|
fallback?: 'open_meteo'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTidesForCoordinates(
|
||||||
|
lat: number,
|
||||||
|
lon: number,
|
||||||
|
options?: { name?: string; source?: 'coordinates' | 'geocoded' }
|
||||||
|
): Promise<TideProviderResult> {
|
||||||
|
try {
|
||||||
|
const bsh = await fetchBshTidesForCoordinates(lat, lon)
|
||||||
|
return bsh
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : ''
|
||||||
|
const tooFar = message === 'bsh_station_too_far'
|
||||||
|
const noStation = message === 'no_bsh_station' || message === 'bsh_empty_station_list'
|
||||||
|
const noData = message === 'no_tide_data'
|
||||||
|
|
||||||
|
if (!tooFar && !noStation && !noData) {
|
||||||
|
console.warn('BSH tide lookup failed, trying Open-Meteo fallback:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = await fetchOpenMeteoTidesForCoordinates(lat, lon, options)
|
||||||
|
return {
|
||||||
|
...fallback,
|
||||||
|
fallback: 'open_meteo',
|
||||||
|
tides: {
|
||||||
|
data: {
|
||||||
|
...fallback.tides.data,
|
||||||
|
source: `${fallback.tides.data.source} (Fallback — keine BSH-Station innerhalb ${MAX_BSH_DISTANCE_KM} km)`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listNearbyTideStations(
|
||||||
|
lat: number,
|
||||||
|
lon: number,
|
||||||
|
limit = 8
|
||||||
|
): Promise<BshStationSuggestion[]> {
|
||||||
|
try {
|
||||||
|
return await listNearbyBshStations(lat, lon, limit)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTidesForStation(
|
||||||
|
stationId: string,
|
||||||
|
options?: { queryLat?: number; queryLon?: number }
|
||||||
|
): Promise<TideProviderResult> {
|
||||||
|
try {
|
||||||
|
return await fetchBshTidesForStation(stationId, options)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : ''
|
||||||
|
if (message === 'bsh_invalid_station' || message === 'no_tide_data') {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
console.warn('BSH station tide lookup failed:', error)
|
||||||
|
throw new Error('no_tide_data')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeForMatching(s: string): string {
|
||||||
|
return s
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/ae/g, 'ä')
|
||||||
|
.replace(/oe/g, 'ö')
|
||||||
|
.replace(/ue/g, 'ü')
|
||||||
|
.replace(/ss/g, 'ß');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTidesForPlace(query: string): Promise<TideProviderResult> {
|
||||||
|
const normQuery = normalizeForMatching(query)
|
||||||
|
if (normQuery) {
|
||||||
|
try {
|
||||||
|
const stations = await loadBshStationIndex()
|
||||||
|
let match = stations.find(s => normalizeForMatching(s.name) === normQuery)
|
||||||
|
if (!match) {
|
||||||
|
match = stations.find(s => normalizeForMatching(s.name).startsWith(normQuery))
|
||||||
|
}
|
||||||
|
if (match) {
|
||||||
|
return await fetchTidesForStation(match.id)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[tideProvider] Direct BSH station lookup failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const place = await geocodePlace(query)
|
||||||
|
if (!place) {
|
||||||
|
if (normQuery) {
|
||||||
|
try {
|
||||||
|
const stations = await loadBshStationIndex()
|
||||||
|
const match = stations.find(s =>
|
||||||
|
normalizeForMatching(s.name).includes(normQuery) ||
|
||||||
|
normQuery.includes(normalizeForMatching(s.name))
|
||||||
|
)
|
||||||
|
if (match) {
|
||||||
|
return await fetchTidesForStation(match.id)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[tideProvider] Fallback BSH station lookup failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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