Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 759cc4b839 | |||
| 932a73ab0c | |||
| 5b9c1e3220 | |||
| abd5fe1ac8 | |||
| e03163735e | |||
| 0e0f045e84 | |||
| 4f519e34b4 | |||
| 7d6c908f65 | |||
| 0b46154696 | |||
| 9634370a08 | |||
| 1bad0531b5 | |||
| 5d4e498528 | |||
| d667062ec2 | |||
| 0bfc38f290 | |||
| 943ce838af | |||
| f7ad7001d7 | |||
| 444d347c56 | |||
| a185bbaf27 | |||
| 864d45714c | |||
| faf3b8e3cf | |||
| 74ff8eb16b | |||
| 81d3e3b777 | |||
| 97c5173e63 | |||
| 8b34044481 | |||
| d948325a45 | |||
| 8b8196f6e3 | |||
| 6593b320ee | |||
| 9a931024d6 | |||
| 4dfe2cea4e | |||
| 944f4518e9 | |||
| 0c765f712c | |||
| 676547686b | |||
| 66606c5eca | |||
| a30fac029d | |||
| 796e61f4ea | |||
| 594c65d1a5 |
@@ -64,3 +64,8 @@ NTFY_TOKEN=tk_example_ntfy_access_token
|
||||
# Staging: PLAUSIBLE_ENABLED=false (default in docker-compose.staging.yml)
|
||||
PLAUSIBLE_ENABLED=true
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
{"plausibleEnabled":${PLAUSIBLE_ENABLED_JSON},"plausibleHost":"${PLAUSIBLE_HOST}"}
|
||||
|
||||
@@ -9,6 +9,7 @@ server {
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" 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;
|
||||
${ROBOTS_NOINDEX_HEADER}
|
||||
|
||||
# Service worker and app shell must revalidate so PWA updates are detected
|
||||
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>
|
||||
@@ -1939,6 +1939,21 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.logbook-card-right-group {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.logbook-card-right-group .logbook-card-chevron {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.logbook-card .logbook-title-editable,
|
||||
.logbook-card .logbook-title-inline-edit,
|
||||
.logbook-card .card-title-row {
|
||||
@@ -2165,6 +2180,16 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
color: var(--app-text-subtle);
|
||||
}
|
||||
|
||||
.entry-count-badge {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--app-text-muted);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.entry-sign-badge {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
@@ -2958,6 +2983,12 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.logbook-card-right-group .btn-pdf,
|
||||
.logbook-card-right-group .btn-delete {
|
||||
position: static;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -3305,6 +3336,51 @@ html.theme-cupertino .events-scroll-container {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.photo-maximized-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #f1f5f9;
|
||||
border-radius: 50%;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 11005;
|
||||
}
|
||||
|
||||
.photo-maximized-nav:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: #ffffff;
|
||||
transform: translateY(-50%) scale(1.08);
|
||||
}
|
||||
|
||||
.photo-maximized-prev {
|
||||
left: 24px;
|
||||
}
|
||||
|
||||
.photo-maximized-next {
|
||||
right: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.photo-maximized-nav {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.photo-maximized-prev {
|
||||
left: 12px;
|
||||
}
|
||||
.photo-maximized-next {
|
||||
right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Dialog Modals Styling */
|
||||
.custom-dialog-overlay {
|
||||
position: fixed;
|
||||
@@ -3805,6 +3881,45 @@ html.theme-cupertino .events-scroll-container {
|
||||
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 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
@@ -4531,6 +4646,49 @@ html.theme-cupertino .events-scroll-container {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* Tides accordion (LogEntryEditor) */
|
||||
.tides-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.tides-panel__hints {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tides-panel__hints .form-hint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--app-text-muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.tides-panel__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 {
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
@@ -6523,3 +6681,148 @@ body.app-tour-active .feedback-modal-overlay--tour .disclaimer-modal-panel {
|
||||
cursor: pointer;
|
||||
accent-color: var(--app-accent, #fbbf24);
|
||||
}
|
||||
|
||||
/* Language Dropdown */
|
||||
.lang-dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.lang-dropdown-trigger-flag {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.lang-dropdown-chevron {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.75;
|
||||
transition: transform 0.2s ease;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.lang-dropdown.is-open .lang-dropdown-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.lang-dropdown-menu {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
top: calc(100% + 8px);
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
list-style: none;
|
||||
border: 1px solid var(--app-input-border, rgba(255, 255, 255, 0.1));
|
||||
border-radius: var(--app-radius-input, 12px);
|
||||
box-shadow: var(--app-card-shadow, 0 10px 30px rgba(0, 0, 0, 0.3));
|
||||
min-width: 140px;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
animation: slideDownFade 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes slideDownFade {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.lang-dropdown.align-right .lang-dropdown-menu {
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.lang-dropdown.align-left .lang-dropdown-menu {
|
||||
left: 0;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
html.scheme-light .lang-dropdown-menu {
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
html.scheme-dark .lang-dropdown-menu {
|
||||
background: #1c1c1e;
|
||||
color: #f8fafc;
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.lang-dropdown-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: calc(var(--app-radius-input, 12px) - 4px);
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.lang-flag-svg {
|
||||
width: 20px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.lang-flag-svg.trigger-icon-only {
|
||||
width: 24px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
html.scheme-light .lang-dropdown-option {
|
||||
color: #334155;
|
||||
-webkit-text-fill-color: #334155;
|
||||
}
|
||||
|
||||
html.scheme-dark .lang-dropdown-option {
|
||||
color: #cbd5e1;
|
||||
-webkit-text-fill-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.lang-dropdown-option:hover {
|
||||
background: var(--app-accent-bg, rgba(217, 119, 6, 0.1));
|
||||
}
|
||||
|
||||
html.scheme-light .lang-dropdown-option:hover {
|
||||
color: var(--app-accent, #d97706);
|
||||
-webkit-text-fill-color: var(--app-accent, #d97706);
|
||||
}
|
||||
|
||||
html.scheme-dark .lang-dropdown-option:hover {
|
||||
color: var(--app-accent-light, #fbbf24);
|
||||
-webkit-text-fill-color: var(--app-accent-light, #fbbf24);
|
||||
}
|
||||
|
||||
.lang-dropdown-option.is-selected {
|
||||
background: var(--app-accent-bg, rgba(217, 119, 6, 0.15));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
html.scheme-light .lang-dropdown-option.is-selected {
|
||||
color: var(--app-accent, #d97706);
|
||||
-webkit-text-fill-color: var(--app-accent, #d97706);
|
||||
}
|
||||
|
||||
html.scheme-dark .lang-dropdown-option.is-selected {
|
||||
color: var(--app-accent-light, #fbbf24);
|
||||
-webkit-text-fill-color: var(--app-accent-light, #fbbf24);
|
||||
}
|
||||
|
||||
.lang-trigger-name {
|
||||
max-width: 80px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
+4
-12
@@ -46,14 +46,14 @@ import { db } from './services/db.js'
|
||||
import { getLogbookAccess } from './services/logbookAccess.js'
|
||||
import type { LogbookAccessRole } from './services/logbook.js'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react'
|
||||
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, BarChart2 } from 'lucide-react'
|
||||
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
|
||||
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
|
||||
import ProfileHeaderButton from './components/ProfileHeaderButton.tsx'
|
||||
import AdminHeaderButton from './components/AdminHeaderButton.tsx'
|
||||
import { checkAdminAccess } from './services/adminApi.js'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage } from './utils/i18nLanguages.js'
|
||||
import LanguageDropdown from './components/LanguageDropdown.tsx'
|
||||
import {
|
||||
resolveTourLogbookContext,
|
||||
seedDemoLogbookIfNeeded
|
||||
@@ -66,7 +66,7 @@ import { requestPersistentStorage } from './utils/storagePersist.js'
|
||||
const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
|
||||
|
||||
function App() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { t } = useTranslation()
|
||||
const { confirmLeave } = useUnsavedChangesContext()
|
||||
const { registerNavigation, registerDemoTourContext, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
@@ -555,10 +555,6 @@ function App() {
|
||||
localStorage.removeItem('active_logbook_title')
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
const handleExitDemo = () => {
|
||||
window.history.replaceState({}, document.title, '/')
|
||||
syncRouteFromLocation()
|
||||
@@ -715,10 +711,7 @@ function App() {
|
||||
{online ? <Wifi size={18} /> : <WifiOff size={18} />}
|
||||
<span>{online ? 'Online' : t('sync.status_offline')}</span>
|
||||
</div>
|
||||
|
||||
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
||||
<Languages size={18} />
|
||||
</button>
|
||||
<LanguageDropdown variant="icon" align="right" />
|
||||
|
||||
{isAdminUser && <AdminHeaderButton onClick={openAdmin} />}
|
||||
|
||||
@@ -859,7 +852,6 @@ function App() {
|
||||
{activeTab === 'settings' && (
|
||||
<SettingsForm
|
||||
logbookId={activeLogbookId}
|
||||
onLogbookRestored={selectLogbook}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
||||
import LanguageDropdown from './LanguageDropdown.tsx'
|
||||
import {
|
||||
registerUser,
|
||||
loginUser,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
logoutUser,
|
||||
resolveRestoreUsername
|
||||
} from '../services/auth.js'
|
||||
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
|
||||
import { KeyRound, ShieldAlert, HelpCircle, UserRound, X } from 'lucide-react'
|
||||
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
|
||||
import DisclaimerModal from './DisclaimerModal.tsx'
|
||||
import BetaBadge from './BetaBadge.tsx'
|
||||
@@ -37,7 +37,7 @@ export default function AuthOnboarding({
|
||||
onOpenDemo,
|
||||
restoreSession = false
|
||||
}: AuthOnboardingProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { t } = useTranslation()
|
||||
const [username, setUsername] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -267,9 +267,6 @@ export default function AuthOnboarding({
|
||||
setKnownUsers(getKnownUsernames())
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
const copyToClipboard = () => {
|
||||
if (recoveryPhrase) {
|
||||
@@ -780,10 +777,7 @@ export default function AuthOnboarding({
|
||||
</div>
|
||||
|
||||
<div className="auth-footer">
|
||||
<button type="button" className="btn-icon-text" onClick={toggleLanguage}>
|
||||
<Languages size={18} />
|
||||
{t(`languages.${getNextLanguage(i18n.language)}`)}
|
||||
</button>
|
||||
<LanguageDropdown variant="text" align="left" />
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon-text link-sec"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
||||
import LanguageDropdown from './LanguageDropdown.tsx'
|
||||
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
|
||||
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
|
||||
import type { LogbookCrewSelectionData } from '../types/person.js'
|
||||
import { personToSnapshot } from '../utils/personSnapshots.js'
|
||||
import LogEntriesList from './LogEntriesList.tsx'
|
||||
import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react'
|
||||
import { Ship, Users, FileText, Lock, ChevronLeft, UserPlus } from 'lucide-react'
|
||||
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
|
||||
import type { VesselData } from '../types/vessel.js'
|
||||
import type { LogbookVesselSelectionData } from '../types/vessel.js'
|
||||
@@ -52,9 +52,6 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
||||
}
|
||||
}, [registerNavigation, registerDemoTourContext, startTour, fixture.firstEntryId])
|
||||
|
||||
const toggleLanguage = () => {
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
@@ -111,10 +108,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
||||
<UserPlus size={14} style={{ marginRight: '4px' }} />
|
||||
{t('demo.cta_register')}
|
||||
</button>
|
||||
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}>
|
||||
<Globe size={14} style={{ marginRight: '4px' }} />
|
||||
{t(`languages.${getNextLanguage(i18n.language)}`)}
|
||||
</button>
|
||||
<LanguageDropdown variant="secondary-button" align="right" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -172,7 +166,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
||||
payloadId: v.payloadId,
|
||||
data: v.data as VesselData
|
||||
}))}
|
||||
preloadedSelection={logbookVesselSelection as LogbookVesselSelectionData}
|
||||
preloadedSelection={logbookVesselSelection as unknown as LogbookVesselSelectionData}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -10,18 +10,23 @@ interface EventTimeInput24hProps {
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
'aria-label'?: string
|
||||
fallback?: string
|
||||
}
|
||||
|
||||
export default function EventTimeInput24h({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
'aria-label': ariaLabel
|
||||
'aria-label': ariaLabel,
|
||||
fallback
|
||||
}: EventTimeInput24hProps) {
|
||||
const baseId = useId()
|
||||
const useNativePicker = preferNativeCameraPicker()
|
||||
const { hours, minutes } = useMemo(() => splitTimeHHMM(value), [value])
|
||||
const timeValue = useMemo(() => joinTimeHHMM(hours, minutes), [hours, minutes])
|
||||
const { hours, minutes } = useMemo(() => splitTimeHHMM(value, fallback), [value, fallback])
|
||||
const timeValue = useMemo(() => {
|
||||
if (!value.trim()) return ''
|
||||
return joinTimeHHMM(hours, minutes)
|
||||
}, [value, hours, minutes])
|
||||
|
||||
if (useNativePicker) {
|
||||
return (
|
||||
@@ -34,7 +39,7 @@ export default function EventTimeInput24h({
|
||||
value={timeValue}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value
|
||||
if (next) onChange(next.slice(0, 5))
|
||||
onChange(next ? next.slice(0, 5) : '')
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
||||
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight, KeyRound } from 'lucide-react'
|
||||
import LanguageDropdown from './LanguageDropdown.tsx'
|
||||
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, ArrowRight, KeyRound } from 'lucide-react'
|
||||
import {
|
||||
getActiveMasterKey,
|
||||
registerUser,
|
||||
@@ -50,7 +50,7 @@ const hexToBuffer = (hex: string): ArrayBuffer => {
|
||||
}
|
||||
|
||||
export default function InvitationAcceptance({ onAccepted, onCancel }: InvitationAcceptanceProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [accepting, setAccepting] = useState(false)
|
||||
@@ -308,9 +308,6 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
setIsLoggedIn(true)
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
if (recoveryPhrase) {
|
||||
return (
|
||||
@@ -510,10 +507,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
)}
|
||||
|
||||
<div className="auth-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '16px', marginTop: '24px' }}>
|
||||
<button className="btn-icon-text" onClick={toggleLanguage}>
|
||||
<Languages size={18} />
|
||||
{t(`languages.${getNextLanguage(i18n.language)}`)}
|
||||
</button>
|
||||
<LanguageDropdown variant="text" align="left" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Languages, Globe, ChevronDown } from 'lucide-react'
|
||||
import {
|
||||
SUPPORTED_LANGUAGES,
|
||||
changeAppLanguage,
|
||||
normalizeAppLanguage,
|
||||
type AppLanguage
|
||||
} from '../utils/i18nLanguages.js'
|
||||
|
||||
function FlagIcon({ lang, className, style }: { lang: string; className?: string; style?: React.CSSProperties }) {
|
||||
const baseStyle = {
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'middle',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255, 255, 255, 0.15)',
|
||||
boxSizing: 'border-box' as const,
|
||||
...style
|
||||
}
|
||||
|
||||
switch (lang) {
|
||||
case 'de':
|
||||
return (
|
||||
<svg viewBox="0 0 5 3" className={className} style={baseStyle}>
|
||||
<rect width="5" height="3" fill="#FFCE00"/>
|
||||
<rect width="5" height="2" fill="#DD0000"/>
|
||||
<rect width="5" height="1" fill="#000000"/>
|
||||
</svg>
|
||||
)
|
||||
case 'en':
|
||||
return (
|
||||
<svg viewBox="0 0 60 30" className={className} style={baseStyle}>
|
||||
<clipPath id="union-jack-clip">
|
||||
<path d="M0,0 L60,30 M60,0 L0,30"/>
|
||||
</clipPath>
|
||||
<rect width="60" height="30" fill="#012169"/>
|
||||
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#fff" strokeWidth="6"/>
|
||||
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#C8102E" strokeWidth="4" clipPath="url(#union-jack-clip)"/>
|
||||
<path d="M30,0 v30 M0,15 h60" stroke="#fff" strokeWidth="10"/>
|
||||
<path d="M30,0 v30 M0,15 h60" stroke="#C8102E" strokeWidth="6"/>
|
||||
</svg>
|
||||
)
|
||||
case 'da':
|
||||
return (
|
||||
<svg viewBox="0 0 37 28" className={className} style={baseStyle}>
|
||||
<rect width="37" height="28" fill="#C8102E"/>
|
||||
<path d="M12,0 h4 v28 h-4 z M0,12 h37 v4 h-37 z" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
)
|
||||
case 'sv':
|
||||
return (
|
||||
<svg viewBox="0 0 16 10" className={className} style={baseStyle}>
|
||||
<rect width="16" height="10" fill="#006AA7"/>
|
||||
<path d="M5,0 h2 v10 h-2 z M0,4 h16 v2 h-16 z" fill="#FECC00"/>
|
||||
</svg>
|
||||
)
|
||||
case 'nb':
|
||||
return (
|
||||
<svg viewBox="0 0 22 16" className={className} style={baseStyle}>
|
||||
<rect width="22" height="16" fill="#BA0C2F"/>
|
||||
<path d="M6,0 h4 v16 h-4 z M0,6 h22 v4 h-22 z" fill="#FFFFFF"/>
|
||||
<path d="M7,0 h2 v16 h-2 z M0,7 h22 v2 h-22 z" fill="#00205B"/>
|
||||
</svg>
|
||||
)
|
||||
case 'fr':
|
||||
return (
|
||||
<svg viewBox="0 0 3 2" className={className} style={baseStyle}>
|
||||
<rect width="3" height="2" fill="#FFFFFF"/>
|
||||
<rect width="1" height="2" fill="#002395"/>
|
||||
<rect x="2" width="1" height="2" fill="#ED2939"/>
|
||||
</svg>
|
||||
)
|
||||
case 'es':
|
||||
return (
|
||||
<svg viewBox="0 0 3 2" className={className} style={baseStyle}>
|
||||
<rect width="3" height="2" fill="#C1272D"/>
|
||||
<rect y="0.5" width="3" height="1" fill="#FEE100"/>
|
||||
</svg>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface LanguageDropdownProps {
|
||||
variant?: 'icon' | 'text' | 'secondary-button'
|
||||
align?: 'left' | 'right'
|
||||
}
|
||||
|
||||
export default function LanguageDropdown({
|
||||
variant = 'icon',
|
||||
align = 'right'
|
||||
}: LanguageDropdownProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const activeLang = normalizeAppLanguage(i18n.language)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const closeOnOutsideClick = (event: MouseEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setIsOpen(false)
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', closeOnOutsideClick)
|
||||
document.addEventListener('keydown', closeOnEscape)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', closeOnOutsideClick)
|
||||
document.removeEventListener('keydown', closeOnEscape)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const selectLanguage = (lang: AppLanguage) => {
|
||||
changeAppLanguage(i18n, lang)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
// Trigger button content based on variant
|
||||
const renderTriggerContent = () => {
|
||||
const name = t(`languages.${activeLang}`)
|
||||
|
||||
if (variant === 'icon') {
|
||||
return (
|
||||
<span className="lang-dropdown-trigger-flag" aria-hidden="true">
|
||||
<FlagIcon lang={activeLang} className="lang-flag-svg trigger-icon-only" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === 'secondary-button') {
|
||||
return (
|
||||
<>
|
||||
<Globe size={14} style={{ marginRight: '4px' }} />
|
||||
<FlagIcon lang={activeLang} className="lang-flag-svg" style={{ marginRight: '4px' }} />
|
||||
<span className="lang-trigger-name">{name}</span>
|
||||
<ChevronDown size={12} className="lang-dropdown-chevron" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Default or "text" variant (used in footer)
|
||||
return (
|
||||
<>
|
||||
<Languages size={18} />
|
||||
<FlagIcon lang={activeLang} className="lang-flag-svg" style={{ margin: '0 4px' }} />
|
||||
<span>{name}</span>
|
||||
<ChevronDown size={14} className="lang-dropdown-chevron" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const triggerClass =
|
||||
variant === 'icon'
|
||||
? 'btn-icon'
|
||||
: variant === 'secondary-button'
|
||||
? 'btn secondary compact'
|
||||
: 'btn-icon-text'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`lang-dropdown ${isOpen ? 'is-open' : ''} align-${align}`}
|
||||
ref={rootRef}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={triggerClass}
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
title="Switch Language"
|
||||
style={variant === 'secondary-button' ? { width: 'auto', padding: '6px 12px', fontSize: '13px' } : undefined}
|
||||
>
|
||||
{renderTriggerContent()}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<ul className="lang-dropdown-menu" role="listbox">
|
||||
{SUPPORTED_LANGUAGES.map((lang) => {
|
||||
const isSelected = lang === activeLang
|
||||
return (
|
||||
<li
|
||||
key={lang}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className={`lang-dropdown-option ${isSelected ? 'is-selected' : ''}`}
|
||||
onClick={() => selectLanguage(lang)}
|
||||
>
|
||||
<FlagIcon lang={lang} className="lang-flag-svg" />
|
||||
<span className="lang-option-name">{t(`languages.${lang}`)}</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Radio,
|
||||
Sailboat,
|
||||
Undo2,
|
||||
Waves,
|
||||
Zap
|
||||
} from 'lucide-react'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
appendTankRefill as apiAppendTankRefill,
|
||||
findOrCreateTodayEntry,
|
||||
loadEntry,
|
||||
patchEntryTides,
|
||||
removeLastEvent
|
||||
} from '../services/quickEventLog.js'
|
||||
import CreatorAvatar from './CreatorAvatar.tsx'
|
||||
@@ -56,6 +58,23 @@ const formatSpeedKn = (speedKn: number) =>
|
||||
formatAppDecimal(speedKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.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 {
|
||||
geolocationErrorI18nKey,
|
||||
getCurrentPosition,
|
||||
@@ -108,6 +127,8 @@ type LiveModal =
|
||||
| 'sog'
|
||||
| 'stw'
|
||||
| 'position'
|
||||
| 'tides'
|
||||
| 'tides_picker'
|
||||
| 'photo'
|
||||
| 'voice'
|
||||
|
||||
@@ -190,6 +211,8 @@ export default function LiveLogView({
|
||||
const [entryId, setEntryId] = useState<string | null>(null)
|
||||
const [dayOfTravel, setDayOfTravel] = useState('')
|
||||
const [date, setDate] = useState('')
|
||||
const [departure, setDeparture] = useState('')
|
||||
const [destination, setDestination] = useState('')
|
||||
const [events, setEvents] = useState<LogEventPayload[]>([])
|
||||
const [crewSnapshotsById, setCrewSnapshotsById] = useState<Record<string, any>>({})
|
||||
const [selectedSkipperId, setSelectedSkipperId] = useState<string | null>(null)
|
||||
@@ -200,6 +223,15 @@ export default function LiveLogView({
|
||||
const [modal, setModal] = useState<LiveModal>('none')
|
||||
const [weatherExpanded, setWeatherExpanded] = useState(false)
|
||||
const [weatherOwmLoading, setWeatherOwmLoading] = useState(false)
|
||||
const [tidesLoading, setTidesLoading] = useState(false)
|
||||
const [tidePreview, setTidePreview] = useState<{
|
||||
highWater: string
|
||||
lowWater: string
|
||||
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 [commentText, setCommentText] = useState('')
|
||||
const [valueInput, setValueInput] = useState('')
|
||||
@@ -301,6 +333,8 @@ export default function LiveLogView({
|
||||
const entryEvents = (loaded.data.events as LogEventPayload[]) || []
|
||||
setDayOfTravel(String(loaded.data.dayOfTravel || ''))
|
||||
setDate(String(loaded.data.date || ''))
|
||||
setDeparture(String(loaded.data.departure || ''))
|
||||
setDestination(String(loaded.data.destination || ''))
|
||||
setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e }))))
|
||||
setCrewSnapshotsById((loaded.data.crewSnapshotsById as Record<string, any>) || {})
|
||||
setSelectedSkipperId(typeof loaded.data.selectedSkipperId === 'string' ? loaded.data.selectedSkipperId : null)
|
||||
@@ -784,6 +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 = () => {
|
||||
if (!entryId || busy) return
|
||||
const photoId = undoPhotoIdRef.current
|
||||
@@ -1257,6 +1430,10 @@ export default function LiveLogView({
|
||||
<MapPin size={18} />
|
||||
{t('logs.live_position')}
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn" onClick={handleFetchTides} disabled={busy || tidesLoading}>
|
||||
<Waves size={18} />
|
||||
{tidesLoading ? t('logs.tide_fetch_loading') : t('logs.tides')}
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn" onClick={() => { setCommentText(''); setModal('comment') }} disabled={busy}>
|
||||
<MessageSquare size={18} />
|
||||
{t('logs.live_comment_btn')}
|
||||
@@ -1455,6 +1632,70 @@ export default function LiveLogView({
|
||||
</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' && (
|
||||
<div className="live-log-modal-backdrop" onClick={() => setModal('none')}>
|
||||
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { db } from '../services/db.js'
|
||||
import { getActiveMasterKey } from '../services/auth.js'
|
||||
import { getActiveMasterKey, hasUnlockedLocalCrypto } from '../services/auth.js'
|
||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||
import { encryptJson } from '../services/crypto.js'
|
||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||
import { buildZipArchive } from '../services/logbookBackup/zipArchive.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { getErrorMessage } from '../utils/errors.js'
|
||||
import { findTodayEntryId, pruneEmptyTodayDuplicates, tryDecryptEntryPayload } from '../services/quickEventLog.js'
|
||||
@@ -59,6 +60,42 @@ interface DecryptedEntryItem {
|
||||
skipperSignStatus: SkipperSignStatus
|
||||
}
|
||||
|
||||
// Helper to convert data URL to Uint8Array for zip packaging
|
||||
function dataUrlToUint8Array(dataUrl: string): { data: Uint8Array; ext: string } {
|
||||
const parts = dataUrl.split(',')
|
||||
if (parts.length < 2) {
|
||||
throw new Error('Invalid data URL')
|
||||
}
|
||||
const meta = parts[0]
|
||||
const base64Data = parts[1]
|
||||
|
||||
let ext = 'jpg'
|
||||
const mimeMatch = meta.match(/data:([^;]+)/)
|
||||
if (mimeMatch) {
|
||||
const mime = mimeMatch[1]
|
||||
if (mime === 'image/png') ext = 'png'
|
||||
else if (mime === 'image/gif') ext = 'gif'
|
||||
else if (mime === 'image/webp') ext = 'webp'
|
||||
else if (mime === 'image/heic') ext = 'heic'
|
||||
else if (mime === 'image/heif') ext = 'heif'
|
||||
}
|
||||
|
||||
const binaryString = atob(base64Data)
|
||||
const bytes = new Uint8Array(binaryString.length)
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
return { data: bytes, ext }
|
||||
}
|
||||
|
||||
function sanitizeFilename(str: string): string {
|
||||
return str
|
||||
.replace(/[^\w\s-]/gi, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '_')
|
||||
.slice(0, 30)
|
||||
}
|
||||
|
||||
export default function LogEntriesList({
|
||||
logbookId,
|
||||
readOnly = false,
|
||||
@@ -257,6 +294,90 @@ export default function LogEntriesList({
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadPhotosZip = async () => {
|
||||
setExporting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
// Fetch all photos for this logbook from IndexedDB
|
||||
const localPhotos = await db.photos.where({ logbookId }).toArray()
|
||||
if (localPhotos.length === 0) {
|
||||
setError(t('logs.no_photos_to_download'))
|
||||
return
|
||||
}
|
||||
|
||||
// Build a map of entry ID to entry info for filename lookup
|
||||
const entryMap = new Map<string, DecryptedEntryItem>()
|
||||
entries.forEach((e) => entryMap.set(e.id, e))
|
||||
|
||||
const files: Record<string, Uint8Array> = {}
|
||||
const usedNames = new Set<string>()
|
||||
|
||||
for (const photo of localPhotos) {
|
||||
// Decrypt photo payload (contains base64 image data and caption)
|
||||
const decrypted = await decryptJson(photo.encryptedData, photo.iv, photo.tag, masterKey)
|
||||
if (!decrypted || !decrypted.image) continue
|
||||
|
||||
const { data, ext } = dataUrlToUint8Array(decrypted.image)
|
||||
|
||||
// Construct unique, friendly filename
|
||||
let fileBase = `photo_${photo.payloadId}`
|
||||
const entry = entryMap.get(photo.entryId)
|
||||
if (entry) {
|
||||
const dateStr = entry.date || 'unknown-date'
|
||||
const travelDay = entry.dayOfTravel ? `day-${entry.dayOfTravel}` : ''
|
||||
const sanitizedCaption = decrypted.caption ? sanitizeFilename(decrypted.caption) : ''
|
||||
|
||||
const parts = [dateStr]
|
||||
if (travelDay) parts.push(travelDay)
|
||||
if (sanitizedCaption) parts.push(sanitizedCaption)
|
||||
|
||||
fileBase = parts.join('_')
|
||||
} else if (decrypted.caption) {
|
||||
fileBase = `photo_${sanitizeFilename(decrypted.caption)}`
|
||||
}
|
||||
|
||||
// De-duplicate name
|
||||
let candidate = `${fileBase}.${ext}`
|
||||
let counter = 1
|
||||
while (usedNames.has(candidate.toLowerCase())) {
|
||||
candidate = `${fileBase}_${counter}.${ext}`
|
||||
counter++
|
||||
}
|
||||
usedNames.add(candidate.toLowerCase())
|
||||
|
||||
files[candidate] = data
|
||||
}
|
||||
|
||||
if (Object.keys(files).length === 0) {
|
||||
setError(t('logs.no_photos_to_download'))
|
||||
return
|
||||
}
|
||||
|
||||
const zipBytes = buildZipArchive(files)
|
||||
const blob = new Blob([zipBytes as any], { type: 'application/zip' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
const yachtName = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook'
|
||||
const safeTitle = yachtName.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook'
|
||||
const datePart = new Date().toISOString().slice(0, 10)
|
||||
const filename = `${safeTitle}-photos-${datePart}.zip`
|
||||
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to download photos ZIP:', err)
|
||||
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (readOnly) return
|
||||
setError(null)
|
||||
@@ -488,6 +609,21 @@ export default function LogEntriesList({
|
||||
<span className="hide-mobile">{t('logs.share_csv')}</span>
|
||||
</button>
|
||||
|
||||
{hasUnlockedLocalCrypto() && (
|
||||
<button
|
||||
className="btn secondary"
|
||||
onClick={handleDownloadPhotosZip}
|
||||
disabled={loading || exporting || entries.length === 0}
|
||||
style={{ width: 'auto', padding: '8px 16px' }}
|
||||
title={t('logs.export_photos_zip')}
|
||||
>
|
||||
<Download size={16} />
|
||||
<span className="hide-mobile">
|
||||
{exporting ? t('logs.exporting_photos_zip') : t('logs.export_photos_zip')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.new_entry')}>
|
||||
<Plus size={16} />
|
||||
@@ -541,17 +677,17 @@ export default function LogEntriesList({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChevronRight size={18} className="logbook-card-chevron" aria-hidden />
|
||||
|
||||
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
|
||||
<Download size={18} />
|
||||
</button>
|
||||
|
||||
{!readOnly && (
|
||||
<button className="btn-delete" onClick={(e) => handleDelete(item.id, e)} title={t('logs.delete_entry')}>
|
||||
<Trash2 size={18} />
|
||||
<div className="logbook-card-right-group">
|
||||
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
|
||||
<Download size={18} />
|
||||
</button>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<button className="btn-delete" onClick={(e) => handleDelete(item.id, e)} title={t('logs.delete_entry')}>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
)}
|
||||
<ChevronRight size={18} className="logbook-card-chevron" aria-hidden />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -8,12 +8,12 @@ import { syncLogbook } from '../services/sync.js'
|
||||
import { saveEntryDraft, clearEntryDraft } from '../services/entryDraft.js'
|
||||
import { getErrorMessage } from '../utils/errors.js'
|
||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp, Sparkles, Sliders } from 'lucide-react'
|
||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp, Sparkles, Sliders, Waves } from 'lucide-react'
|
||||
import PhotoCapture from './PhotoCapture.tsx'
|
||||
import EventRemarksCell from './EventRemarksCell.tsx'
|
||||
import CreatorAvatar from './CreatorAvatar.tsx'
|
||||
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 type { PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx'
|
||||
import SignatureSection from './SignatureSection.tsx'
|
||||
@@ -33,7 +33,18 @@ import {
|
||||
hasAnySignature
|
||||
} from '../utils/signatures.js'
|
||||
import type { SignatureValue } from '../types/signatures.js'
|
||||
import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, hasUnsavedEventDraft, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||
import {
|
||||
buildLogEntryPayload,
|
||||
readLogEntryTidesMap,
|
||||
sortLogEventsByTime,
|
||||
normalizeLogEvent,
|
||||
hasUnsavedEventDraft,
|
||||
currentLocalTimeHHMM,
|
||||
isValidTimeHHMM,
|
||||
type LogEventPayload,
|
||||
type LogEntryTidesMap,
|
||||
type TideRole
|
||||
} from '../utils/logEntryPayload.js'
|
||||
import EventTimeInput24h from './EventTimeInput24h.tsx'
|
||||
import CourseDialInput from './CourseDialInput.tsx'
|
||||
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
||||
@@ -43,6 +54,22 @@ import { putEntryRecord } from '../utils/entryListCache.js'
|
||||
import { getLogbookAccess } from '../services/logbookAccess.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
||||
import { 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 {
|
||||
buildTravelDayContext,
|
||||
fetchTravelDaySummaryUsage,
|
||||
@@ -164,6 +191,7 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
|
||||
motorHoursRaw != null && motorHoursRaw !== ''
|
||||
? (parseAppDecimal(String(motorHoursRaw)) ?? undefined)
|
||||
: undefined,
|
||||
tides: readLogEntryTidesMap(decrypted),
|
||||
events: (decrypted.events as LogEventPayload[]) || [],
|
||||
entryCrew: entryCrewFromPreviousEntry(decrypted as Record<string, unknown>)
|
||||
})
|
||||
@@ -298,6 +326,11 @@ export default function LogEntryEditor({
|
||||
|
||||
const [eventsCollapsed, setEventsCollapsed] = useState(true)
|
||||
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 [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({
|
||||
date,
|
||||
dayOfTravel,
|
||||
@@ -430,6 +463,7 @@ export default function LogEntryEditor({
|
||||
consumption: parseAppDecimalOrZero(fuelConsumption)
|
||||
},
|
||||
greywater: { level: parseAppDecimalOrZero(greywaterLevel) },
|
||||
tides: tidesOverride ?? tidesMap,
|
||||
trackDistanceNm: parseOptionalFormDecimal(trackDistanceNm),
|
||||
trackSpeedMaxKn: parseOptionalFormDecimal(trackSpeedMaxKn),
|
||||
trackSpeedAvgKn: parseOptionalFormDecimal(trackSpeedAvgKn),
|
||||
@@ -442,6 +476,7 @@ export default function LogEntryEditor({
|
||||
fwMorning, fwRefilled, fwEvening, fwConsumption,
|
||||
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
|
||||
greywaterLevel,
|
||||
tidesMap,
|
||||
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
|
||||
events,
|
||||
entryCrew
|
||||
@@ -492,6 +527,15 @@ export default function LogEntryEditor({
|
||||
[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 payload = buildPayloadForSigning()
|
||||
return JSON.stringify({
|
||||
@@ -575,12 +619,14 @@ export default function LogEntryEditor({
|
||||
signCrew?: SignatureValue | ''
|
||||
aiSummary?: string
|
||||
aiSummaryGeneratedAt?: string
|
||||
tidesOverride?: LogEntryTidesMap
|
||||
}
|
||||
) => {
|
||||
if (readOnly) return
|
||||
|
||||
const normalized = Array.isArray(options) ? { eventsOverride: options } : (options ?? {})
|
||||
const eventsOverride = normalized.eventsOverride
|
||||
const tidesOverride = normalized.tidesOverride
|
||||
const skipperToSave = normalized.signSkipper !== undefined ? normalized.signSkipper : signSkipper
|
||||
const crewToSave = normalized.signCrew !== undefined ? normalized.signCrew : signCrew
|
||||
let summaryToSave = normalized.aiSummary !== undefined ? normalized.aiSummary : aiSummary
|
||||
@@ -608,7 +654,7 @@ export default function LogEntryEditor({
|
||||
}
|
||||
|
||||
const entryData: Record<string, unknown> = {
|
||||
...buildPayloadForSigning(eventsOverride),
|
||||
...buildPayloadForSigning(eventsOverride, tidesOverride),
|
||||
signSkipper: normalizedSerializedSignature(skipperToSave),
|
||||
signCrew: normalizedSerializedSignature(crewToSave)
|
||||
}
|
||||
@@ -921,6 +967,9 @@ export default function LogEntryEditor({
|
||||
setGreywaterLevel('0')
|
||||
}
|
||||
|
||||
const preloadedTides = readLogEntryTidesMap(preloadedEntry as Record<string, unknown>)
|
||||
setTidesMap(preloadedTides)
|
||||
|
||||
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
|
||||
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
||||
setEntryCrew(entryCrewFromPreviousEntry(preloadedEntry as Record<string, unknown>))
|
||||
@@ -962,6 +1011,9 @@ export default function LogEntryEditor({
|
||||
setGreywaterLevel('0')
|
||||
}
|
||||
|
||||
const loadedTides = readLogEntryTidesMap(decrypted as Record<string, unknown>)
|
||||
setTidesMap(loadedTides)
|
||||
|
||||
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
|
||||
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
||||
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 () => {
|
||||
if (!canSignSkipper || readOnly || aiSummaryLoading) return
|
||||
if (!getAiAuthorized()) {
|
||||
@@ -2113,6 +2290,122 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tides */}
|
||||
<div className="form-card">
|
||||
<div
|
||||
className="form-header mb-4 accordion-header"
|
||||
onClick={() => setTidesCollapsed(!tidesCollapsed)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setTidesCollapsed(!tidesCollapsed)
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
aria-expanded={!tidesCollapsed}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="accordion-header-title">
|
||||
<Waves size={20} className="form-icon" />
|
||||
<h3>{t('logs.tides')}</h3>
|
||||
</div>
|
||||
{tidesCollapsed ? <ChevronDown size={20} /> : <ChevronUp size={20} />}
|
||||
</div>
|
||||
|
||||
{!tidesCollapsed && (
|
||||
<div className="tides-panel">
|
||||
<div className="tides-panel__hints" 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) */}
|
||||
<div className="form-card">
|
||||
<div
|
||||
@@ -2930,6 +3223,33 @@ export default function LogEntryEditor({
|
||||
nmeaArchive={nmeaArchive}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Archive, Download, Upload, Check, AlertTriangle } from 'lucide-react'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { Archive, Download, Check, AlertTriangle } from 'lucide-react'
|
||||
import {
|
||||
downloadBackupBlob,
|
||||
exportLogbookBackup,
|
||||
formatBackupBytes,
|
||||
parseLogbookBackupFile,
|
||||
previewLogbookBackup,
|
||||
restoreLogbookBackup,
|
||||
BACKUP_SIZE_CONFIRM_BYTES,
|
||||
type ParsedLogbookBackup,
|
||||
type LogbookBackupPreview
|
||||
exportLogbookBackup
|
||||
} from '../services/logbookBackup.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
|
||||
|
||||
interface LogbookBackupPanelProps {
|
||||
logbookId: string
|
||||
onRestored?: (logbookId: string, title: string) => void
|
||||
}
|
||||
|
||||
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) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { showConfirm } = useDialog()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
export default function LogbookBackupPanel({ logbookId }: LogbookBackupPanelProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [exportPassphrase, setExportPassphrase] = useState('')
|
||||
const [exportConfirm, setExportConfirm] = useState('')
|
||||
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 [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
@@ -76,11 +57,6 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
||||
await handleExport()
|
||||
}
|
||||
|
||||
const handleImportSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
await handleRestore()
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
setError(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 (
|
||||
<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' }}>
|
||||
@@ -306,93 +183,6 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
||||
)}
|
||||
</form>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage } from '../utils/i18nLanguages.js'
|
||||
import LanguageDropdown from './LanguageDropdown.tsx'
|
||||
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
|
||||
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
|
||||
import { loadLogbookSearchFieldsBatch } from '../services/logbookSearchIndex.js'
|
||||
@@ -11,11 +11,12 @@ import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { getErrorMessage } from '../utils/errors.js'
|
||||
import { logoutUser } from '../services/auth.js'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { BookOpen, Plus, Trash2, LogOut, Languages, 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 FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
||||
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
|
||||
import AdminHeaderButton from './AdminHeaderButton.tsx'
|
||||
import LogbookRestorePanel from './LogbookRestorePanel.tsx'
|
||||
|
||||
interface LogbookDashboardProps {
|
||||
onSelectLogbook: (id: string, title: string) => void
|
||||
@@ -35,10 +36,14 @@ function sortLogbooks(
|
||||
): DecryptedLogbook[] {
|
||||
const sorted = [...items]
|
||||
sorted.sort((a, b) => {
|
||||
const cmp =
|
||||
sortBy === 'name'
|
||||
? a.title.localeCompare(b.title, locale, { sensitivity: 'base' })
|
||||
: new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
|
||||
let cmp = 0
|
||||
if (sortBy === 'name') {
|
||||
cmp = a.title.localeCompare(b.title, locale, { sensitivity: 'base' })
|
||||
} else {
|
||||
const timeA = a.lastTravelDate ? new Date(a.lastTravelDate).getTime() : new Date(a.updatedAt).getTime()
|
||||
const timeB = b.lastTravelDate ? new Date(b.lastTravelDate).getTime() : new Date(b.updatedAt).getTime()
|
||||
cmp = timeA - timeB
|
||||
}
|
||||
return direction === 'asc' ? cmp : -cmp
|
||||
})
|
||||
return sorted
|
||||
@@ -63,6 +68,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
|
||||
const filterInputRef = useRef<HTMLInputElement>(null)
|
||||
const [online, setOnline] = useState(navigator.onLine)
|
||||
const [showRestore, setShowRestore] = useState(false)
|
||||
|
||||
const { pendingCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator()
|
||||
|
||||
@@ -198,9 +204,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
onLogout()
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
|
||||
const sharedLogbooks = logbooks.filter((lb) => lb.isShared)
|
||||
@@ -291,8 +294,12 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
{lb.isDemo && (
|
||||
<span className="demo-badge">{t('demo.badge')}</span>
|
||||
)}
|
||||
<span className="entry-count-badge" title={t('dashboard.travel_days_count', { count: lb.entryCount ?? 0 })}>
|
||||
<CalendarDays size={12} style={{ marginRight: '4px' }} />
|
||||
{lb.entryCount ?? 0}
|
||||
</span>
|
||||
<span className="date-badge">
|
||||
{new Date(lb.updatedAt).toLocaleDateString(i18n.language, {
|
||||
{new Date(lb.lastTravelDate || lb.updatedAt).toLocaleDateString(i18n.language, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
@@ -392,10 +399,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
|
||||
{onOpenAdmin && <AdminHeaderButton onClick={onOpenAdmin} />}
|
||||
|
||||
{/* Lang toggle */}
|
||||
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
||||
<Languages size={18} />
|
||||
</button>
|
||||
<LanguageDropdown variant="icon" align="right" />
|
||||
|
||||
<DisclaimerHeaderButton />
|
||||
|
||||
@@ -432,6 +436,24 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
</form>
|
||||
|
||||
{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>
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.j
|
||||
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { Camera, Image, Trash2, X } from 'lucide-react'
|
||||
import { Camera, Image, Trash2, X, ChevronDown, ChevronUp, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { probeCameraAvailability } from '../utils/cameraAvailability.js'
|
||||
|
||||
interface PhotoCaptureProps {
|
||||
@@ -29,6 +29,7 @@ interface DecryptedPhoto {
|
||||
export default function PhotoCapture({ entryId, logbookId, readOnly = false, preloadedPhotos }: PhotoCaptureProps) {
|
||||
const { t } = useTranslation()
|
||||
const { showConfirm } = useDialog()
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
const [caption, setCaption] = useState('')
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -38,6 +39,46 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null)
|
||||
const touchStartX = useRef<number>(0)
|
||||
const touchEndX = useRef<number>(0)
|
||||
|
||||
const goToNext = () => {
|
||||
if (!maximizedPhoto || decryptedPhotos.length <= 1) return
|
||||
const currentIndex = decryptedPhotos.findIndex(p => p.payloadId === maximizedPhoto.payloadId)
|
||||
if (currentIndex === -1) return
|
||||
const nextIndex = (currentIndex + 1) % decryptedPhotos.length
|
||||
setMaximizedPhoto(decryptedPhotos[nextIndex])
|
||||
}
|
||||
|
||||
const goToPrev = () => {
|
||||
if (!maximizedPhoto || decryptedPhotos.length <= 1) return
|
||||
const currentIndex = decryptedPhotos.findIndex(p => p.payloadId === maximizedPhoto.payloadId)
|
||||
if (currentIndex === -1) return
|
||||
const prevIndex = (currentIndex - 1 + decryptedPhotos.length) % decryptedPhotos.length
|
||||
setMaximizedPhoto(decryptedPhotos[prevIndex])
|
||||
}
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
touchStartX.current = e.targetTouches[0].clientX
|
||||
touchEndX.current = e.targetTouches[0].clientX
|
||||
}
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
touchEndX.current = e.targetTouches[0].clientX
|
||||
}
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (!touchStartX.current || !touchEndX.current) return
|
||||
const diffX = touchStartX.current - touchEndX.current
|
||||
const threshold = 50
|
||||
if (diffX > threshold) {
|
||||
goToNext()
|
||||
} else if (diffX < -threshold) {
|
||||
goToPrev()
|
||||
}
|
||||
touchStartX.current = 0
|
||||
touchEndX.current = 0
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!maximizedPhoto) return
|
||||
@@ -45,6 +86,10 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setMaximizedPhoto(null)
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'Left') {
|
||||
goToPrev()
|
||||
} else if (e.key === 'ArrowRight' || e.key === 'Right') {
|
||||
goToNext()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +97,7 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [maximizedPhoto])
|
||||
}, [maximizedPhoto, decryptedPhotos])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -165,133 +210,156 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
||||
|
||||
return (
|
||||
<div className="form-card mt-6">
|
||||
<div className="form-header mb-4">
|
||||
<Camera size={20} className="form-icon" />
|
||||
<h3>{t('logs.photos_title')}</h3>
|
||||
<div
|
||||
className="form-header accordion-header"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setCollapsed(!collapsed)
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
aria-expanded={!collapsed}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="accordion-header-title">
|
||||
<Camera size={20} className="form-icon" />
|
||||
<h3>{t('logs.photos_title')}</h3>
|
||||
</div>
|
||||
{collapsed ? (
|
||||
<ChevronDown size={20} className="accordion-chevron" />
|
||||
) : (
|
||||
<ChevronUp size={20} className="accordion-chevron" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="auth-error mb-4">{error}</div>}
|
||||
{!collapsed && (
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
{error && <div className="auth-error mb-4">{error}</div>}
|
||||
|
||||
{/* Upload area */}
|
||||
{/* Upload Form */}
|
||||
{!readOnly && (
|
||||
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}>
|
||||
<label>{t('logs.photo_caption_label')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('logs.photo_caption_placeholder')}
|
||||
className="input-text"
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
{/* Upload area */}
|
||||
{/* Upload Form */}
|
||||
{!readOnly && (
|
||||
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}>
|
||||
<label>{t('logs.photo_caption_label')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('logs.photo_caption_placeholder')}
|
||||
className="input-text"
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
ref={cameraInputRef}
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
ref={cameraInputRef}
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
{hasCamera ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={triggerCameraSelect}
|
||||
disabled={uploading}
|
||||
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||
>
|
||||
{uploading ? (
|
||||
<span className="spin">⏳</span>
|
||||
) : (
|
||||
<Camera size={16} />
|
||||
)}
|
||||
{uploading ? t('logs.photo_processing') : t('logs.photo_camera_btn')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={triggerGallerySelect}
|
||||
disabled={uploading}
|
||||
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||
>
|
||||
{uploading ? (
|
||||
<span className="spin">⏳</span>
|
||||
) : (
|
||||
<Image size={16} />
|
||||
)}
|
||||
{uploading ? t('logs.photo_processing') : t('logs.photo_gallery_btn')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={triggerGallerySelect}
|
||||
disabled={uploading}
|
||||
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||
>
|
||||
{uploading ? (
|
||||
<span className="spin">⏳</span>
|
||||
{hasCamera ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={triggerCameraSelect}
|
||||
disabled={uploading}
|
||||
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||
>
|
||||
{uploading ? (
|
||||
<span className="spin">⏳</span>
|
||||
) : (
|
||||
<Camera size={16} />
|
||||
)}
|
||||
{uploading ? t('logs.photo_processing') : t('logs.photo_camera_btn')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={triggerGallerySelect}
|
||||
disabled={uploading}
|
||||
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||
>
|
||||
{uploading ? (
|
||||
<span className="spin">⏳</span>
|
||||
) : (
|
||||
<Image size={16} />
|
||||
)}
|
||||
{uploading ? t('logs.photo_processing') : t('logs.photo_gallery_btn')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<Camera size={16} />
|
||||
)}
|
||||
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Photo Grid */}
|
||||
{decryptedPhotos.length === 0 ? (
|
||||
<div className="dashboard-status-msg">{t('logs.no_photos')}</div>
|
||||
) : (
|
||||
<div className="photo-attachments-grid">
|
||||
{decryptedPhotos.map((photo) => (
|
||||
<div
|
||||
key={photo.payloadId}
|
||||
className="photo-card glass"
|
||||
onClick={() => setMaximizedPhoto(photo)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div className="photo-container">
|
||||
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
className="photo-btn-delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete(photo.payloadId)
|
||||
}}
|
||||
title="Remove photo"
|
||||
className="btn primary"
|
||||
onClick={triggerGallerySelect}
|
||||
disabled={uploading}
|
||||
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{uploading ? (
|
||||
<span className="spin">⏳</span>
|
||||
) : (
|
||||
<Camera size={16} />
|
||||
)}
|
||||
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{photo.caption && (
|
||||
<div className="photo-caption-bar">
|
||||
<span>{photo.caption}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* Photo Grid */}
|
||||
{decryptedPhotos.length === 0 ? (
|
||||
<div className="dashboard-status-msg">{t('logs.no_photos')}</div>
|
||||
) : (
|
||||
<div className="photo-attachments-grid">
|
||||
{decryptedPhotos.map((photo) => (
|
||||
<div
|
||||
key={photo.payloadId}
|
||||
className="photo-card glass"
|
||||
onClick={() => setMaximizedPhoto(photo)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div className="photo-container">
|
||||
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
className="photo-btn-delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete(photo.payloadId)
|
||||
}}
|
||||
title="Remove photo"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{photo.caption && (
|
||||
<div className="photo-caption-bar">
|
||||
<span>{photo.caption}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -299,7 +367,37 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
||||
<div
|
||||
className="photo-maximized-overlay"
|
||||
onClick={() => setMaximizedPhoto(null)}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{decryptedPhotos.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="photo-maximized-nav photo-maximized-prev"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
goToPrev()
|
||||
}}
|
||||
aria-label={t('common.previous') || 'Previous'}
|
||||
>
|
||||
<ChevronLeft size={32} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="photo-maximized-nav photo-maximized-next"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
goToNext()
|
||||
}}
|
||||
aria-label={t('common.next') || 'Next'}
|
||||
>
|
||||
<ChevronRight size={32} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="photo-maximized-container" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18nLanguages.js'
|
||||
import { isGermanLocale } from '../utils/i18nLanguages.js'
|
||||
import LanguageDropdown from './LanguageDropdown.tsx'
|
||||
import { decryptJson } from '../services/crypto.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
|
||||
@@ -12,7 +13,7 @@ import { emptyLogbookCrewSelection } from '../types/person.js'
|
||||
import { legacyCrewRecordsToLogbookSelection } from '../utils/personSnapshots.js'
|
||||
import type { PersonData } from '../types/person.js'
|
||||
import LogEntriesList from './LogEntriesList.tsx'
|
||||
import { Ship, Users, FileText, Lock, AlertCircle, Globe } from 'lucide-react'
|
||||
import { Ship, Users, FileText, Lock, AlertCircle } from 'lucide-react'
|
||||
|
||||
interface ReadOnlyViewerProps {
|
||||
token: string
|
||||
@@ -215,9 +216,6 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -258,10 +256,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
</div>
|
||||
|
||||
<div className="header-actions">
|
||||
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}>
|
||||
<Globe size={14} style={{ marginRight: '4px' }} />
|
||||
{t(`languages.${getNextLanguage(i18n.language)}`)}
|
||||
</button>
|
||||
<LanguageDropdown variant="secondary-button" align="right" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Settings as SettingsIcon, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
|
||||
import { Settings as SettingsIcon, Check, Users, Trash2, Copy, Link as LinkIcon, Share2 } from 'lucide-react'
|
||||
import { ensureLogbookKey } from '../services/logbookKeys.js'
|
||||
import LogbookBackupPanel from './LogbookBackupPanel.tsx'
|
||||
import LinkQrCode from './LinkQrCode.tsx'
|
||||
@@ -17,7 +17,6 @@ import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
||||
|
||||
interface SettingsFormProps {
|
||||
logbookId?: string | null
|
||||
onLogbookRestored?: (logbookId: string, title: string) => void
|
||||
}
|
||||
|
||||
interface Collaborator {
|
||||
@@ -34,7 +33,7 @@ const bufferToHex = (buffer: ArrayBuffer): string => {
|
||||
.join('')
|
||||
}
|
||||
|
||||
export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsFormProps) {
|
||||
export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const { showConfirm, showAlert } = useDialog()
|
||||
|
||||
@@ -131,6 +130,24 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
}
|
||||
}
|
||||
|
||||
const isShareSupported = typeof navigator !== 'undefined' && !!navigator.share
|
||||
|
||||
const handleShareLink = async () => {
|
||||
if (shareLink) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: t('seo.title') || 'Kapteins Daagbok',
|
||||
text: t('settings.share_desc'),
|
||||
url: shareLink
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name !== 'AbortError') {
|
||||
console.error('Sharing link failed:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadCollaborators = async () => {
|
||||
setLoadingCollabs(true)
|
||||
setCollabError(null)
|
||||
@@ -337,6 +354,17 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
>
|
||||
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
{isShareSupported && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => void handleShareLink()}
|
||||
style={{ width: 'auto', padding: '10px' }}
|
||||
title={t('settings.share_btn')}
|
||||
>
|
||||
<Share2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<LinkQrCode value={shareLink} />
|
||||
</div>
|
||||
@@ -345,7 +373,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
)}
|
||||
|
||||
{logbookId && isOwner && (
|
||||
<LogbookBackupPanel logbookId={logbookId} onRestored={onLogbookRestored} />
|
||||
<LogbookBackupPanel logbookId={logbookId} />
|
||||
)}
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import deJson from './locales/de.json'
|
||||
import daJson from './locales/da.json'
|
||||
import svJson from './locales/sv.json'
|
||||
import nbJson from './locales/nb.json'
|
||||
import frJson from './locales/fr.json'
|
||||
import esJson from './locales/es.json'
|
||||
import { initSeo } from '../utils/seo.js'
|
||||
import { SUPPORTED_LANGUAGES } from '../utils/i18nLanguages.js'
|
||||
|
||||
@@ -15,7 +17,9 @@ const resources = {
|
||||
de: { translation: deJson.translation },
|
||||
da: { translation: daJson.translation },
|
||||
sv: { translation: svJson.translation },
|
||||
nb: { translation: nbJson.translation }
|
||||
nb: { translation: nbJson.translation },
|
||||
fr: { translation: frJson.translation },
|
||||
es: { translation: esJson.translation }
|
||||
}
|
||||
|
||||
i18n
|
||||
|
||||
@@ -4,6 +4,8 @@ import enJson from '../i18n/locales/en.json'
|
||||
import daJson from '../i18n/locales/da.json'
|
||||
import svJson from '../i18n/locales/sv.json'
|
||||
import nbJson from '../i18n/locales/nb.json'
|
||||
import frJson from '../i18n/locales/fr.json'
|
||||
import esJson from '../i18n/locales/es.json'
|
||||
|
||||
function collectKeys(obj: Record<string, unknown>, prefix = ''): string[] {
|
||||
const keys: string[] = []
|
||||
@@ -23,7 +25,9 @@ const bundles = {
|
||||
en: enJson.translation,
|
||||
da: daJson.translation,
|
||||
sv: svJson.translation,
|
||||
nb: nbJson.translation
|
||||
nb: nbJson.translation,
|
||||
fr: frJson.translation,
|
||||
es: esJson.translation
|
||||
} as const
|
||||
|
||||
describe('i18n locale key parity', () => {
|
||||
|
||||
+680
-640
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,9 @@
|
||||
"en": "English",
|
||||
"da": "Dansk",
|
||||
"sv": "Svenska",
|
||||
"nb": "Norsk"
|
||||
"nb": "Norsk",
|
||||
"fr": "Français",
|
||||
"es": "Español"
|
||||
},
|
||||
"dialog": {
|
||||
"ok": "OK",
|
||||
@@ -34,7 +36,9 @@
|
||||
"unsaved_changes_stay": "Bleiben",
|
||||
"unsaved_changes_save_leave": "Speichern & verlassen",
|
||||
"unsaved_changes_discard": "Verwerfen",
|
||||
"unsaved_changes_leave": "Verlassen"
|
||||
"unsaved_changes_leave": "Verlassen",
|
||||
"previous": "Zurück",
|
||||
"next": "Weiter"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -186,6 +190,35 @@
|
||||
"departure": "Start-Hafen (Reise von)",
|
||||
"destination": "Ziel-Hafen (nach)",
|
||||
"route": "Reise von/nach",
|
||||
"tides": "Tiden",
|
||||
"tide_high_water": "Hochwasser",
|
||||
"tide_low_water": "Niedrigwasser",
|
||||
"tide_fetch_btn": "Gezeiten abrufen",
|
||||
"tide_fetch_loading": "Gezeiten werden geladen…",
|
||||
"tide_disclaimer": "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",
|
||||
"customize_columns": "Spalten anpassen",
|
||||
"column_selector_title": "Anzuzeigende Spalten",
|
||||
@@ -443,6 +476,9 @@
|
||||
"ai_summary_error_forbidden": "Nur der Skipper darf KI-Zusammenfassungen generieren.",
|
||||
"ai_summary_offline": "Die KI-Zusammenfassung erfordert eine Internetverbindung. Du bist derzeit offline.",
|
||||
"photos_title": "Foto-Anhänge",
|
||||
"export_photos_zip": "Fotos herunterladen (ZIP)",
|
||||
"exporting_photos_zip": "ZIP wird erstellt...",
|
||||
"no_photos_to_download": "Keine Fotos in diesem Logbuch vorhanden.",
|
||||
"photo_caption_label": "Foto-Beschreibung / Label (Optional)",
|
||||
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
|
||||
"photo_btn": "Foto aufnehmen / Hochladen",
|
||||
@@ -540,6 +576,9 @@
|
||||
"delete_confirm": "Bist du sicher, dass du dieses Logbuch unwiderruflich löschen möchtest? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstelle vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok), falls du die Daten später behalten möchtest.",
|
||||
"no_logbooks": "Keine Logbücher gefunden. Erstelle dein erstes Logbuch, um zu beginnen!",
|
||||
"loading": "Logbücher werden geladen...",
|
||||
"travel_days_count_zero": "Keine Reisetage",
|
||||
"travel_days_count_one": "1 Reisetag",
|
||||
"travel_days_count_other": "{{count}} Reisetage",
|
||||
"status_synced": "Synchronisiert",
|
||||
"status_local": "Nur lokaler Cache",
|
||||
"delete_btn": "Logbuch löschen",
|
||||
@@ -815,6 +854,7 @@
|
||||
"share_enable": "Öffentlichen Link aktivieren",
|
||||
"share_copied": "Link kopiert!",
|
||||
"share_copy_btn": "Link kopieren",
|
||||
"share_btn": "Link teilen",
|
||||
"link_qr_hint": "QR-Code zum Scannen mit dem Smartphone",
|
||||
"link_qr_alt": "QR-Code für den Link",
|
||||
"danger_zone_title": "Gefahrenzone",
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
"en": "English",
|
||||
"da": "Dansk",
|
||||
"sv": "Svenska",
|
||||
"nb": "Norsk"
|
||||
"nb": "Norsk",
|
||||
"fr": "French",
|
||||
"es": "Spanish"
|
||||
},
|
||||
"dialog": {
|
||||
"ok": "OK",
|
||||
@@ -34,7 +36,9 @@
|
||||
"unsaved_changes_stay": "Stay",
|
||||
"unsaved_changes_save_leave": "Save & leave",
|
||||
"unsaved_changes_discard": "Discard",
|
||||
"unsaved_changes_leave": "Leave"
|
||||
"unsaved_changes_leave": "Leave",
|
||||
"previous": "Previous",
|
||||
"next": "Next"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -186,6 +190,35 @@
|
||||
"departure": "Departure Port (von)",
|
||||
"destination": "Destination Port (nach)",
|
||||
"route": "Route / Journey",
|
||||
"tides": "Tides",
|
||||
"tide_high_water": "High water",
|
||||
"tide_low_water": "Low water",
|
||||
"tide_fetch_btn": "Fetch tides",
|
||||
"tide_fetch_loading": "Loading tides…",
|
||||
"tide_disclaimer": "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",
|
||||
"customize_columns": "Customize columns",
|
||||
"column_selector_title": "Columns to Show",
|
||||
@@ -443,6 +476,9 @@
|
||||
"ai_summary_error_forbidden": "Only the skipper may generate AI summaries.",
|
||||
"ai_summary_offline": "AI summary generation requires an internet connection. You are currently offline.",
|
||||
"photos_title": "Photo Attachments",
|
||||
"export_photos_zip": "Download Photos (ZIP)",
|
||||
"exporting_photos_zip": "Creating ZIP...",
|
||||
"no_photos_to_download": "No photos found in this logbook.",
|
||||
"photo_caption_label": "Photo Caption / Label (Optional)",
|
||||
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
|
||||
"photo_btn": "Take Photo / Upload",
|
||||
@@ -540,6 +576,9 @@
|
||||
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok) if you may need the data later.",
|
||||
"no_logbooks": "No logbooks found. Create your first logbook to begin!",
|
||||
"loading": "Loading logbooks...",
|
||||
"travel_days_count_zero": "No travel days",
|
||||
"travel_days_count_one": "1 travel day",
|
||||
"travel_days_count_other": "{{count}} travel days",
|
||||
"status_synced": "Synced",
|
||||
"status_local": "Local Cache Only",
|
||||
"delete_btn": "Delete logbook",
|
||||
@@ -815,6 +854,7 @@
|
||||
"share_enable": "Enable Public Link",
|
||||
"share_copied": "Link copied!",
|
||||
"share_copy_btn": "Copy Link",
|
||||
"share_btn": "Share Link",
|
||||
"link_qr_hint": "Scan this QR code with your phone",
|
||||
"link_qr_alt": "QR code for the link",
|
||||
"danger_zone_title": "Danger Zone",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+632
-592
File diff suppressed because it is too large
Load Diff
+683
-643
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,7 @@ export const PlausibleEvents = {
|
||||
VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded',
|
||||
VOICE_MEMO_TRANSCRIBED: 'Voice Memo Transcribed',
|
||||
OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
|
||||
TIDE_FETCHED: 'Tide Fetched',
|
||||
AI_SUMMARY_GENERATED: 'AI Summary Generated',
|
||||
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
|
||||
PWA_BOOT_WATCHDOG_HARD: 'PWA Boot Watchdog Hard',
|
||||
@@ -54,6 +55,8 @@ export const PlausibleEvents = {
|
||||
/** Where a successful OpenWeatherMap API call originated (no coordinates or place names). */
|
||||
export type OwmAnalyticsSource = 'live_log' | 'entry_editor' | 'entry_editor_gps_lookup'
|
||||
|
||||
export type TideAnalyticsSource = 'live_log' | 'entry_editor'
|
||||
|
||||
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
|
||||
|
||||
export type PlausibleEventProps = Record<string, string | number | boolean>
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface DecryptedLogbook {
|
||||
isShared: boolean
|
||||
accessRole: LogbookAccessRole
|
||||
isDemo?: boolean
|
||||
lastTravelDate?: string
|
||||
entryCount?: number
|
||||
}
|
||||
|
||||
// Helper to decrypt a logbook's title using the active logbook key or master key
|
||||
@@ -142,10 +144,24 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
|
||||
// Retrieve all from Dexie cache
|
||||
const cachedLogbooks = await db.logbooks.toArray()
|
||||
|
||||
// Decrypt titles
|
||||
// Decrypt titles and query last travel dates
|
||||
const decrypted: DecryptedLogbook[] = []
|
||||
for (const lb of cachedLogbooks) {
|
||||
const title = await decryptLogbookTitle(lb.id, lb.encryptedTitle)
|
||||
|
||||
// Find latest travel date from local entries cache
|
||||
const entries = await db.entries.where({ logbookId: lb.id }).toArray()
|
||||
let lastTravelDate: string | undefined = undefined
|
||||
if (entries.length > 0) {
|
||||
const dates = entries
|
||||
.map((e) => e.listCache?.date)
|
||||
.filter((d): d is string => typeof d === 'string' && d.length > 0)
|
||||
if (dates.length > 0) {
|
||||
dates.sort()
|
||||
lastTravelDate = dates[dates.length - 1]
|
||||
}
|
||||
}
|
||||
|
||||
decrypted.push({
|
||||
id: lb.id,
|
||||
title,
|
||||
@@ -155,7 +171,9 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
|
||||
accessRole: lb.isShared === 1
|
||||
? parseCollaborationRole(lb.collaborationRole, `cached logbook ${lb.id}`)
|
||||
: 'OWNER',
|
||||
isDemo: lb.isDemo === 1
|
||||
isDemo: lb.isDemo === 1,
|
||||
lastTravelDate,
|
||||
entryCount: entries.length
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,14 @@ import { putEntryRecord } from '../utils/entryListCache.js'
|
||||
import {
|
||||
buildLogEntryPayload,
|
||||
normalizeLogEvent,
|
||||
readLogEntryTidesMap,
|
||||
sortLogEventsByTime,
|
||||
currentLocalTimeHHMM,
|
||||
localDateString,
|
||||
type LogEventPayload
|
||||
type LogEntryTides,
|
||||
type LogEntryTidesMap,
|
||||
type LogEventPayload,
|
||||
type TideRole
|
||||
} from '../utils/logEntryPayload.js'
|
||||
import {
|
||||
carryOverFromPreviousDay,
|
||||
@@ -75,6 +79,7 @@ function buildEncryptedPayload(
|
||||
destination?: string
|
||||
freshwater?: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
fuel?: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
tides?: LogEntryTidesMap
|
||||
clearSignatures?: boolean
|
||||
}
|
||||
): Record<string, unknown> {
|
||||
@@ -113,6 +118,7 @@ function buildEncryptedPayload(
|
||||
freshwater,
|
||||
fuel: fuelLevels,
|
||||
greywater: gw ? { level: gw.level || 0 } : undefined,
|
||||
tides: options.tides ?? readLogEntryTidesMap(data),
|
||||
trackDistanceNm:
|
||||
trackDistance != null && trackDistance !== ''
|
||||
? parseFloat(String(trackDistance))
|
||||
@@ -398,6 +404,31 @@ export async function appendQuickEvents(
|
||||
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(
|
||||
logbookId: 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
|
||||
}
|
||||
@@ -20,14 +20,13 @@ vi.mock('../services/analytics.js', async (importOriginal) => {
|
||||
})
|
||||
|
||||
function createMockI18n(language: string): I18nInstance {
|
||||
let current = language
|
||||
return {
|
||||
language: current,
|
||||
const mock = {
|
||||
language,
|
||||
changeLanguage: vi.fn(async (lng: string) => {
|
||||
current = lng
|
||||
;(this as { language: string }).language = lng
|
||||
mock.language = lng
|
||||
})
|
||||
} as unknown as I18nInstance
|
||||
return mock
|
||||
}
|
||||
|
||||
describe('i18nLanguages', () => {
|
||||
@@ -72,11 +71,11 @@ describe('i18nLanguages', () => {
|
||||
})
|
||||
|
||||
it('cycleAppLanguage tracks the next language', () => {
|
||||
const i18n = createMockI18n('nb')
|
||||
const i18n = createMockI18n('es')
|
||||
cycleAppLanguage(i18n)
|
||||
|
||||
expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.LANGUAGE_CHANGED, {
|
||||
from: 'nb',
|
||||
from: 'es',
|
||||
to: 'de'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,10 +2,20 @@ import type { i18n as I18nInstance } from 'i18next'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
|
||||
/** Supported UI languages (ISO 639-1, language-only). */
|
||||
export const SUPPORTED_LANGUAGES = ['de', 'en', 'da', 'sv', 'nb'] as const
|
||||
export const SUPPORTED_LANGUAGES = ['de', 'en', 'da', 'sv', 'nb', 'fr', 'es'] as const
|
||||
|
||||
export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number]
|
||||
|
||||
export const LANGUAGE_FLAGS: Record<AppLanguage, string> = {
|
||||
de: '🇩🇪',
|
||||
en: '🇬🇧',
|
||||
da: '🇩🇰',
|
||||
sv: '🇸🇪',
|
||||
nb: '🇳🇴',
|
||||
fr: '🇫🇷',
|
||||
es: '🇪🇸'
|
||||
}
|
||||
|
||||
export function normalizeAppLanguage(language?: string): AppLanguage {
|
||||
const base = (language ?? 'en').split('-')[0].toLowerCase()
|
||||
if ((SUPPORTED_LANGUAGES as readonly string[]).includes(base)) {
|
||||
|
||||
@@ -154,6 +154,9 @@ export function getLastAutoPositionMs(
|
||||
/** Max age of a logged position for OpenWeatherMap lookups in live log. */
|
||||
export const LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS = 6 * 60 * 60 * 1000
|
||||
|
||||
/** Max age of a logged position for tide lookups (TideTurtle). */
|
||||
export const LIVE_LOG_TIDE_POSITION_MAX_AGE_MS = 2 * 60 * 60 * 1000
|
||||
|
||||
export type LiveLogPositionSource = 'position' | 'auto_position'
|
||||
|
||||
export interface LiveLogPosition {
|
||||
@@ -176,7 +179,10 @@ export function getLatestLoggedPosition(
|
||||
events: Array<{ remarks: string; time: string; gpsLat?: string; gpsLng?: string }>,
|
||||
entryDate: string
|
||||
): LiveLogPosition | null {
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
let best: LiveLogPosition | null = null
|
||||
let bestIndex = -1
|
||||
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const event = events[i]
|
||||
const code = event.remarks.trim()
|
||||
if (!isPositionEventCode(code)) continue
|
||||
@@ -185,14 +191,25 @@ export function getLatestLoggedPosition(
|
||||
if (!lat || !lng) continue
|
||||
const loggedAtMs = eventTimestampMs(entryDate, event.time)
|
||||
if (loggedAtMs == null) continue
|
||||
return {
|
||||
|
||||
const candidate: LiveLogPosition = {
|
||||
lat,
|
||||
lng,
|
||||
loggedAtMs,
|
||||
source: isManualPositionEventCode(code) ? 'position' : 'auto_position'
|
||||
}
|
||||
|
||||
if (
|
||||
!best ||
|
||||
candidate.loggedAtMs > best.loggedAtMs ||
|
||||
(candidate.loggedAtMs === best.loggedAtMs && i > bestIndex)
|
||||
) {
|
||||
best = candidate
|
||||
bestIndex = i
|
||||
}
|
||||
}
|
||||
return null
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
/** Logged position for weather if recorded within `maxAgeMs` (default 6 h). */
|
||||
|
||||
@@ -19,6 +19,16 @@ describe('live log position', () => {
|
||||
expect(position?.source).toBe('position')
|
||||
})
|
||||
|
||||
it('picks latest position by event time even when array is not sorted', () => {
|
||||
const entryDate = '2026-06-01'
|
||||
const events = [
|
||||
{ remarks: LIVE_EVENT_CODES.POSITION, time: '14:16', gpsLat: '54.12', gpsLng: '10.65' },
|
||||
{ remarks: LIVE_EVENT_CODES.POSITION, time: '14:03', gpsLat: '53.62', gpsLng: '7.15' }
|
||||
]
|
||||
const position = getLatestLoggedPosition(events, entryDate)
|
||||
expect(position?.lat).toBe('54.12')
|
||||
})
|
||||
|
||||
it('reads legacy __live:fix remarks', () => {
|
||||
const entryDate = '2026-06-01'
|
||||
const events = [
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
isLogEventDraftEmpty,
|
||||
localDateString,
|
||||
normalizeLogEvent,
|
||||
splitTimeHHMM,
|
||||
readLogEntryTidesMap,
|
||||
type LogEventPayload
|
||||
} from './logEntryPayload.js'
|
||||
|
||||
@@ -72,3 +74,140 @@ describe('buildLogEntryPayload greywater', () => {
|
||||
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
|
||||
}
|
||||
|
||||
export function splitTimeHHMM(value: string): { hours: string; minutes: string } {
|
||||
const parsed = parseTimeToHHMM(value) ?? currentLocalTimeHHMM()
|
||||
export function splitTimeHHMM(value: string, fallback?: string): { hours: string; minutes: string } {
|
||||
const parsed = parseTimeToHHMM(value) ?? fallback ?? currentLocalTimeHHMM()
|
||||
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 || ''))
|
||||
}
|
||||
|
||||
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 {
|
||||
date: string
|
||||
dayOfTravel: string
|
||||
@@ -158,6 +175,7 @@ export interface LogEntryPayloadInput {
|
||||
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
fuel: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
greywater?: { level: number }
|
||||
tides?: LogEntryTidesMap
|
||||
trackDistanceNm?: number
|
||||
trackSpeedMaxKn?: number
|
||||
trackSpeedAvgKn?: number
|
||||
@@ -166,6 +184,64 @@ export interface LogEntryPayloadInput {
|
||||
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> {
|
||||
const payload: Record<string, unknown> = {
|
||||
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) {
|
||||
payload.selectedSkipperId = input.entryCrew.selectedSkipperId
|
||||
payload.selectedCrewIds = [...input.entryCrew.selectedCrewIds]
|
||||
|
||||
@@ -10,7 +10,9 @@ const OG_LOCALES: Record<SeoLang, string> = {
|
||||
en: 'en_GB',
|
||||
da: 'da_DK',
|
||||
sv: 'sv_SE',
|
||||
nb: 'nb_NO'
|
||||
nb: 'nb_NO',
|
||||
fr: 'fr_FR',
|
||||
es: 'es_ES'
|
||||
}
|
||||
|
||||
let i18nRef: I18nInstance | null = null
|
||||
|
||||
@@ -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:
|
||||
PLAUSIBLE_ENABLED: ${PLAUSIBLE_ENABLED:-false}
|
||||
PLAUSIBLE_HOST: ${PLAUSIBLE_HOST:-https://plausible.elpatron.me}
|
||||
ROBOTS_NOINDEX: ${ROBOTS_NOINDEX:-true}
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
|
||||
@@ -83,7 +83,7 @@ Notfall ohne Checks: `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-remotes.sh -dest s
|
||||
| Forward Port | `80` |
|
||||
| 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).
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ Das Script wird über `plausible-bootstrap.js` geladen; `data-domain` ist der ak
|
||||
| Voice Memo Uploaded | Sprachnotiz gespeichert (`voiceAttachments.ts`) | `context`: `logbook` \| `live_log` |
|
||||
| Voice Memo Transcribed | Sprachmemo transkribiert (`LiveLogView.tsx`, `EventRemarksCell.tsx`) | `status`: `success` \| `failed`, `mode`: `auto` (beim Speichern) \| `manual` (nachträglich) |
|
||||
| OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) |
|
||||
| Tide Fetched | Erfolgreicher TideTurtle-Abruf (`tides.ts`) | `source`: `live_log` \| `entry_editor`; `location_source`: `gps` \| `departure` |
|
||||
| AI Summary Generated | Erfolgreiche KI-Zusammenfassung eines Reisetags (`aiSummary.ts`) | — |
|
||||
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes` (Anzahlen/Größe, keine Inhalte) |
|
||||
| Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes`, `mode`: `same_id` \| `overwrite` \| `new_id` |
|
||||
@@ -148,7 +149,7 @@ Empfohlene Goal-Ketten für Auswertung (nur Business!):
|
||||
8. **Internationalisierung:** Language Changed (Verteilung `to`, Pfade mit Übersetzungs-Feedback)
|
||||
9. **NMEA-Import:** NMEA Uploaded → NMEA Imported (Modus, `events`, optional Track; Upload-Funnel vs. Abbruch)
|
||||
10. **Live-Journal:** Live Log Opened → Live Log Event Logged (Verteilung `action`; z. B. `position`, `course`, `motor_start`) → Photo Uploaded / Voice Memo Uploaded (Filter `context`: `live_log`)
|
||||
11. **OpenWeatherMap:** OWM Weather Fetched (Verteilung `source`; Live-Journal vs. Reisetag-Editor)
|
||||
11. **OpenWeatherMap / Gezeiten:** OWM Weather Fetched (Verteilung `source`); Tide Fetched (Verteilung `location_source`)
|
||||
12. **PWA-Stabilitaet:** PWA Boot Watchdog Soft → PWA Boot Watchdog Hard → PWA Boot Watchdog Fallback → PWA Boot Watchdog Manual Repair
|
||||
|
||||
## Entwicklung
|
||||
|
||||
@@ -23,7 +23,9 @@ const defaultSource = resolve(repoRoot, 'client/src/i18n/locales/de.json')
|
||||
const TARGETS = {
|
||||
da: 'DA',
|
||||
sv: 'SV',
|
||||
nb: 'NB'
|
||||
nb: 'NB',
|
||||
fr: 'FR',
|
||||
es: 'ES'
|
||||
}
|
||||
|
||||
/** Keys whose values stay identical to source (language names, brand). */
|
||||
|
||||
+31
-18
@@ -70,6 +70,11 @@ DEFAULT_VERSION="0.1.0.0"
|
||||
MAX_WAIT=90
|
||||
|
||||
REMOTE_USER="${REMOTE_USER:-root}"
|
||||
# GIT_REMOTE="${GIT_REMOTE:-github}"
|
||||
# GIT_REMOTE_URL="${GIT_REMOTE_URL:-https://github.com/elpatron68/kapteins-daagbok.git}"
|
||||
GIT_REMOTE="${GIT_REMOTE:-origin}"
|
||||
GIT_REMOTE_URL="${GIT_REMOTE_URL:-https://gitea.elpatron.me/elpatron/kapteins-daagbok.git}"
|
||||
|
||||
|
||||
if [[ "$DEST" == "stage" ]]; then
|
||||
REMOTE_HOST="${REMOTE_HOST:-10.0.0.27}"
|
||||
@@ -85,7 +90,7 @@ else
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
|
||||
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-prod-backend}"
|
||||
APP_URL="${APP_URL:-https://kapteins-daagbok.eu}"
|
||||
DEPLOY_BRANCH=""
|
||||
DEPLOY_BRANCH="none"
|
||||
ENV_LABEL="Production"
|
||||
fi
|
||||
|
||||
@@ -186,34 +191,34 @@ ensure_local_sync_with_origin() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Syncing with origin..."
|
||||
git fetch --tags origin
|
||||
echo "Syncing with ${GIT_REMOTE}..."
|
||||
git fetch --tags "${GIT_REMOTE}"
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: git fetch origin failed." >&2
|
||||
echo "Error: git fetch ${GIT_REMOTE} failed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git rev-parse --verify "origin/${branch}" >/dev/null 2>&1; then
|
||||
echo "Error: origin/${branch} does not exist." >&2
|
||||
if ! git rev-parse --verify "${GIT_REMOTE}/${branch}" >/dev/null 2>&1; then
|
||||
echo "Error: ${GIT_REMOTE}/${branch} does not exist." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local_sha="$(git rev-parse HEAD)"
|
||||
origin_sha="$(git rev-parse "origin/${branch}")"
|
||||
origin_sha="$(git rev-parse "${GIT_REMOTE}/${branch}")"
|
||||
|
||||
if [ "$local_sha" = "$origin_sha" ]; then
|
||||
echo "Local branch '$branch' matches origin/${branch} ($(git rev-parse --short HEAD))."
|
||||
echo "Local branch '$branch' matches ${GIT_REMOTE}/${branch} ($(git rev-parse --short HEAD))."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Error: Local '$branch' is not in sync with origin/${branch}." >&2
|
||||
echo "Error: Local '$branch' is not in sync with ${GIT_REMOTE}/${branch}." >&2
|
||||
echo " local: $(git rev-parse --short HEAD) $(git log -1 --format='%s' HEAD)" >&2
|
||||
echo " origin: $(git rev-parse --short "origin/${branch}") $(git log -1 --format='%s' "origin/${branch}")" >&2
|
||||
echo " ${GIT_REMOTE}: $(git rev-parse --short "${GIT_REMOTE}/${branch}") $(git log -1 --format='%s' "${GIT_REMOTE}/${branch}")" >&2
|
||||
|
||||
if git merge-base --is-ancestor "$local_sha" "origin/${branch}" 2>/dev/null; then
|
||||
if git merge-base --is-ancestor "$local_sha" "${GIT_REMOTE}/${branch}" 2>/dev/null; then
|
||||
echo "Hint: run 'git pull' to fast-forward." >&2
|
||||
elif git merge-base --is-ancestor "origin/${branch}" "$local_sha" 2>/dev/null; then
|
||||
echo "Hint: run 'git push origin ${branch}' before deploying." >&2
|
||||
elif git merge-base --is-ancestor "${GIT_REMOTE}/${branch}" "$local_sha" 2>/dev/null; then
|
||||
echo "Hint: run 'git push ${GIT_REMOTE} ${branch}' before deploying." >&2
|
||||
else
|
||||
echo "Hint: branches have diverged — reconcile manually before deploying." >&2
|
||||
fi
|
||||
@@ -246,12 +251,12 @@ prepare_release() {
|
||||
echo " Next prep: v${next_version}"
|
||||
echo ""
|
||||
|
||||
read -r -p "Push commit and tag to origin? [Y/n] " push_answer
|
||||
read -r -p "Push commit and tag to ${GIT_REMOTE}? [Y/n] " push_answer
|
||||
if [[ ! "$push_answer" =~ ^[nN]$ ]]; then
|
||||
current_branch="$(git branch --show-current)"
|
||||
git push origin "$current_branch"
|
||||
git push origin "$tag_name"
|
||||
echo "Pushed ${current_branch} and ${tag_name} to origin."
|
||||
git push "${GIT_REMOTE}" "$current_branch"
|
||||
git push "${GIT_REMOTE}" "$tag_name"
|
||||
echo "Pushed ${current_branch} and ${tag_name} to ${GIT_REMOTE}."
|
||||
else
|
||||
echo "Skipped push. Remote host must receive this commit/tag manually."
|
||||
fi
|
||||
@@ -281,7 +286,7 @@ echo "Deploying v${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}"
|
||||
echo "=================================================="
|
||||
|
||||
ssh -o ConnectTimeout=10 "$REMOTE_TARGET" 'bash -s' -- \
|
||||
"$REMOTE_DIR" "$COMPOSE_FILE" "$BACKEND_CONTAINER" "$MAX_WAIT" "$APP_URL" "$APP_VERSION" "$DEST" "$DEPLOY_BRANCH" <<'REMOTE_SCRIPT'
|
||||
"$REMOTE_DIR" "$COMPOSE_FILE" "$BACKEND_CONTAINER" "$MAX_WAIT" "$APP_URL" "$APP_VERSION" "$DEST" "$DEPLOY_BRANCH" "$GIT_REMOTE_URL" <<'REMOTE_SCRIPT'
|
||||
set -uo pipefail
|
||||
|
||||
REMOTE_DIR="$1"
|
||||
@@ -292,9 +297,17 @@ APP_URL="$5"
|
||||
APP_VERSION="$6"
|
||||
DEST="$7"
|
||||
DEPLOY_BRANCH="${8:-}"
|
||||
GIT_REMOTE_URL="${9:-https://github.com/elpatron68/kapteins-daagbok.git}"
|
||||
|
||||
cd "$REMOTE_DIR" || { echo "Error: Remote directory '$REMOTE_DIR' not found."; exit 1; }
|
||||
|
||||
echo "Configuring git remote 'origin' URL to ${GIT_REMOTE_URL} on remote host..."
|
||||
if git remote | grep -q "^origin$"; then
|
||||
git remote set-url origin "$GIT_REMOTE_URL"
|
||||
else
|
||||
git remote add origin "$GIT_REMOTE_URL"
|
||||
fi
|
||||
|
||||
if ! git diff-index --quiet HEAD -- || [ -n "$(git status --porcelain)" ]; then
|
||||
echo "Warning: Local changes on deployment host will be discarded."
|
||||
fi
|
||||
|
||||
@@ -11,7 +11,7 @@ import { flattenTranslation } from './lib/deepl-translate.mjs'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const localesDir = resolve(__dirname, '../client/src/i18n/locales')
|
||||
const localeFiles = ['de.json', 'en.json', 'da.json', 'sv.json', 'nb.json']
|
||||
const localeFiles = ['de.json', 'en.json', 'da.json', 'sv.json', 'nb.json', 'fr.json', 'es.json']
|
||||
|
||||
async function loadKeys(filename) {
|
||||
const raw = await readFile(resolve(localesDir, filename), 'utf8')
|
||||
|
||||
@@ -10,6 +10,7 @@ import collaborationRouter from './routes/collaboration.js'
|
||||
import signRouter from './routes/sign.js'
|
||||
import pushRouter from './routes/push.js'
|
||||
import weatherRouter from './routes/weather.js'
|
||||
import tidesRouter from './routes/tides.js'
|
||||
import aiRouter from './routes/ai.js'
|
||||
import feedbackRouter from './routes/feedback.js'
|
||||
import adminRouter from './routes/admin.js'
|
||||
@@ -120,6 +121,7 @@ export function createApp(): express.Express {
|
||||
app.use('/api/sign', signRouter)
|
||||
app.use('/api/push', pushRouter)
|
||||
app.use('/api/weather', weatherRouter)
|
||||
app.use('/api/tides', tidesRouter)
|
||||
app.use('/api/ai', aiRouter)
|
||||
app.use('/api/feedback', feedbackRouter)
|
||||
app.use('/api/admin', adminRouter)
|
||||
|
||||
@@ -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