Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
465c58ee69 | ||
|
|
0fe272a4de | ||
|
|
2689bdbc96 | ||
|
|
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 | ||
|
|
fafefff29b | ||
|
|
4fd7f3c6cf | ||
|
|
262c48a01a | ||
|
|
9ad3c2cf38 | ||
|
|
6848390ffa | ||
|
|
65d2215a35 | ||
|
|
f321e5bbd1 | ||
|
|
d2961b050a | ||
|
|
6943fd2dc4 | ||
|
|
f332eccf22 | ||
|
|
9d2a19dbf8 | ||
|
|
e3cd89be5d | ||
|
|
a86da72b04 | ||
|
|
7d6f381f55 | ||
|
|
878be33b7c | ||
|
|
318f5e65da | ||
|
|
8c6ab59d67 | ||
|
|
a9c3e9ce3e | ||
|
|
3eaf59e2b3 | ||
|
|
b1e17be7fd | ||
|
|
ac7e7c92d1 | ||
|
|
e10cef4b05 | ||
|
|
0ec5c51102 | ||
|
|
57b93b7ce7 |
+7
-4
@@ -6,10 +6,6 @@ OpenRouterAPIKey=
|
||||
# Valid examples: anthropic/claude-3.5-haiku, anthropic/claude-3-haiku, anthropic/claude-haiku-4.5
|
||||
# OpenRouterModel=anthropic/claude-3.5-haiku
|
||||
|
||||
# Speech-to-Text Transcription Service (local Parakeet container endpoint)
|
||||
# Defaults to: http://localhost:5092/v1/audio/transcriptions (or http://parakeet:5092/v1/audio/transcriptions in Docker)
|
||||
# PARAKEET_URL=http://localhost:5092/v1/audio/transcriptions
|
||||
|
||||
# DeepL API (for scripts/translate-locales.mjs and scripts/translate-flyer.mjs)
|
||||
# Free plan keys use api-free.deepl.com automatically (suffix :fx)
|
||||
DeepLAPIKey=
|
||||
@@ -38,6 +34,8 @@ ORIGIN=http://localhost:5173
|
||||
# POSTGRES_USER=postgres
|
||||
# POSTGRES_PASSWORD=
|
||||
# POSTGRES_DB=daagbox
|
||||
# Optional: lock Docker Compose to a specific configuration file (e.g. staging or production) on the server:
|
||||
# COMPOSE_FILE=docker-compose.staging.yml
|
||||
# Optional: comma-separated CORS origins (defaults to ORIGIN; 127.0.0.1 may be allowed for CORS but not for login)
|
||||
# CORS_ORIGINS=http://localhost:5173
|
||||
|
||||
@@ -66,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
|
||||
|
||||
@@ -13,3 +13,6 @@ server/dist/
|
||||
*.log
|
||||
|
||||
userfeedback/
|
||||
|
||||
# Cursor IDE local rules
|
||||
.cursor/rules/
|
||||
|
||||
@@ -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>
|
||||
+910
-17
File diff suppressed because it is too large
Load Diff
+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>
|
||||
|
||||
@@ -7,12 +7,22 @@ import {
|
||||
type AdminTimeSeriesResponse,
|
||||
type AdminTimeBucket
|
||||
} from '../services/adminApi.js'
|
||||
import { BarChart2, Bookmark, ChevronLeft, Image, MapPin, Mic, Users } from 'lucide-react'
|
||||
import { BarChart2, Bookmark, ChevronLeft, Database, Image, MapPin, Mic, Users } from 'lucide-react'
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number | undefined): string {
|
||||
if (bytes === undefined || bytes === null) return '—'
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
const num = bytes / Math.pow(k, i)
|
||||
return `${num.toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
function KpiCard({
|
||||
icon,
|
||||
label,
|
||||
@@ -20,14 +30,14 @@ function KpiCard({
|
||||
}: {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
value: number
|
||||
value: number | string
|
||||
}) {
|
||||
return (
|
||||
<div className="stats-kpi-card glass">
|
||||
<div className="stats-kpi-icon">{icon}</div>
|
||||
<div className="stats-kpi-body">
|
||||
<span className="stats-kpi-label">{label}</span>
|
||||
<span className="stats-kpi-value">{formatNumber(value)}</span>
|
||||
<span className="stats-kpi-value">{typeof value === 'number' ? formatNumber(value) : value}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -194,6 +204,7 @@ export default function AdminDashboard({ onBack }: AdminDashboardProps) {
|
||||
label="Einträge mit AI-Zusammenfassung"
|
||||
value={summary.aiSummaryEntries}
|
||||
/>
|
||||
<KpiCard icon={<Database size={20} />} label="Datenbankgröße" value={formatBytes(summary.dbSize)} />
|
||||
</section>
|
||||
|
||||
<section className="admin-controls">
|
||||
@@ -233,6 +244,7 @@ export default function AdminDashboard({ onBack }: AdminDashboardProps) {
|
||||
<TimeSeriesChart title="Neue Benutzer" seriesKey="users_created" data={timeSeries} />
|
||||
<TimeSeriesChart title="Neue Logbücher" seriesKey="logbooks_created" data={timeSeries} />
|
||||
<TimeSeriesChart title="Foto-Aktivität" seriesKey="photos_updated" data={timeSeries} />
|
||||
<TimeSeriesChart title="Datenbankgröße (MB)" seriesKey="database_size" data={timeSeries} />
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Users } from 'lucide-react'
|
||||
import { Users, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import type { EntryCrewFields, PersonSnapshot } from '../types/person.js'
|
||||
import { loadPersonPool } from '../services/personPool.js'
|
||||
import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js'
|
||||
@@ -24,6 +24,7 @@ export default function EntryCrewSection({
|
||||
preloadedPool
|
||||
}: EntryCrewSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map())
|
||||
|
||||
useEffect(() => {
|
||||
@@ -90,54 +91,78 @@ export default function EntryCrewSection({
|
||||
|
||||
return (
|
||||
<div className="form-card" data-tour="entry-crew">
|
||||
<div className="form-header">
|
||||
<Users size={22} className="form-icon" />
|
||||
<h3>{t('entry_crew.title')}</h3>
|
||||
</div>
|
||||
<p className="help-text mb-3">{t('entry_crew.subtitle')}</p>
|
||||
|
||||
<div className="input-group mb-3">
|
||||
<label>{t('entry_crew.day_skipper')}</label>
|
||||
{skippers.length === 0 ? (
|
||||
<p className="help-text">{t('entry_crew.no_skipper')}</p>
|
||||
<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">
|
||||
<Users size={22} className="form-icon" />
|
||||
<h3>{t('entry_crew.title')}</h3>
|
||||
</div>
|
||||
{collapsed ? (
|
||||
<ChevronDown size={20} className="accordion-chevron" />
|
||||
) : (
|
||||
<div className="crew-selection-list">
|
||||
{skippers.map(([id, data]) => (
|
||||
<label key={id} className="crew-selection-item">
|
||||
<input
|
||||
type="radio"
|
||||
name={`entry-skipper-${logbookId}`}
|
||||
checked={value.selectedSkipperId === id}
|
||||
onChange={() => !readOnly && applyChange(id, value.selectedCrewIds)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<span>{data.name || t('logbook_crew.unnamed')}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<ChevronUp size={20} className="accordion-chevron" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('entry_crew.day_crew')}</label>
|
||||
{crewEntries.length === 0 ? (
|
||||
<p className="help-text">{t('entry_crew.no_crew')}</p>
|
||||
) : (
|
||||
<div className="crew-selection-list">
|
||||
{crewEntries.map(([id, data]) => (
|
||||
<label key={id} className="crew-selection-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value.selectedCrewIds.includes(id)}
|
||||
onChange={() => toggleCrew(id)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<span>{data.name || t('logbook_crew.unnamed')}</span>
|
||||
</label>
|
||||
))}
|
||||
{!collapsed && (
|
||||
<>
|
||||
<p className="help-text mb-3" style={{ marginTop: '16px' }}>{t('entry_crew.subtitle')}</p>
|
||||
|
||||
<div className="input-group mb-3">
|
||||
<label>{t('entry_crew.day_skipper')}</label>
|
||||
{skippers.length === 0 ? (
|
||||
<p className="help-text">{t('entry_crew.no_skipper')}</p>
|
||||
) : (
|
||||
<div className="crew-selection-list">
|
||||
{skippers.map(([id, data]) => (
|
||||
<label key={id} className="crew-selection-item">
|
||||
<input
|
||||
type="radio"
|
||||
name={`entry-skipper-${logbookId}`}
|
||||
checked={value.selectedSkipperId === id}
|
||||
onChange={() => !readOnly && applyChange(id, value.selectedCrewIds)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<span>{data.name || t('logbook_crew.unnamed')}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('entry_crew.day_crew')}</label>
|
||||
{crewEntries.length === 0 ? (
|
||||
<p className="help-text">{t('entry_crew.no_crew')}</p>
|
||||
) : (
|
||||
<div className="crew-selection-list">
|
||||
{crewEntries.map(([id, data]) => (
|
||||
<label key={id} className="crew-selection-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value.selectedCrewIds.includes(id)}
|
||||
onChange={() => toggleCrew(id)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<span>{data.name || t('logbook_crew.unnamed')}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { formatEventSummary } from '../utils/formatEventSummary.js'
|
||||
import VoiceMemoPlayer, { type PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { updateVoiceMemoTranscript } from '../services/voiceAttachments.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { getAiAuthorized } from '../services/userPreferences.js'
|
||||
|
||||
interface EventRemarksCellProps {
|
||||
event: LogEventPayload
|
||||
@@ -44,6 +46,13 @@ export default function EventRemarksCell({
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (transcribing || !preloaded?.audio || !voiceId) return
|
||||
if (!getAiAuthorized()) {
|
||||
void showAlert(
|
||||
t('profile.ai_unauthorized_alert_desc'),
|
||||
t('profile.ai_unauthorized_alert_title')
|
||||
)
|
||||
return
|
||||
}
|
||||
setTranscribing(true)
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 15000)
|
||||
@@ -66,9 +75,17 @@ export default function EventRemarksCell({
|
||||
throw new Error('Transcription returned empty text')
|
||||
}
|
||||
await updateVoiceMemoTranscript(logbookId, voiceId, text)
|
||||
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
|
||||
status: 'success',
|
||||
mode: 'manual'
|
||||
})
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId)
|
||||
console.error('[EventRemarksCell] Transcription failed:', err)
|
||||
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
|
||||
status: 'failed',
|
||||
mode: 'manual'
|
||||
})
|
||||
void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn'))
|
||||
} finally {
|
||||
setTranscribing(false)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Anchor,
|
||||
Camera,
|
||||
CloudSun,
|
||||
Compass,
|
||||
Droplets,
|
||||
Fuel,
|
||||
Gauge,
|
||||
MapPin,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
MoreHorizontal,
|
||||
Sailboat,
|
||||
Waves,
|
||||
X,
|
||||
Zap
|
||||
} from 'lucide-react'
|
||||
|
||||
export interface LiveActionToolbarProps {
|
||||
motorRunning: boolean
|
||||
hasLoggedPosition: boolean
|
||||
lastSailsLabel?: string
|
||||
busy: boolean
|
||||
photoSaving: boolean
|
||||
voiceSaving: boolean
|
||||
tidesLoading: boolean
|
||||
weatherOwmLoading: boolean
|
||||
onMotorToggle: () => void
|
||||
onCastOff: () => void
|
||||
onMoor: () => void
|
||||
onOpenSails: () => void
|
||||
onOpenCourse: () => void
|
||||
onOpenSog: () => void
|
||||
onOpenStw: () => void
|
||||
onOpenFuel: () => void
|
||||
onOpenWater: () => void
|
||||
onOpenPosition: () => void
|
||||
onFetchTides: () => void
|
||||
onOpenComment: () => void
|
||||
onOpenPhoto: () => void
|
||||
onOpenVoice: () => void
|
||||
onFetchOwmWeather: () => void
|
||||
onOpenWind: () => void
|
||||
onOpenTemp: () => void
|
||||
onOpenPressure: () => void
|
||||
onOpenPrecip: () => void
|
||||
onOpenSeaState: () => void
|
||||
onOpenVisibility: () => void
|
||||
}
|
||||
|
||||
export default function LiveActionToolbar({
|
||||
motorRunning,
|
||||
hasLoggedPosition,
|
||||
lastSailsLabel,
|
||||
busy,
|
||||
photoSaving,
|
||||
voiceSaving,
|
||||
tidesLoading,
|
||||
weatherOwmLoading,
|
||||
onMotorToggle,
|
||||
onCastOff,
|
||||
onMoor,
|
||||
onOpenSails,
|
||||
onOpenCourse,
|
||||
onOpenSog,
|
||||
onOpenStw,
|
||||
onOpenFuel,
|
||||
onOpenWater,
|
||||
onOpenPosition,
|
||||
onFetchTides,
|
||||
onOpenComment,
|
||||
onOpenPhoto,
|
||||
onOpenVoice,
|
||||
onFetchOwmWeather,
|
||||
onOpenWind,
|
||||
onOpenTemp,
|
||||
onOpenPressure,
|
||||
onOpenPrecip,
|
||||
onOpenSeaState,
|
||||
onOpenVisibility
|
||||
}: LiveActionToolbarProps) {
|
||||
const { t } = useTranslation()
|
||||
const [weatherSheetOpen, setWeatherSheetOpen] = useState(false)
|
||||
const [moreSheetOpen, setMoreSheetOpen] = useState(false)
|
||||
|
||||
const closeSheets = () => {
|
||||
setWeatherSheetOpen(false)
|
||||
setMoreSheetOpen(false)
|
||||
}
|
||||
|
||||
const runWeather = (fn: () => void) => {
|
||||
fn()
|
||||
closeSheets()
|
||||
}
|
||||
|
||||
const runMore = (fn: () => void) => {
|
||||
fn()
|
||||
closeSheets()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="live-action-toolbar-sticky">
|
||||
<div className="live-action-toolbar" aria-label={t('logs.live_actions_label')}>
|
||||
<div className="live-action-grid live-action-grid-primary" data-tour="live-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`live-log-action-btn live-log-action-btn-compact ${motorRunning ? 'is-active' : ''}`}
|
||||
onClick={onMotorToggle}
|
||||
disabled={busy}
|
||||
>
|
||||
<Zap size={20} />
|
||||
<span>{motorRunning ? t('logs.live_motor_stop') : t('logs.live_motor_start')}</span>
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn live-log-action-btn-compact" onClick={onCastOff} disabled={busy}>
|
||||
<Anchor size={20} />
|
||||
<span>{t('logs.live_cast_off')}</span>
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn live-log-action-btn-compact" onClick={onMoor} disabled={busy}>
|
||||
<Anchor size={20} style={{ transform: 'scaleX(-1)' }} />
|
||||
<span>{t('logs.live_moor')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`live-log-action-btn live-log-action-btn-compact ${hasLoggedPosition ? 'has-gps' : ''}`}
|
||||
onClick={onOpenPosition}
|
||||
disabled={busy}
|
||||
>
|
||||
<MapPin size={20} />
|
||||
<span>{t('logs.live_position')}</span>
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn live-log-action-btn-compact" onClick={onOpenPhoto} disabled={busy || photoSaving}>
|
||||
<Camera size={20} />
|
||||
<span>{t('logs.live_photo_btn')}</span>
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn live-log-action-btn-compact" onClick={onOpenVoice} disabled={busy || voiceSaving}>
|
||||
<Mic size={20} />
|
||||
<span>{t('logs.live_voice_btn')}</span>
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn live-log-action-btn-compact" onClick={onOpenComment} disabled={busy}>
|
||||
<MessageSquare size={20} />
|
||||
<span>{t('logs.live_comment_btn')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="live-action-grid live-action-grid-secondary">
|
||||
<button
|
||||
type="button"
|
||||
className={`live-log-action-btn live-log-action-btn-compact ${lastSailsLabel ? 'has-context' : ''}`}
|
||||
onClick={onOpenSails}
|
||||
disabled={busy}
|
||||
title={lastSailsLabel}
|
||||
>
|
||||
<Sailboat size={18} />
|
||||
<span>{t('logs.live_sails_btn')}</span>
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn live-log-action-btn-compact" onClick={onOpenCourse} disabled={busy}>
|
||||
<Compass size={18} />
|
||||
<span>{t('logs.live_course_btn')}</span>
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn live-log-action-btn-compact" onClick={onOpenSog} disabled={busy}>
|
||||
<Gauge size={18} />
|
||||
<span>{t('logs.live_sog_btn')}</span>
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn live-log-action-btn-compact" onClick={onOpenStw} disabled={busy}>
|
||||
<Gauge size={18} style={{ transform: 'scaleX(-1)' }} />
|
||||
<span>{t('logs.live_stw_btn')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="live-log-action-btn live-log-action-btn-compact"
|
||||
onClick={() => { setMoreSheetOpen(false); setWeatherSheetOpen(true) }}
|
||||
disabled={busy}
|
||||
>
|
||||
<CloudSun size={18} />
|
||||
<span>{t('logs.live_weather_btn')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="live-log-action-btn live-log-action-btn-compact"
|
||||
onClick={() => { setWeatherSheetOpen(false); setMoreSheetOpen(true) }}
|
||||
disabled={busy}
|
||||
>
|
||||
<MoreHorizontal size={18} />
|
||||
<span>{t('logs.live_more_btn')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{weatherSheetOpen && (
|
||||
<div
|
||||
className="live-bottom-sheet-backdrop"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) closeSheets() }}
|
||||
role="presentation"
|
||||
>
|
||||
<div className="live-bottom-sheet" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="live-bottom-sheet-handle" aria-hidden />
|
||||
<div className="live-bottom-sheet-header">
|
||||
<h3>{t('logs.live_weather_btn')}</h3>
|
||||
<button type="button" className="btn-icon" onClick={closeSheets} aria-label={t('logs.live_cancel')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="live-overflow-menu-list">
|
||||
<button
|
||||
type="button"
|
||||
className="live-overflow-menu-item live-log-subaction-btn-owm"
|
||||
onClick={() => runWeather(onFetchOwmWeather)}
|
||||
disabled={busy || weatherOwmLoading}
|
||||
>
|
||||
{weatherOwmLoading ? t('logs.live_weather_owm_loading') : t('logs.live_weather_owm_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-overflow-menu-item" onClick={() => runWeather(onOpenWind)} disabled={busy}>
|
||||
{t('logs.live_wind_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-overflow-menu-item" onClick={() => runWeather(onOpenTemp)} disabled={busy}>
|
||||
{t('logs.live_temp_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-overflow-menu-item" onClick={() => runWeather(onOpenPressure)} disabled={busy}>
|
||||
{t('logs.live_pressure_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-overflow-menu-item" onClick={() => runWeather(onOpenPrecip)} disabled={busy}>
|
||||
{t('logs.live_precip_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-overflow-menu-item" onClick={() => runWeather(onOpenSeaState)} disabled={busy}>
|
||||
{t('logs.live_sea_state_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-overflow-menu-item" onClick={() => runWeather(onOpenVisibility)} disabled={busy}>
|
||||
{t('logs.live_visibility_btn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{moreSheetOpen && (
|
||||
<div
|
||||
className="live-bottom-sheet-backdrop"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) closeSheets() }}
|
||||
role="presentation"
|
||||
>
|
||||
<div className="live-bottom-sheet" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="live-bottom-sheet-handle" aria-hidden />
|
||||
<div className="live-bottom-sheet-header">
|
||||
<h3>{t('logs.live_more_btn')}</h3>
|
||||
<button type="button" className="btn-icon" onClick={closeSheets} aria-label={t('logs.live_cancel')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="live-overflow-menu-list">
|
||||
<button type="button" className="live-overflow-menu-item" onClick={() => runMore(onOpenFuel)} disabled={busy}>
|
||||
<Fuel size={18} />
|
||||
{t('logs.live_fuel_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-overflow-menu-item" onClick={() => runMore(onOpenWater)} disabled={busy}>
|
||||
<Droplets size={18} />
|
||||
{t('logs.live_water_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-overflow-menu-item" onClick={() => runMore(onFetchTides)} disabled={busy || tidesLoading}>
|
||||
<Waves size={18} />
|
||||
{tidesLoading ? t('logs.tide_fetch_loading') : t('logs.tides')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Pencil, Trash2, X } from 'lucide-react'
|
||||
import type { LogEventPayload } from '../utils/logEntryPayload.js'
|
||||
import { isValidTimeHHMM } from '../utils/logEntryPayload.js'
|
||||
|
||||
interface LiveEventSheetProps {
|
||||
open: boolean
|
||||
event: LogEventPayload | null
|
||||
eventIndex: number | null
|
||||
onClose: () => void
|
||||
onSave: (index: number, patch: Partial<LogEventPayload>) => Promise<void>
|
||||
onDelete: (index: number) => Promise<void>
|
||||
onOpenEditor?: () => void
|
||||
busy?: boolean
|
||||
}
|
||||
|
||||
export default function LiveEventSheet({
|
||||
open,
|
||||
event,
|
||||
eventIndex,
|
||||
onClose,
|
||||
onSave,
|
||||
onDelete,
|
||||
onOpenEditor,
|
||||
busy = false
|
||||
}: LiveEventSheetProps) {
|
||||
const { t } = useTranslation()
|
||||
const timeRef = useRef<HTMLInputElement>(null)
|
||||
const remarksRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !event) return
|
||||
if (timeRef.current) timeRef.current.value = event.time || ''
|
||||
if (remarksRef.current) remarksRef.current.value = event.remarks || ''
|
||||
}, [open, event])
|
||||
|
||||
if (!open || !event || eventIndex === null) return null
|
||||
|
||||
const handleSave = async () => {
|
||||
const time = timeRef.current?.value.trim() ?? ''
|
||||
const remarks = remarksRef.current?.value ?? ''
|
||||
if (!isValidTimeHHMM(time)) return
|
||||
await onSave(eventIndex, { time, remarks })
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
await onDelete(eventIndex)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="live-bottom-sheet-backdrop"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className="live-bottom-sheet"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="live-event-sheet-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="live-bottom-sheet-handle" aria-hidden />
|
||||
<div className="live-bottom-sheet-header">
|
||||
<h3 id="live-event-sheet-title">{t('logs.live_edit_event')}</h3>
|
||||
<button type="button" className="btn-icon" onClick={onClose} aria-label={t('logs.live_cancel')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="live-event-sheet-form">
|
||||
<label className="live-event-sheet-field">
|
||||
<span>{t('logs.event_time')}</span>
|
||||
<input
|
||||
ref={timeRef}
|
||||
type="text"
|
||||
className="input-text"
|
||||
inputMode="numeric"
|
||||
placeholder="HH:MM"
|
||||
maxLength={5}
|
||||
/>
|
||||
</label>
|
||||
<label className="live-event-sheet-field">
|
||||
<span>{t('logs.event_remarks')}</span>
|
||||
<textarea
|
||||
ref={remarksRef}
|
||||
className="input-text"
|
||||
rows={3}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="live-bottom-sheet-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={busy}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
{t('logs.save')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary danger-text"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={busy}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{t('logs.live_delete_event')}
|
||||
</button>
|
||||
{onOpenEditor && (
|
||||
<button type="button" className="btn secondary" onClick={onOpenEditor} disabled={busy}>
|
||||
{t('logs.live_open_editor')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,32 +2,21 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Anchor,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronUp,
|
||||
CloudSun,
|
||||
Compass,
|
||||
Droplets,
|
||||
FileText,
|
||||
Fuel,
|
||||
Gauge,
|
||||
MapPin,
|
||||
MessageSquare,
|
||||
Camera,
|
||||
Mic,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Radio,
|
||||
Sailboat,
|
||||
Undo2,
|
||||
Zap
|
||||
Undo2
|
||||
} from 'lucide-react'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { getAiAuthorized } from '../services/userPreferences.js'
|
||||
import {
|
||||
appendQuickEvent as apiAppendQuickEvent,
|
||||
appendQuickEvents as apiAppendQuickEvents,
|
||||
appendTankRefill as apiAppendTankRefill,
|
||||
findOrCreateTodayEntry,
|
||||
loadEntry,
|
||||
patchEntryTides,
|
||||
removeLastEvent
|
||||
} from '../services/quickEventLog.js'
|
||||
import CreatorAvatar from './CreatorAvatar.tsx'
|
||||
@@ -47,7 +36,8 @@ import {
|
||||
liveSogRemark,
|
||||
liveStwRemark,
|
||||
liveTempRemark,
|
||||
liveWaterRemark
|
||||
liveWaterRemark,
|
||||
parseLiveSailsRemark
|
||||
} from '../utils/liveEventCodes.js'
|
||||
import { formatAppDecimal, formatTankLiters, parseAppDecimal } from '../utils/numberFormat.js'
|
||||
|
||||
@@ -55,6 +45,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,
|
||||
@@ -84,11 +91,35 @@ import { saveEntryVoiceMemo, deleteEntryVoiceMemo } from '../services/voiceAttac
|
||||
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
||||
import { blobToAudioDataUrl } from '../utils/audioBlob.js'
|
||||
import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js'
|
||||
import { useEventEditor } from '../hooks/useEventEditor.js'
|
||||
import LiveActionToolbar from './LiveActionToolbar.tsx'
|
||||
import LiveEventSheet from './LiveEventSheet.tsx'
|
||||
import LiveOverflowMenu from './LiveOverflowMenu.tsx'
|
||||
|
||||
export interface LiveEntrySummary {
|
||||
id: string
|
||||
date: string
|
||||
dayOfTravel: string
|
||||
departure: string
|
||||
destination: string
|
||||
}
|
||||
|
||||
interface LiveLogViewProps {
|
||||
logbookId: string
|
||||
selectedEntryId?: string | null
|
||||
entrySummaries?: LiveEntrySummary[]
|
||||
todayEntryId?: string | null
|
||||
onEntryChange?: (entryId: string) => void
|
||||
onOpenEditor: (entryId: string) => void
|
||||
onSwitchToList: () => void
|
||||
onOpenAllDays: () => void
|
||||
onDeleteEntry?: (entryId: string) => void
|
||||
onDownloadCsv?: () => void
|
||||
onShareCsv?: () => void
|
||||
onDownloadPhotosZip?: () => void
|
||||
onDownloadPdf?: (entryId: string, date: string) => void
|
||||
exporting?: boolean
|
||||
canExportPhotosZip?: boolean
|
||||
hasLogbookEntries?: boolean
|
||||
}
|
||||
|
||||
type LiveModal =
|
||||
@@ -107,6 +138,8 @@ type LiveModal =
|
||||
| 'sog'
|
||||
| 'stw'
|
||||
| 'position'
|
||||
| 'tides'
|
||||
| 'tides_picker'
|
||||
| 'photo'
|
||||
| 'voice'
|
||||
|
||||
@@ -152,6 +185,14 @@ function lastWindDirectionFromEvents(events: LogEventPayload[]): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
function lastSailsFromEvents(events: LogEventPayload[]): string {
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const sails = parseLiveSailsRemark(events[i].remarks?.trim() ?? '')
|
||||
if (sails) return sails
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function gpsFailureAlertBody(
|
||||
t: (key: string) => string,
|
||||
reason: GeolocationErrorReason
|
||||
@@ -179,8 +220,20 @@ function findActiveCreatorId(
|
||||
|
||||
export default function LiveLogView({
|
||||
logbookId,
|
||||
selectedEntryId: selectedEntryIdProp,
|
||||
entrySummaries = [],
|
||||
todayEntryId,
|
||||
onEntryChange,
|
||||
onOpenEditor,
|
||||
onSwitchToList
|
||||
onOpenAllDays,
|
||||
onDeleteEntry,
|
||||
onDownloadCsv,
|
||||
onShareCsv,
|
||||
onDownloadPhotosZip,
|
||||
onDownloadPdf,
|
||||
exporting = false,
|
||||
canExportPhotosZip = false,
|
||||
hasLogbookEntries = true
|
||||
}: LiveLogViewProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { showAlert, showConfirm } = useDialog()
|
||||
@@ -189,6 +242,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)
|
||||
@@ -197,8 +252,19 @@ export default function LiveLogView({
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [modal, setModal] = useState<LiveModal>('none')
|
||||
const [weatherExpanded, setWeatherExpanded] = useState(false)
|
||||
const [overflowOpen, setOverflowOpen] = useState(false)
|
||||
const [editingEventIndex, setEditingEventIndex] = useState<number | null>(null)
|
||||
const [signSkipper, setSignSkipper] = useState('')
|
||||
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('')
|
||||
@@ -290,16 +356,33 @@ export default function LiveLogView({
|
||||
)
|
||||
const motorRunning = isMotorRunningFromEvents(events)
|
||||
const motorLabel = t('logs.motor_propulsion')
|
||||
const lastSailsLabel = lastSailsFromEvents(events)
|
||||
const hasLoggedPosition = useMemo(
|
||||
() => (date ? getLatestLoggedPosition(events, date) != null : false),
|
||||
[events, date]
|
||||
)
|
||||
const voiceMemoLookup = useEntryVoiceMemos(logbookId, entryId)
|
||||
|
||||
const { updateEvent, deleteEvent } = useEventEditor({
|
||||
logbookId,
|
||||
entryId,
|
||||
events,
|
||||
onEventsChange: setEvents,
|
||||
hasSkipperSignature: !!signSkipper
|
||||
})
|
||||
|
||||
const dayChips = useMemo(() => {
|
||||
const max = 5
|
||||
return entrySummaries.slice(0, max)
|
||||
}, [entrySummaries])
|
||||
|
||||
const applyLoadedEntry = useCallback((loaded: NonNullable<Awaited<ReturnType<typeof loadEntry>>>) => {
|
||||
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 || ''))
|
||||
setSignSkipper(typeof loaded.data.signSkipper === 'string' ? loaded.data.signSkipper : '')
|
||||
setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e }))))
|
||||
setCrewSnapshotsById((loaded.data.crewSnapshotsById as Record<string, any>) || {})
|
||||
setSelectedSkipperId(typeof loaded.data.selectedSkipperId === 'string' ? loaded.data.selectedSkipperId : null)
|
||||
@@ -323,7 +406,7 @@ export default function LiveLogView({
|
||||
}, UNDO_TIMEOUT_MS)
|
||||
}, [])
|
||||
|
||||
const runInit = useCallback(async () => {
|
||||
const runInit = useCallback(async (targetEntryId?: string | null) => {
|
||||
const seq = ++initSeqRef.current
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
@@ -338,11 +421,13 @@ export default function LiveLogView({
|
||||
}
|
||||
|
||||
try {
|
||||
const id = await withTimeout(
|
||||
findOrCreateTodayEntry(logbookId),
|
||||
LIVE_LOG_INIT_TIMEOUT_MS,
|
||||
t('logs.live_load_error')
|
||||
)
|
||||
const id = targetEntryId
|
||||
? targetEntryId
|
||||
: await withTimeout(
|
||||
findOrCreateTodayEntry(logbookId),
|
||||
LIVE_LOG_INIT_TIMEOUT_MS,
|
||||
t('logs.live_load_error')
|
||||
)
|
||||
if (seq !== initSeqRef.current) return
|
||||
setEntryId(id)
|
||||
|
||||
@@ -374,6 +459,14 @@ export default function LiveLogView({
|
||||
}
|
||||
}, [logbookId, applyLoadedEntry, t])
|
||||
|
||||
useEffect(() => {
|
||||
void runInit(selectedEntryIdProp ?? null)
|
||||
return () => {
|
||||
initSeqRef.current += 1
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- runInit
|
||||
}, [logbookId, selectedEntryIdProp])
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOnline(true)
|
||||
const handleOffline = () => setIsOnline(false)
|
||||
@@ -385,15 +478,6 @@ export default function LiveLogView({
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void runInit()
|
||||
return () => {
|
||||
initSeqRef.current += 1
|
||||
}
|
||||
// Only re-init when the logbook changes — not when i18n `t` identity changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- runInit
|
||||
}, [logbookId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && entryId) {
|
||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_OPENED)
|
||||
@@ -783,6 +867,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
|
||||
@@ -834,28 +1057,32 @@ export default function LiveLogView({
|
||||
void (async () => {
|
||||
try {
|
||||
const audioDataUrl = await blobToAudioDataUrl(blob)
|
||||
|
||||
const authorized = getAiAuthorized()
|
||||
let transcriptionText = ''
|
||||
let transcribed = true
|
||||
let transcriptionError = false
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 4000)
|
||||
if (authorized) {
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 4000)
|
||||
|
||||
const res = await fetch('/api/ai/transcribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ audioDataUrl }),
|
||||
signal: controller.signal
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
if (!res.ok) throw new Error(`Status ${res.status}`)
|
||||
const data = await res.json()
|
||||
transcriptionText = (data.text || '').trim()
|
||||
} catch (err) {
|
||||
console.warn('[LiveLogView] Automatic transcription failed or timed out:', err)
|
||||
transcriptionError = true
|
||||
const res = await fetch('/api/ai/transcribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ audioDataUrl }),
|
||||
signal: controller.signal
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
if (!res.ok) throw new Error(`Status ${res.status}`)
|
||||
const data = await res.json()
|
||||
transcriptionText = (data.text || '').trim()
|
||||
} catch (err) {
|
||||
console.warn('[LiveLogView] Automatic transcription failed or timed out:', err)
|
||||
transcriptionError = true
|
||||
transcribed = false
|
||||
}
|
||||
} else {
|
||||
transcribed = false
|
||||
}
|
||||
|
||||
@@ -885,9 +1112,22 @@ export default function LiveLogView({
|
||||
setVoiceCaption('')
|
||||
showUndo('voice')
|
||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'voice' })
|
||||
|
||||
if (transcriptionError) {
|
||||
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
|
||||
status: 'failed',
|
||||
mode: 'auto'
|
||||
})
|
||||
void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn'))
|
||||
} else if (authorized) {
|
||||
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
|
||||
status: 'success',
|
||||
mode: 'auto'
|
||||
})
|
||||
} else {
|
||||
void showAlert(
|
||||
t('profile.ai_unauthorized_alert_desc'),
|
||||
t('profile.ai_unauthorized_alert_title')
|
||||
)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error('Live log voice save failed:', err)
|
||||
@@ -1116,32 +1356,60 @@ export default function LiveLogView({
|
||||
return (
|
||||
<>
|
||||
<div className="live-log-card">
|
||||
<div className="section-title-bar mb-4">
|
||||
<div className="form-header" style={{ margin: 0 }}>
|
||||
<Radio size={24} className="form-icon" />
|
||||
<div className="live-log-header">
|
||||
<div className="live-log-header-main">
|
||||
<Radio size={24} className="form-icon" aria-hidden />
|
||||
<div>
|
||||
<h2>{t('logs.live_title')}</h2>
|
||||
{date && (
|
||||
<p className="live-log-subtitle">
|
||||
{t('logs.travel_day_number', { number: dayOfTravel })} · {new Date(date).toLocaleDateString()}
|
||||
{departure && destination && (
|
||||
<> · {departure} → {destination}</>
|
||||
)}
|
||||
{departure && !destination && (
|
||||
<> · {departure}</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="section-toolbar">
|
||||
<button type="button" className="btn secondary" onClick={onSwitchToList} style={{ width: 'auto', padding: '8px 16px' }}>
|
||||
<ChevronLeft size={16} />
|
||||
<span className="hide-mobile">{t('logs.view_list')}</span>
|
||||
</button>
|
||||
{entryId && (
|
||||
<button type="button" className="btn secondary" onClick={() => onOpenEditor(entryId)} style={{ width: 'auto', padding: '8px 16px' }}>
|
||||
<FileText size={16} />
|
||||
<span className="hide-mobile">{t('logs.live_open_editor')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon live-log-overflow-trigger"
|
||||
onClick={() => setOverflowOpen(true)}
|
||||
aria-label={t('logs.more_actions')}
|
||||
>
|
||||
<MoreVertical size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{dayChips.length > 0 && (
|
||||
<div className="live-day-chips" role="tablist" aria-label={t('logs.live_switch_day')}>
|
||||
{dayChips.map((chip) => {
|
||||
const isToday = todayEntryId ? chip.id === todayEntryId : false
|
||||
const isActive = chip.id === entryId
|
||||
return (
|
||||
<button
|
||||
key={chip.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
className={`live-day-chip ${isActive ? 'is-active' : ''}`}
|
||||
onClick={() => {
|
||||
if (chip.id !== entryId) onEntryChange?.(chip.id)
|
||||
}}
|
||||
>
|
||||
{isToday ? t('logs.live_day_today') : t('logs.travel_day_number', { number: chip.dayOfTravel })}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<button type="button" className="live-day-chip live-day-chip-all" onClick={onOpenAllDays}>
|
||||
{t('logs.all_days')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="auth-error mb-4">{error}</div>}
|
||||
|
||||
{!hasLoggedPosition && (
|
||||
@@ -1152,116 +1420,56 @@ export default function LiveLogView({
|
||||
)}
|
||||
|
||||
<div className="live-log-layout">
|
||||
<aside className="live-log-actions" aria-label={t('logs.live_actions_label')}>
|
||||
<button type="button" className={`live-log-action-btn ${motorRunning ? 'is-active' : ''}`} onClick={handleMotorToggle} disabled={busy}>
|
||||
<Zap size={18} />
|
||||
{motorRunning ? t('logs.live_motor_stop') : t('logs.live_motor_start')}
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn" onClick={handleCastOff} disabled={busy}>
|
||||
<Anchor size={18} />
|
||||
{t('logs.live_cast_off')}
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn" onClick={handleMoor} disabled={busy}>
|
||||
<Anchor size={18} style={{ transform: 'scaleX(-1)' }} />
|
||||
{t('logs.live_moor')}
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn" onClick={() => { setSelectedSails([]); setModal('sails') }} disabled={busy}>
|
||||
<Sailboat size={18} />
|
||||
{t('logs.live_sails_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn" onClick={() => openValueModal('course', lastCourseFromEvents(events))} disabled={busy}>
|
||||
<Compass size={18} />
|
||||
{t('logs.live_course_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn" onClick={() => void openSogModal()} disabled={busy}>
|
||||
<Gauge size={18} />
|
||||
{t('logs.live_sog_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn" onClick={() => openValueModal('stw')} disabled={busy}>
|
||||
<Gauge size={18} style={{ transform: 'scaleX(-1)' }} />
|
||||
{t('logs.live_stw_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn" onClick={() => openValueModal('fuel')} disabled={busy}>
|
||||
<Fuel size={18} />
|
||||
{t('logs.live_fuel_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn" onClick={() => openValueModal('water')} disabled={busy}>
|
||||
<Droplets size={18} />
|
||||
{t('logs.live_water_btn')}
|
||||
</button>
|
||||
<LiveActionToolbar
|
||||
motorRunning={motorRunning}
|
||||
hasLoggedPosition={hasLoggedPosition}
|
||||
lastSailsLabel={lastSailsLabel || undefined}
|
||||
busy={busy}
|
||||
photoSaving={photoSaving}
|
||||
voiceSaving={voiceSaving}
|
||||
tidesLoading={tidesLoading}
|
||||
weatherOwmLoading={weatherOwmLoading}
|
||||
onMotorToggle={handleMotorToggle}
|
||||
onCastOff={handleCastOff}
|
||||
onMoor={handleMoor}
|
||||
onOpenSails={() => { setSelectedSails([]); setModal('sails') }}
|
||||
onOpenCourse={() => openValueModal('course', lastCourseFromEvents(events))}
|
||||
onOpenSog={() => void openSogModal()}
|
||||
onOpenStw={() => openValueModal('stw')}
|
||||
onOpenFuel={() => openValueModal('fuel')}
|
||||
onOpenWater={() => openValueModal('water')}
|
||||
onOpenPosition={() => void openPositionModal()}
|
||||
onFetchTides={handleFetchTides}
|
||||
onOpenComment={() => { setCommentText(''); setModal('comment') }}
|
||||
onOpenPhoto={openPhotoModal}
|
||||
onOpenVoice={openVoiceModal}
|
||||
onFetchOwmWeather={handleFetchOwmWeather}
|
||||
onOpenWind={() => openValueModal('wind', lastWindDirectionFromEvents(events))}
|
||||
onOpenTemp={() => openValueModal('temp')}
|
||||
onOpenPressure={() => openValueModal('pressure')}
|
||||
onOpenPrecip={() => openValueModal('precip')}
|
||||
onOpenSeaState={() => openValueModal('sea_state')}
|
||||
onOpenVisibility={() => openValueModal('visibility')}
|
||||
/>
|
||||
|
||||
<div className="live-log-weather-group">
|
||||
<button
|
||||
type="button"
|
||||
className={`live-log-action-btn live-log-weather-toggle ${weatherExpanded ? 'is-expanded' : ''}`}
|
||||
onClick={() => setWeatherExpanded((prev) => !prev)}
|
||||
disabled={busy}
|
||||
aria-expanded={weatherExpanded}
|
||||
>
|
||||
<CloudSun size={18} />
|
||||
{t('logs.live_weather_btn')}
|
||||
{weatherExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
</button>
|
||||
{weatherExpanded && (
|
||||
<div className="live-log-weather-submenu">
|
||||
<button
|
||||
type="button"
|
||||
className="live-log-subaction-btn live-log-subaction-btn-owm"
|
||||
onClick={handleFetchOwmWeather}
|
||||
disabled={busy || weatherOwmLoading}
|
||||
aria-busy={busy || weatherOwmLoading}
|
||||
>
|
||||
{weatherOwmLoading ? t('logs.live_weather_owm_loading') : t('logs.live_weather_owm_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('wind', lastWindDirectionFromEvents(events))} disabled={busy}>
|
||||
{t('logs.live_wind_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('temp')} disabled={busy}>
|
||||
{t('logs.live_temp_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('pressure')} disabled={busy}>
|
||||
{t('logs.live_pressure_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('precip')} disabled={busy}>
|
||||
{t('logs.live_precip_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('sea_state')} disabled={busy}>
|
||||
{t('logs.live_sea_state_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('visibility')} disabled={busy}>
|
||||
{t('logs.live_visibility_btn')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button type="button" className="live-log-action-btn" onClick={() => void openPositionModal()} disabled={busy}>
|
||||
<MapPin size={18} />
|
||||
{t('logs.live_position')}
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn" onClick={() => { setCommentText(''); setModal('comment') }} disabled={busy}>
|
||||
<MessageSquare size={18} />
|
||||
{t('logs.live_comment_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn" onClick={openPhotoModal} disabled={busy || photoSaving}>
|
||||
<Camera size={18} />
|
||||
{t('logs.live_photo_btn')}
|
||||
</button>
|
||||
<button type="button" className="live-log-action-btn" onClick={openVoiceModal} disabled={busy || voiceSaving}>
|
||||
<Mic size={18} />
|
||||
{t('logs.live_voice_btn')}
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<section className="live-log-stream-panel" aria-label={t('logs.live_stream_label')}>
|
||||
<section className="live-log-stream-panel" aria-label={t('logs.live_stream_label')} data-tour="entry-list">
|
||||
<h3 className="live-log-stream-title">{t('logs.live_stream_title')}</h3>
|
||||
{events.length === 0 ? (
|
||||
<p className="live-log-empty">{t('logs.live_no_events')}</p>
|
||||
) : (
|
||||
<ol className="live-log-stream">
|
||||
{events.map((event, index) => {
|
||||
return (
|
||||
<li key={`${event.time}-${index}`} className="live-log-entry">
|
||||
{events.map((event, index) => (
|
||||
<li
|
||||
key={`${event.time}-${index}`}
|
||||
className="live-log-entry"
|
||||
data-tour={index === 0 ? 'entry-first' : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="live-log-entry-tap"
|
||||
onClick={() => setEditingEventIndex(index)}
|
||||
aria-label={t('logs.live_edit_event')}
|
||||
>
|
||||
<time className="live-log-time">{event.time}</time>
|
||||
<CreatorAvatar
|
||||
creatorId={event.creatorId}
|
||||
@@ -1276,9 +1484,18 @@ export default function LiveLogView({
|
||||
readOnly={false}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon live-log-entry-edit"
|
||||
onClick={() => setEditingEventIndex(index)}
|
||||
title={t('logs.live_edit_event')}
|
||||
aria-label={t('logs.live_edit_event')}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
<div ref={streamEndRef} />
|
||||
</ol>
|
||||
)}
|
||||
@@ -1286,6 +1503,33 @@ export default function LiveLogView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LiveOverflowMenu
|
||||
open={overflowOpen}
|
||||
onClose={() => setOverflowOpen(false)}
|
||||
onOpenAllDays={onOpenAllDays}
|
||||
onOpenEditor={() => entryId && onOpenEditor(entryId)}
|
||||
onDownloadCsv={() => onDownloadCsv?.()}
|
||||
onShareCsv={() => onShareCsv?.()}
|
||||
onDownloadPhotosZip={onDownloadPhotosZip ? () => onDownloadPhotosZip() : undefined}
|
||||
onDownloadPdf={() => entryId && date && onDownloadPdf?.(entryId, date)}
|
||||
onDeleteEntry={onDeleteEntry && entryId ? () => onDeleteEntry(entryId) : undefined}
|
||||
exporting={exporting}
|
||||
canExportPhotosZip={canExportPhotosZip}
|
||||
hasEntries={hasLogbookEntries}
|
||||
entryId={entryId}
|
||||
/>
|
||||
|
||||
<LiveEventSheet
|
||||
open={editingEventIndex !== null}
|
||||
event={editingEventIndex !== null ? events[editingEventIndex] ?? null : null}
|
||||
eventIndex={editingEventIndex}
|
||||
onClose={() => setEditingEventIndex(null)}
|
||||
onSave={updateEvent}
|
||||
onDelete={deleteEvent}
|
||||
onOpenEditor={entryId ? () => onOpenEditor(entryId) : undefined}
|
||||
busy={busy}
|
||||
/>
|
||||
|
||||
{((undoVisible && events.length > 0) || modal !== 'none') && createPortal(
|
||||
<>
|
||||
{undoVisible && events.length > 0 && (
|
||||
@@ -1437,6 +1681,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()}>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Calendar,
|
||||
Download,
|
||||
FileText,
|
||||
List,
|
||||
Share2,
|
||||
Trash2,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
|
||||
interface LiveOverflowMenuProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onOpenAllDays: () => void
|
||||
onOpenEditor: () => void
|
||||
onDownloadCsv: () => void
|
||||
onShareCsv: () => void
|
||||
onDownloadPhotosZip?: () => void
|
||||
onDownloadPdf: () => void
|
||||
onDeleteEntry?: () => void
|
||||
exporting?: boolean
|
||||
canExportPhotosZip?: boolean
|
||||
hasEntries?: boolean
|
||||
entryId?: string | null
|
||||
}
|
||||
|
||||
export default function LiveOverflowMenu({
|
||||
open,
|
||||
onClose,
|
||||
onOpenAllDays,
|
||||
onOpenEditor,
|
||||
onDownloadCsv,
|
||||
onShareCsv,
|
||||
onDownloadPhotosZip,
|
||||
onDownloadPdf,
|
||||
onDeleteEntry,
|
||||
exporting = false,
|
||||
canExportPhotosZip = false,
|
||||
hasEntries = true,
|
||||
entryId
|
||||
}: LiveOverflowMenuProps) {
|
||||
const { t } = useTranslation()
|
||||
const firstBtnRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) firstBtnRef.current?.focus()
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const disabled = exporting || !hasEntries
|
||||
|
||||
const run = (fn: () => void) => {
|
||||
fn()
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="live-bottom-sheet-backdrop"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className="live-bottom-sheet live-overflow-sheet"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="live-overflow-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="live-bottom-sheet-handle" aria-hidden />
|
||||
<div className="live-bottom-sheet-header">
|
||||
<h3 id="live-overflow-title">{t('logs.more_actions')}</h3>
|
||||
<button type="button" className="btn-icon" onClick={onClose} aria-label={t('logs.live_cancel')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="live-overflow-menu-list">
|
||||
<button
|
||||
ref={firstBtnRef}
|
||||
type="button"
|
||||
className="live-overflow-menu-item"
|
||||
onClick={() => run(onOpenAllDays)}
|
||||
>
|
||||
<List size={18} />
|
||||
{t('logs.all_days')}
|
||||
</button>
|
||||
|
||||
{entryId && (
|
||||
<button
|
||||
type="button"
|
||||
className="live-overflow-menu-item"
|
||||
onClick={() => run(onOpenEditor)}
|
||||
>
|
||||
<FileText size={18} />
|
||||
{t('logs.live_open_editor')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="live-overflow-menu-item"
|
||||
onClick={() => run(onDownloadCsv)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Download size={18} />
|
||||
{exporting ? t('logs.exporting') : t('logs.export_csv')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="live-overflow-menu-item"
|
||||
onClick={() => run(onShareCsv)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Share2 size={18} />
|
||||
{t('logs.share_csv')}
|
||||
</button>
|
||||
|
||||
{canExportPhotosZip && onDownloadPhotosZip && (
|
||||
<button
|
||||
type="button"
|
||||
className="live-overflow-menu-item"
|
||||
onClick={() => run(onDownloadPhotosZip)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Download size={18} />
|
||||
{exporting ? t('logs.exporting_photos_zip') : t('logs.export_photos_zip')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{entryId && (
|
||||
<button
|
||||
type="button"
|
||||
className="live-overflow-menu-item"
|
||||
onClick={() => run(onDownloadPdf)}
|
||||
disabled={exporting}
|
||||
>
|
||||
<Calendar size={18} />
|
||||
{t('logs.export_pdf')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onDeleteEntry && entryId && (
|
||||
<button
|
||||
type="button"
|
||||
className="live-overflow-menu-item live-overflow-menu-item-danger"
|
||||
onClick={() => run(onDeleteEntry)}
|
||||
disabled={exporting}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
{t('logs.delete_entry')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,14 +5,18 @@ import { getActiveMasterKey } from '../services/auth.js'
|
||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||
import { encryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { getErrorMessage } from '../utils/errors.js'
|
||||
import { findTodayEntryId, pruneEmptyTodayDuplicates, tryDecryptEntryPayload } from '../services/quickEventLog.js'
|
||||
import { localDateString } from '../utils/logEntryPayload.js'
|
||||
import {
|
||||
getLogsViewModePreference,
|
||||
setLogsViewModePreference,
|
||||
getActiveUserId
|
||||
} from '../services/userPreferences.js'
|
||||
import { useLogExport } from '../hooks/useLogExport.js'
|
||||
import LogEntryEditor from './LogEntryEditor.tsx'
|
||||
import LiveLogView from './LiveLogView.tsx'
|
||||
import LiveLogView, { type LiveEntrySummary } from './LiveLogView.tsx'
|
||||
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
|
||||
@@ -22,7 +26,7 @@ import {
|
||||
putEntryRecord
|
||||
} from '../utils/entryListCache.js'
|
||||
import { forEachInBatches } from '../utils/yieldToMain.js'
|
||||
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react'
|
||||
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, ChevronLeft } from 'lucide-react'
|
||||
import {
|
||||
carryOverFromPreviousDay,
|
||||
compareTravelDaysChronological,
|
||||
@@ -87,12 +91,39 @@ export default function LogEntriesList({
|
||||
}
|
||||
}
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [viewMode, setViewMode] = useState<LogsViewMode>('list')
|
||||
const [viewMode, setViewModeState] = useState<LogsViewMode>(() =>
|
||||
readOnly ? 'list' : getLogsViewModePreference(getActiveUserId())
|
||||
)
|
||||
const [liveSelectedEntryId, setLiveSelectedEntryId] = useState<string | null>(null)
|
||||
const [todayEntryId, setTodayEntryId] = useState<string | null>(null)
|
||||
const [returnToLiveAfterEditor, setReturnToLiveAfterEditor] = useState(false)
|
||||
const prevSelectedEntryIdRef = useRef<string | null | undefined>(undefined)
|
||||
|
||||
const setViewMode = useCallback((mode: LogsViewMode) => {
|
||||
setViewModeState(mode)
|
||||
if (!readOnly) {
|
||||
const userId = getActiveUserId()
|
||||
if (userId) setLogsViewModePreference(userId, mode)
|
||||
}
|
||||
}, [readOnly])
|
||||
|
||||
const {
|
||||
exporting: exportBusy,
|
||||
error: exportError,
|
||||
handleDownloadCsv,
|
||||
handleShareCsv,
|
||||
handleDownloadPdf,
|
||||
handleDownloadPhotosZip,
|
||||
canExportPhotosZip
|
||||
} = useLogExport({
|
||||
logbookId,
|
||||
entries,
|
||||
readOnly,
|
||||
preloadedYacht,
|
||||
preloadedEntries
|
||||
})
|
||||
|
||||
const loadEntries = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
@@ -124,9 +155,10 @@ export default function LogEntriesList({
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const todayEntryId = await findTodayEntryId(logbookId)
|
||||
if (todayEntryId) {
|
||||
await pruneEmptyTodayDuplicates(logbookId, todayEntryId)
|
||||
const todayId = await findTodayEntryId(logbookId)
|
||||
setTodayEntryId(todayId)
|
||||
if (todayId) {
|
||||
await pruneEmptyTodayDuplicates(logbookId, todayId)
|
||||
}
|
||||
|
||||
const local = await db.entries.where({ logbookId }).toArray()
|
||||
@@ -175,9 +207,8 @@ export default function LogEntriesList({
|
||||
}, [logbookId, readOnly, preloadedEntries])
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === 'live') return
|
||||
loadEntries()
|
||||
}, [loadEntries, viewMode])
|
||||
}, [loadEntries])
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === 'live') return
|
||||
@@ -189,74 +220,6 @@ export default function LogEntriesList({
|
||||
}
|
||||
}, [selectedEntryId, loadEntries, viewMode])
|
||||
|
||||
const handleDownloadCsv = async () => {
|
||||
setExporting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const title = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook'
|
||||
if (readOnly && preloadedEntries && preloadedYacht) {
|
||||
await downloadCsv(logbookId, title, { yacht: preloadedYacht, entries: preloadedEntries })
|
||||
} else {
|
||||
await downloadCsv(logbookId, title)
|
||||
}
|
||||
trackPlausibleEvent(PlausibleEvents.CSV_EXPORTED)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to download CSV:', err)
|
||||
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleShareCsv = async () => {
|
||||
setExporting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const title = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook'
|
||||
if (readOnly && preloadedEntries && preloadedYacht) {
|
||||
await shareCsv(logbookId, title, { yacht: preloadedYacht, entries: preloadedEntries })
|
||||
} else {
|
||||
await shareCsv(logbookId, title)
|
||||
}
|
||||
trackPlausibleEvent(PlausibleEvents.CSV_SHARED)
|
||||
} catch (err: any) {
|
||||
if (err.message === 'share_unsupported') {
|
||||
const title = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook'
|
||||
if (readOnly && preloadedEntries && preloadedYacht) {
|
||||
await downloadCsv(logbookId, title, { yacht: preloadedYacht, entries: preloadedEntries })
|
||||
} else {
|
||||
await downloadCsv(logbookId, title)
|
||||
}
|
||||
setError(t('logs.share_unsupported'))
|
||||
} else {
|
||||
console.error('Failed to share CSV:', err)
|
||||
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||
}
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadPdf = async (entryId: string, date: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setExporting(true)
|
||||
setError(null)
|
||||
try {
|
||||
if (readOnly && preloadedEntries && preloadedYacht) {
|
||||
const fullEntry = preloadedEntries.find(entry => (entry.payloadId || entry.id) === entryId)
|
||||
await downloadLogbookPagePdf(logbookId, entryId, date, { yacht: preloadedYacht, entry: fullEntry })
|
||||
} else {
|
||||
await downloadLogbookPagePdf(logbookId, entryId, date)
|
||||
}
|
||||
trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
|
||||
} catch (err: any) {
|
||||
console.error('Failed to download PDF:', err)
|
||||
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (readOnly) return
|
||||
setError(null)
|
||||
@@ -367,17 +330,13 @@ export default function LogEntriesList({
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (entryId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const handleDeleteEntry = async (entryId: string) => {
|
||||
if (readOnly) return
|
||||
|
||||
if (await showConfirm(t('logs.delete_confirm'), t('logs.delete_entry'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
|
||||
setError(null)
|
||||
try {
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.entries.delete(entryId)
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'delete',
|
||||
type: 'entry',
|
||||
@@ -386,16 +345,33 @@ export default function LogEntriesList({
|
||||
data: '',
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
setEntries((prev) => prev.filter((item) => item.id !== entryId))
|
||||
if (liveSelectedEntryId === entryId) {
|
||||
setLiveSelectedEntryId(null)
|
||||
}
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to delete log entry:', err)
|
||||
setError(getErrorMessage(err, t('errors.delete_failed')))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (entryId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
await handleDeleteEntry(entryId)
|
||||
}
|
||||
|
||||
const entrySummaries: LiveEntrySummary[] = entries.map((e) => ({
|
||||
id: e.id,
|
||||
date: e.date,
|
||||
dayOfTravel: e.dayOfTravel,
|
||||
departure: e.departure,
|
||||
destination: e.destination
|
||||
}))
|
||||
|
||||
const combinedError = error || exportError
|
||||
|
||||
if (selectedEntryId) {
|
||||
return (
|
||||
<LogEntryEditor
|
||||
@@ -419,17 +395,32 @@ export default function LogEntriesList({
|
||||
|
||||
if (viewMode === 'live' && !readOnly) {
|
||||
return (
|
||||
<LiveLogView
|
||||
logbookId={logbookId}
|
||||
onOpenEditor={(entryId) => {
|
||||
setReturnToLiveAfterEditor(true)
|
||||
setSelectedEntryId(entryId)
|
||||
}}
|
||||
onSwitchToList={() => {
|
||||
setViewMode('list')
|
||||
void loadEntries()
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
{combinedError && <div className="auth-error mb-4" style={{ margin: '0 0 12px' }}>{combinedError}</div>}
|
||||
<LiveLogView
|
||||
logbookId={logbookId}
|
||||
selectedEntryId={liveSelectedEntryId}
|
||||
entrySummaries={entrySummaries}
|
||||
todayEntryId={todayEntryId}
|
||||
onEntryChange={setLiveSelectedEntryId}
|
||||
onOpenEditor={(entryId) => {
|
||||
setReturnToLiveAfterEditor(true)
|
||||
setSelectedEntryId(entryId)
|
||||
}}
|
||||
onOpenAllDays={() => {
|
||||
setViewMode('list')
|
||||
void loadEntries()
|
||||
}}
|
||||
onDeleteEntry={handleDeleteEntry}
|
||||
onDownloadCsv={() => void handleDownloadCsv()}
|
||||
onShareCsv={() => void handleShareCsv()}
|
||||
onDownloadPhotosZip={() => void handleDownloadPhotosZip()}
|
||||
onDownloadPdf={(entryId, date) => void handleDownloadPdf(entryId, date)}
|
||||
exporting={exportBusy}
|
||||
canExportPhotosZip={canExportPhotosZip}
|
||||
hasLogbookEntries={entries.length > 0}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -456,40 +447,46 @@ export default function LogEntriesList({
|
||||
</div>
|
||||
<div className="section-toolbar">
|
||||
{!readOnly && (
|
||||
<div className="logs-view-toggle" role="group" aria-label={t('logs.view_mode_label')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn secondary logs-view-toggle-btn ${viewMode === 'list' ? 'is-active' : ''}`}
|
||||
onClick={() => setViewMode('list')}
|
||||
title={t('logs.view_list')}
|
||||
>
|
||||
<List size={16} />
|
||||
<span className="hide-mobile">{t('logs.view_list')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn secondary logs-view-toggle-btn ${viewMode === 'live' ? 'is-active' : ''}`}
|
||||
onClick={() => setViewMode('live')}
|
||||
title={t('logs.live_mode')}
|
||||
>
|
||||
<Radio size={16} />
|
||||
<span className="hide-mobile">{t('logs.live_mode')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => setViewMode('live')}
|
||||
style={{ width: 'auto', padding: '8px 16px' }}
|
||||
title={t('logs.live_mode')}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
<Radio size={16} />
|
||||
<span className="hide-mobile">{t('logs.back_to_live')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button className="btn secondary" onClick={handleDownloadCsv} disabled={loading || exporting || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.export_csv')}>
|
||||
<button className="btn secondary" onClick={() => void handleDownloadCsv()} disabled={loading || exportBusy || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.export_csv')}>
|
||||
<Download size={16} />
|
||||
<span className="hide-mobile">{exporting ? t('logs.exporting') : t('logs.export_csv')}</span>
|
||||
<span className="hide-mobile">{exportBusy ? t('logs.exporting') : t('logs.export_csv')}</span>
|
||||
</button>
|
||||
|
||||
<button className="btn secondary" onClick={handleShareCsv} disabled={loading || exporting || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.share_csv')}>
|
||||
<button className="btn secondary" onClick={() => void handleShareCsv()} disabled={loading || exportBusy || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.share_csv')}>
|
||||
<Share2 size={16} />
|
||||
<span className="hide-mobile">{t('logs.share_csv')}</span>
|
||||
</button>
|
||||
|
||||
{canExportPhotosZip && (
|
||||
<button
|
||||
className="btn secondary"
|
||||
onClick={() => void handleDownloadPhotosZip()}
|
||||
disabled={loading || exportBusy || entries.length === 0}
|
||||
style={{ width: 'auto', padding: '8px 16px' }}
|
||||
title={t('logs.export_photos_zip')}
|
||||
>
|
||||
<Download size={16} />
|
||||
<span className="hide-mobile">
|
||||
{exportBusy ? 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')}>
|
||||
<button className="btn primary" onClick={handleCreate} disabled={loading || exportBusy} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.new_entry')}>
|
||||
<Plus size={16} />
|
||||
<span className="hide-mobile">{t('logs.new_entry')}</span>
|
||||
</button>
|
||||
@@ -497,7 +494,7 @@ export default function LogEntriesList({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="auth-error mb-4">{error}</div>}
|
||||
{combinedError && <div className="auth-error mb-4">{combinedError}</div>}
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<div className="dashboard-status-msg">{t('logs.no_entries')}</div>
|
||||
@@ -512,7 +509,14 @@ export default function LogEntriesList({
|
||||
<button
|
||||
type="button"
|
||||
className="logbook-card-select"
|
||||
onClick={() => setSelectedEntryId(item.id)}
|
||||
onClick={() => {
|
||||
if (readOnly) {
|
||||
setSelectedEntryId(item.id)
|
||||
} else {
|
||||
setLiveSelectedEntryId(item.id)
|
||||
setViewMode('live')
|
||||
}
|
||||
}}
|
||||
aria-label={
|
||||
item.departure && item.destination
|
||||
? `${item.departure} → ${item.destination}, ${t('logs.travel_day_number', { number: item.dayOfTravel })}`
|
||||
@@ -541,17 +545,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) => { e.stopPropagation(); void handleDownloadPdf(item.id, item.date) }} title={t('logs.export_pdf')} disabled={exportBusy}>
|
||||
<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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { db } from '../services/db.js'
|
||||
import { getActiveMasterKey } from '../services/auth.js'
|
||||
@@ -8,7 +9,8 @@ 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, Trash2 } from 'lucide-react'
|
||||
import { Camera, Image, Trash2, X, ChevronDown, ChevronUp, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { probeCameraAvailability } from '../utils/cameraAvailability.js'
|
||||
|
||||
interface PhotoCaptureProps {
|
||||
entryId: string
|
||||
@@ -27,12 +29,87 @@ 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)
|
||||
const [decryptedPhotos, setDecryptedPhotos] = useState<DecryptedPhoto[]>([])
|
||||
const [hasCamera, setHasCamera] = useState(false)
|
||||
const [maximizedPhoto, setMaximizedPhoto] = useState<DecryptedPhoto | null>(null)
|
||||
|
||||
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
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [maximizedPhoto, decryptedPhotos])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
probeCameraAvailability().then((avail) => {
|
||||
if (!cancelled) {
|
||||
setHasCamera(avail === 'available')
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Reactively query local photos database
|
||||
const localPhotos = useLiveQuery(
|
||||
@@ -119,93 +196,231 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
||||
}
|
||||
}
|
||||
|
||||
const triggerSelect = () => {
|
||||
const triggerGallerySelect = () => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click()
|
||||
}
|
||||
}
|
||||
|
||||
const triggerCameraSelect = () => {
|
||||
if (cameraInputRef.current) {
|
||||
cameraInputRef.current.click()
|
||||
}
|
||||
}
|
||||
|
||||
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' }}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={triggerSelect}
|
||||
disabled={uploading}
|
||||
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||
>
|
||||
{uploading ? (
|
||||
<span className="spin">⏳</span>
|
||||
) : (
|
||||
<Camera size={16} />
|
||||
)}
|
||||
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
ref={cameraInputRef}
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
{/* 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">
|
||||
<div className="photo-container">
|
||||
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
|
||||
{!readOnly && (
|
||||
{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="photo-btn-delete"
|
||||
onClick={() => 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>
|
||||
)}
|
||||
|
||||
{maximizedPhoto && createPortal(
|
||||
<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"
|
||||
className="photo-maximized-close"
|
||||
onClick={() => setMaximizedPhoto(null)}
|
||||
aria-label={t('common.close') || 'Close'}
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
<img
|
||||
src={maximizedPhoto.image}
|
||||
alt={maximizedPhoto.caption || 'Maximized Attachment'}
|
||||
className="photo-maximized-img"
|
||||
/>
|
||||
{maximizedPhoto.caption && (
|
||||
<div className="photo-maximized-caption">
|
||||
{maximizedPhoto.caption}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Compass, Palette, Save, Check, Cloud } from 'lucide-react'
|
||||
import { Compass, Palette, Save, Check, Cloud, Brain } from 'lucide-react'
|
||||
import ThemedSelect from './ThemedSelect.tsx'
|
||||
import PushNotificationSettings from './PushNotificationSettings.tsx'
|
||||
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
getThemePreference,
|
||||
setColorSchemePreference,
|
||||
setOwmApiKey,
|
||||
setThemePreference
|
||||
setThemePreference,
|
||||
getAiAuthorized,
|
||||
setAiAuthorized
|
||||
} from '../services/userPreferences.js'
|
||||
|
||||
interface UserProfilePreferencesProps {
|
||||
@@ -28,12 +30,25 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
|
||||
const [colorScheme, setColorScheme] = useState(() => getColorSchemePreference(userId))
|
||||
const [savingOwm, setSavingOwm] = useState(false)
|
||||
const [owmSaved, setOwmSaved] = useState(false)
|
||||
const [aiAuthorized, setAiAuthorizedState] = useState(() => getAiAuthorized(userId))
|
||||
|
||||
useEffect(() => {
|
||||
const handleChanged = () => {
|
||||
setTheme(getThemePreference(userId))
|
||||
setColorScheme(getColorSchemePreference(userId))
|
||||
setAiAuthorizedState(getAiAuthorized(userId))
|
||||
}
|
||||
window.addEventListener('appearance-changed', handleChanged)
|
||||
return () => {
|
||||
window.removeEventListener('appearance-changed', handleChanged)
|
||||
}
|
||||
}, [userId])
|
||||
|
||||
const persistAppearance = (nextTheme: string, nextColorScheme: string) => {
|
||||
setThemePreference(userId, nextTheme)
|
||||
setColorSchemePreference(userId, nextColorScheme)
|
||||
notifyAppearanceChanged()
|
||||
void saveAppearancePrefsToServer(nextTheme, nextColorScheme).catch((err) => {
|
||||
void saveAppearancePrefsToServer(nextTheme, nextColorScheme, aiAuthorized, userId).catch((err) => {
|
||||
console.warn('Failed to save appearance prefs to server:', err)
|
||||
})
|
||||
}
|
||||
@@ -58,6 +73,15 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
|
||||
window.setTimeout(() => setOwmSaved(false), 3000)
|
||||
}
|
||||
|
||||
const handleAiToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const nextVal = e.target.checked
|
||||
setAiAuthorizedState(nextVal)
|
||||
setAiAuthorized(userId, nextVal)
|
||||
void saveAppearancePrefsToServer(theme, colorScheme, nextVal, userId).catch((err) => {
|
||||
console.warn('Failed to save ai preference to server:', err)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="member-editor-card glass">
|
||||
@@ -152,6 +176,42 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="member-editor-card glass">
|
||||
<div className="profile-section-header">
|
||||
<Brain size={20} style={{ color: 'var(--app-accent-light)' }} />
|
||||
<h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}>
|
||||
{t('profile.ai_title')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 12px 0' }}>
|
||||
{t('profile.ai_desc')}
|
||||
</p>
|
||||
<p className="text-muted" style={{ fontSize: '13px', lineHeight: '145%', margin: '0 0 16px 0', whiteSpace: 'pre-line' }}>
|
||||
{t('profile.ai_help')}
|
||||
</p>
|
||||
|
||||
<label
|
||||
className="switch-label"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: '#f1f5f9'
|
||||
}}
|
||||
>
|
||||
<input
|
||||
id="profile-ai-authorize"
|
||||
type="checkbox"
|
||||
checked={aiAuthorized}
|
||||
onChange={handleAiToggle}
|
||||
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
|
||||
/>
|
||||
<span>{t('profile.ai_enable_label')}</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<PushNotificationSettings />
|
||||
<PwaInstallPrompt variant="inline" />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { patchEntryEvents } from '../services/quickEventLog.js'
|
||||
import { deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
|
||||
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
|
||||
import { normalizeLogEvent, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||
import { useDialog } from '../components/ModalDialog.tsx'
|
||||
|
||||
interface UseEventEditorOptions {
|
||||
logbookId: string
|
||||
entryId: string | null
|
||||
events: LogEventPayload[]
|
||||
onEventsChange: (events: LogEventPayload[]) => void
|
||||
hasSkipperSignature?: boolean
|
||||
}
|
||||
|
||||
export function useEventEditor({
|
||||
logbookId,
|
||||
entryId,
|
||||
events,
|
||||
onEventsChange,
|
||||
hasSkipperSignature = false
|
||||
}: UseEventEditorOptions) {
|
||||
const { t } = useTranslation()
|
||||
const { showAlert, showConfirm } = useDialog()
|
||||
|
||||
const persistEvents = useCallback(async (nextEvents: LogEventPayload[]) => {
|
||||
if (!entryId) throw new Error('No entry selected')
|
||||
const { hadSignature } = await patchEntryEvents(logbookId, entryId, nextEvents)
|
||||
onEventsChange(nextEvents)
|
||||
if (hadSignature || hasSkipperSignature) {
|
||||
void showAlert(
|
||||
t('logs.sign_cleared_skipper_re_sign'),
|
||||
t('logs.sign_cleared_skipper_re_sign_title')
|
||||
)
|
||||
}
|
||||
}, [entryId, hasSkipperSignature, logbookId, onEventsChange, showAlert, t])
|
||||
|
||||
const updateEvent = useCallback(async (index: number, patch: Partial<LogEventPayload>) => {
|
||||
if (!entryId) return
|
||||
const current = events[index]
|
||||
if (!current) return
|
||||
const updated = normalizeLogEvent({ ...current, ...patch })
|
||||
const nextEvents = events.map((ev, idx) => (idx === index ? updated : ev))
|
||||
await persistEvents(nextEvents)
|
||||
}, [entryId, events, persistEvents])
|
||||
|
||||
const deleteEvent = useCallback(async (index: number) => {
|
||||
if (!entryId) return
|
||||
const confirmed = await showConfirm(
|
||||
t('logs.delete_event_confirm'),
|
||||
t('logs.delete_event'),
|
||||
t('logs.confirm_yes'),
|
||||
t('logs.confirm_no')
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
const voiceId = parseLiveVoiceRemark(events[index]?.remarks?.trim() ?? '')
|
||||
const nextEvents = events.filter((_, idx) => idx !== index)
|
||||
if (voiceId) {
|
||||
await deleteEntryVoiceMemo(logbookId, voiceId)
|
||||
}
|
||||
await persistEvents(nextEvents)
|
||||
}, [entryId, events, logbookId, persistEvents, showConfirm, t])
|
||||
|
||||
return { updateEvent, deleteEvent }
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { db } from '../services/db.js'
|
||||
import { getActiveMasterKey, hasUnlockedLocalCrypto } from '../services/auth.js'
|
||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||
import { decryptJson } from '../services/crypto.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'
|
||||
|
||||
export interface LogExportEntryItem {
|
||||
id: string
|
||||
date: string
|
||||
dayOfTravel: string
|
||||
departure: string
|
||||
destination: string
|
||||
}
|
||||
|
||||
interface UseLogExportOptions {
|
||||
logbookId: string
|
||||
entries: LogExportEntryItem[]
|
||||
readOnly?: boolean
|
||||
preloadedYacht?: { name?: string } | null
|
||||
preloadedEntries?: any[]
|
||||
}
|
||||
|
||||
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 function useLogExport({
|
||||
logbookId,
|
||||
entries,
|
||||
readOnly = false,
|
||||
preloadedYacht,
|
||||
preloadedEntries
|
||||
}: UseLogExportOptions) {
|
||||
const { t } = useTranslation()
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const logbookTitle =
|
||||
preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook'
|
||||
|
||||
const handleDownloadCsv = useCallback(async () => {
|
||||
setExporting(true)
|
||||
setError(null)
|
||||
try {
|
||||
if (readOnly && preloadedEntries && preloadedYacht) {
|
||||
await downloadCsv(logbookId, logbookTitle, { yacht: preloadedYacht, entries: preloadedEntries })
|
||||
} else {
|
||||
await downloadCsv(logbookId, logbookTitle)
|
||||
}
|
||||
trackPlausibleEvent(PlausibleEvents.CSV_EXPORTED)
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to download CSV:', err)
|
||||
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}, [logbookId, logbookTitle, preloadedEntries, preloadedYacht, readOnly, t])
|
||||
|
||||
const handleShareCsv = useCallback(async () => {
|
||||
setExporting(true)
|
||||
setError(null)
|
||||
try {
|
||||
if (readOnly && preloadedEntries && preloadedYacht) {
|
||||
await shareCsv(logbookId, logbookTitle, { yacht: preloadedYacht, entries: preloadedEntries })
|
||||
} else {
|
||||
await shareCsv(logbookId, logbookTitle)
|
||||
}
|
||||
trackPlausibleEvent(PlausibleEvents.CSV_SHARED)
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.message === 'share_unsupported') {
|
||||
if (readOnly && preloadedEntries && preloadedYacht) {
|
||||
await downloadCsv(logbookId, logbookTitle, { yacht: preloadedYacht, entries: preloadedEntries })
|
||||
} else {
|
||||
await downloadCsv(logbookId, logbookTitle)
|
||||
}
|
||||
setError(t('logs.share_unsupported'))
|
||||
} else {
|
||||
console.error('Failed to share CSV:', err)
|
||||
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||
}
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}, [logbookId, logbookTitle, preloadedEntries, preloadedYacht, readOnly, t])
|
||||
|
||||
const handleDownloadPdf = useCallback(async (entryId: string, date: string) => {
|
||||
setExporting(true)
|
||||
setError(null)
|
||||
try {
|
||||
if (readOnly && preloadedEntries && preloadedYacht) {
|
||||
const fullEntry = preloadedEntries.find((entry) => (entry.payloadId || entry.id) === entryId)
|
||||
await downloadLogbookPagePdf(logbookId, entryId, date, { yacht: preloadedYacht, entry: fullEntry })
|
||||
} else {
|
||||
await downloadLogbookPagePdf(logbookId, entryId, date)
|
||||
}
|
||||
trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to download PDF:', err)
|
||||
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}, [logbookId, preloadedEntries, preloadedYacht, readOnly, t])
|
||||
|
||||
const handleDownloadPhotosZip = useCallback(async () => {
|
||||
if (!hasUnlockedLocalCrypto()) return
|
||||
setExporting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const masterKey = (await getLogbookKey(logbookId)) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const localPhotos = await db.photos.where({ logbookId }).toArray()
|
||||
if (localPhotos.length === 0) {
|
||||
setError(t('logs.no_photos_to_download'))
|
||||
return
|
||||
}
|
||||
|
||||
const entryMap = new Map(entries.map((e) => [e.id, e]))
|
||||
const files: Record<string, Uint8Array> = {}
|
||||
const usedNames = new Set<string>()
|
||||
|
||||
for (const photo of localPhotos) {
|
||||
const decrypted = await decryptJson(photo.encryptedData, photo.iv, photo.tag, masterKey)
|
||||
if (!decrypted || !decrypted.image) continue
|
||||
|
||||
const { data, ext } = dataUrlToUint8Array(decrypted.image)
|
||||
let fileBase = `photo_${photo.payloadId}`
|
||||
const entry = entryMap.get(photo.entryId)
|
||||
if (entry) {
|
||||
const parts = [entry.date || 'unknown-date']
|
||||
if (entry.dayOfTravel) parts.push(`day-${entry.dayOfTravel}`)
|
||||
const sanitizedCaption = decrypted.caption ? sanitizeFilename(decrypted.caption) : ''
|
||||
if (sanitizedCaption) parts.push(sanitizedCaption)
|
||||
fileBase = parts.join('_')
|
||||
} else if (decrypted.caption) {
|
||||
fileBase = `photo_${sanitizeFilename(decrypted.caption)}`
|
||||
}
|
||||
|
||||
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 BlobPart], { type: 'application/zip' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const safeTitle = logbookTitle.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook'
|
||||
const filename = `${safeTitle}-photos-${new Date().toISOString().slice(0, 10)}.zip`
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to download photos ZIP:', err)
|
||||
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}, [entries, logbookId, logbookTitle, t])
|
||||
|
||||
return {
|
||||
exporting,
|
||||
error,
|
||||
setError,
|
||||
handleDownloadCsv,
|
||||
handleShareCsv,
|
||||
handleDownloadPdf,
|
||||
handleDownloadPhotosZip,
|
||||
canExportPhotosZip: hasUnlockedLocalCrypto()
|
||||
}
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
+691
-630
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,38 @@
|
||||
"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",
|
||||
"freshwater": "Frischwasser (Liter)",
|
||||
"fuel": "Treibstoff / Fuel (Liter)",
|
||||
"greywater": "Grauwasser (Liter)",
|
||||
@@ -237,6 +273,16 @@
|
||||
"view_mode_label": "Ansicht",
|
||||
"view_list": "Liste",
|
||||
"live_mode": "Live",
|
||||
"all_days": "Alle Tage",
|
||||
"back_to_live": "Zurück zu Live",
|
||||
"more_actions": "Weitere Aktionen",
|
||||
"live_edit_event": "Ereignis bearbeiten",
|
||||
"live_delete_event": "Ereignis löschen",
|
||||
"delete_event_confirm": "Dieses Ereignis wirklich löschen?",
|
||||
"live_day_today": "Heute",
|
||||
"live_switch_day": "Reisetag wählen",
|
||||
"live_more_btn": "Mehr",
|
||||
"live_primary_actions": "Schnellaktionen",
|
||||
"live_title": "Live-Journal",
|
||||
"live_loading": "Live-Journal wird geladen...",
|
||||
"live_retry": "Erneut versuchen",
|
||||
@@ -439,10 +485,15 @@
|
||||
"ai_summary_error_rate_limited": "Maximale Anzahl an Generierungen für diesen Reisetag erreicht.",
|
||||
"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 (E2E-verschlüsselt)",
|
||||
"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",
|
||||
"photo_camera_btn": "Foto aufnehmen",
|
||||
"photo_gallery_btn": "Aus Galerie wählen",
|
||||
"photo_processing": "Wird verarbeitet...",
|
||||
"no_photos": "Noch keine Fotos an diesen Reisetag angehängt.",
|
||||
"photo_delete_confirm": "Bist du sicher, dass du dieses Foto unwiderruflich löschen möchtest?",
|
||||
@@ -535,6 +586,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",
|
||||
@@ -672,6 +726,12 @@
|
||||
"integrations_title": "Integrationen",
|
||||
"owm_key": "OpenWeatherMap API-Schlüssel",
|
||||
"owm_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.",
|
||||
"ai_title": "KI-Funktionen & Datenschutz",
|
||||
"ai_desc": "Autorisiere die Nutzung von künstlicher Intelligenz (lokale/Cloud-Integrationen) für deine Logbücher.",
|
||||
"ai_help": "Die Aktivierung ermöglicht es, Reiseberichte automatisch zusammenzufassen und Sprachnotizen zu transkribieren. Zur Verarbeitung werden Sprachaufnahmen und Logbucheinträge verschlüsselt an OpenRouter übertragen. Die Daten werden dort nicht dauerhaft gespeichert.\n\nDa der Betrieb dieser Cloud-Ressourcen Kosten verursacht, freuen wir uns über eine freiwillige Unterstützung über den Ko-fi-Spenden-Link im Footer, um diese Funktionen dauerhaft für alle kostenlos anbieten zu können.",
|
||||
"ai_enable_label": "Transkribierung und Tageszusammenfassungen aktivieren",
|
||||
"ai_unauthorized_alert_title": "KI-Funktionen nicht autorisiert",
|
||||
"ai_unauthorized_alert_desc": "Um Sprachnotizen zu transkribieren oder Reiseberichte zusammenzufassen, musst du der Datenübermittlung an OpenRouter in deinem Benutzerprofil unter 'KI-Funktionen & Datenschutz' zustimmen.",
|
||||
"prefs_save": "Speichern",
|
||||
"prefs_saving": "Wird gespeichert…",
|
||||
"prefs_saved": "Gespeichert",
|
||||
@@ -804,6 +864,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,38 @@
|
||||
"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",
|
||||
"freshwater": "Freshwater (Liters)",
|
||||
"fuel": "Fuel (Liters)",
|
||||
"greywater": "Greywater (Liters)",
|
||||
@@ -237,6 +273,16 @@
|
||||
"view_mode_label": "View",
|
||||
"view_list": "List",
|
||||
"live_mode": "Live",
|
||||
"all_days": "All days",
|
||||
"back_to_live": "Back to Live",
|
||||
"more_actions": "More actions",
|
||||
"live_edit_event": "Edit event",
|
||||
"live_delete_event": "Delete event",
|
||||
"delete_event_confirm": "Delete this event?",
|
||||
"live_day_today": "Today",
|
||||
"live_switch_day": "Select travel day",
|
||||
"live_more_btn": "More",
|
||||
"live_primary_actions": "Quick actions",
|
||||
"live_title": "Live Journal",
|
||||
"live_loading": "Loading live journal...",
|
||||
"live_retry": "Try again",
|
||||
@@ -439,10 +485,15 @@
|
||||
"ai_summary_error_rate_limited": "Maximum number of generations reached for this travel day.",
|
||||
"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 (E2E Encrypted)",
|
||||
"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",
|
||||
"photo_camera_btn": "Take Photo",
|
||||
"photo_gallery_btn": "Choose from Gallery",
|
||||
"photo_processing": "Processing...",
|
||||
"no_photos": "No photos attached to this journal entry yet.",
|
||||
"photo_delete_confirm": "Are you sure you want to permanently delete this photo?",
|
||||
@@ -535,6 +586,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",
|
||||
@@ -672,6 +726,12 @@
|
||||
"integrations_title": "Integrations",
|
||||
"owm_key": "OpenWeatherMap API key",
|
||||
"owm_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.",
|
||||
"ai_title": "AI Features & Privacy",
|
||||
"ai_desc": "Authorize artificial intelligence integrations for your logbooks.",
|
||||
"ai_help": "Enabling AI features allows the app to summarize travel days and transcribe recorded voice memos. To process these requests, raw voice data and travel logs are sent securely on-the-fly to OpenRouter. No data is stored permanently by the AI model.\n\nThese cloud resources cost money to run; if you enjoy using them, please consider supporting the project voluntarily with a donation via the Ko-fi link in the footer to keep them free and sustainable for everyone.",
|
||||
"ai_enable_label": "Enable transcription and travel day summaries",
|
||||
"ai_unauthorized_alert_title": "AI Features Not Authorized",
|
||||
"ai_unauthorized_alert_desc": "To use transcription or travel day summaries, please authorize the data transmission to OpenRouter in your User Profile under 'AI Features & Privacy'.",
|
||||
"prefs_save": "Save",
|
||||
"prefs_saving": "Saving…",
|
||||
"prefs_saved": "Saved",
|
||||
@@ -804,6 +864,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
+645
-584
File diff suppressed because it is too large
Load Diff
+694
-633
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ export interface AdminSummary {
|
||||
totalCollaborations: number
|
||||
totalInvitations: number
|
||||
aiSummaryEntries: number
|
||||
dbSize: number
|
||||
}
|
||||
|
||||
export type AdminTimeBucket = 'day' | 'week' | 'month'
|
||||
|
||||
@@ -42,7 +42,9 @@ export const PlausibleEvents = {
|
||||
LIVE_LOG_OPENED: 'Live Log Opened',
|
||||
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
|
||||
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',
|
||||
@@ -53,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>
|
||||
|
||||
@@ -26,6 +26,7 @@ describe('appearancePrefs', () => {
|
||||
await expect(fetchAppearancePrefs()).resolves.toEqual({
|
||||
theme: 'auto',
|
||||
colorScheme: 'auto',
|
||||
aiAuthorized: false,
|
||||
persisted: false
|
||||
})
|
||||
expect(mockedApiJson).not.toHaveBeenCalled()
|
||||
@@ -36,6 +37,7 @@ describe('appearancePrefs', () => {
|
||||
mockedApiJson.mockResolvedValueOnce({
|
||||
theme: 'ocean',
|
||||
colorScheme: 'dark',
|
||||
aiAuthorized: true,
|
||||
persisted: true
|
||||
})
|
||||
|
||||
@@ -46,6 +48,7 @@ describe('appearancePrefs', () => {
|
||||
|
||||
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean')
|
||||
expect(localStorage.getItem(`user_pref_color_scheme_${USER_ID}`)).toBe('dark')
|
||||
expect(localStorage.getItem(`user_pref_ai_authorized_${USER_ID}`)).toBe('true')
|
||||
expect(changed).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -53,20 +56,20 @@ describe('appearancePrefs', () => {
|
||||
localStorage.setItem('active_userid', USER_ID)
|
||||
setThemePreference(USER_ID, 'material')
|
||||
mockedApiJson
|
||||
.mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', persisted: false })
|
||||
.mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', persisted: true })
|
||||
.mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', aiAuthorized: false, persisted: false })
|
||||
.mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', aiAuthorized: false, persisted: true })
|
||||
|
||||
await syncAppearancePrefs(USER_ID)
|
||||
|
||||
expect(mockedApiJson).toHaveBeenCalledTimes(2)
|
||||
expect(mockedApiJson).toHaveBeenLastCalledWith('/api/auth/appearance-prefs', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ theme: 'material', colorScheme: 'auto' })
|
||||
body: JSON.stringify({ theme: 'material', colorScheme: 'auto', aiAuthorized: false })
|
||||
})
|
||||
})
|
||||
|
||||
it('saveAppearancePrefsToServer skips when not authenticated', async () => {
|
||||
await saveAppearancePrefsToServer('ocean', 'light')
|
||||
await saveAppearancePrefsToServer('ocean', 'light', true)
|
||||
expect(mockedApiJson).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -76,6 +79,7 @@ describe('appearancePrefs', () => {
|
||||
mockedApiJson.mockResolvedValue({
|
||||
theme: 'material',
|
||||
colorScheme: 'dark',
|
||||
aiAuthorized: false,
|
||||
persisted: true
|
||||
})
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
getColorSchemePreference,
|
||||
getThemePreference,
|
||||
setColorSchemePreference,
|
||||
setThemePreference
|
||||
setThemePreference,
|
||||
getAiAuthorized,
|
||||
setAiAuthorized
|
||||
} from './userPreferences.js'
|
||||
|
||||
const API_BASE = '/api/auth/appearance-prefs'
|
||||
@@ -13,13 +15,15 @@ const API_BASE = '/api/auth/appearance-prefs'
|
||||
export interface AppearancePrefs {
|
||||
theme: string
|
||||
colorScheme: string
|
||||
aiAuthorized: boolean
|
||||
persisted: boolean
|
||||
}
|
||||
|
||||
function hasLocalAppearancePrefs(userId: string): boolean {
|
||||
return (
|
||||
localStorage.getItem(`user_pref_theme_${userId}`) != null ||
|
||||
localStorage.getItem(`user_pref_color_scheme_${userId}`) != null
|
||||
localStorage.getItem(`user_pref_color_scheme_${userId}`) != null ||
|
||||
localStorage.getItem(`user_pref_ai_authorized_${userId}`) != null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,7 +39,7 @@ function resolveSyncedUserId(userId?: string | null): string | null {
|
||||
|
||||
export async function fetchAppearancePrefs(userId?: string | null): Promise<AppearancePrefs> {
|
||||
if (!resolveSyncedUserId(userId)) {
|
||||
return { theme: 'auto', colorScheme: 'auto', persisted: false }
|
||||
return { theme: 'auto', colorScheme: 'auto', aiAuthorized: false, persisted: false }
|
||||
}
|
||||
|
||||
return apiJson<AppearancePrefs>(API_BASE)
|
||||
@@ -44,13 +48,14 @@ export async function fetchAppearancePrefs(userId?: string | null): Promise<Appe
|
||||
export async function saveAppearancePrefsToServer(
|
||||
theme: string,
|
||||
colorScheme: string,
|
||||
aiAuthorized: boolean,
|
||||
userId?: string | null
|
||||
): Promise<void> {
|
||||
if (!resolveSyncedUserId(userId)) return
|
||||
|
||||
await apiJson<AppearancePrefs>(API_BASE, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ theme, colorScheme })
|
||||
body: JSON.stringify({ theme, colorScheme, aiAuthorized })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -65,8 +70,14 @@ export async function syncAppearancePrefs(userId?: string | null): Promise<void>
|
||||
if (server.persisted) {
|
||||
setThemePreference(id, server.theme)
|
||||
setColorSchemePreference(id, server.colorScheme)
|
||||
setAiAuthorized(id, server.aiAuthorized)
|
||||
} else if (hasLocalAppearancePrefs(id)) {
|
||||
await saveAppearancePrefsToServer(getThemePreference(id), getColorSchemePreference(id), id)
|
||||
await saveAppearancePrefsToServer(
|
||||
getThemePreference(id),
|
||||
getColorSchemePreference(id),
|
||||
getAiAuthorized(id),
|
||||
id
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to sync appearance preferences:', err)
|
||||
|
||||
@@ -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,
|
||||
@@ -438,6 +469,26 @@ async function persistEntry(
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
}
|
||||
|
||||
export async function patchEntryEvents(
|
||||
logbookId: string,
|
||||
entryId: string,
|
||||
events: LogEventPayload[],
|
||||
options?: { clearSignatures?: boolean }
|
||||
): Promise<{ hadSignature: boolean }> {
|
||||
const loaded = await loadEntry(logbookId, entryId)
|
||||
if (!loaded) throw new Error('Entry not found')
|
||||
|
||||
const hadSignature = !!(loaded.data.signSkipper || loaded.data.signCrew)
|
||||
const nextEvents = sortLogEventsByTime(events.map((e) => normalizeLogEvent({ ...e })))
|
||||
|
||||
await persistEntry(logbookId, entryId, loaded.data, {
|
||||
events: nextEvents,
|
||||
clearSignatures: options?.clearSignatures ?? hadSignature
|
||||
})
|
||||
|
||||
return { hadSignature }
|
||||
}
|
||||
|
||||
export async function removeLastEvent(
|
||||
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
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
getThemePreference,
|
||||
setColorSchemePreference,
|
||||
setOwmApiKey,
|
||||
setThemePreference
|
||||
setThemePreference,
|
||||
getAiAuthorized,
|
||||
setAiAuthorized
|
||||
} from './userPreferences.js'
|
||||
|
||||
const USER_ID = 'test-user-123'
|
||||
@@ -58,4 +60,13 @@ describe('userPreferences', () => {
|
||||
expect(getThemePreference(USER_ID)).toBe('ocean')
|
||||
expect(getColorSchemePreference(USER_ID)).toBe('light')
|
||||
})
|
||||
|
||||
it('stores AI authorization preference per user', () => {
|
||||
localStorage.setItem('active_userid', USER_ID)
|
||||
expect(getAiAuthorized()).toBe(false)
|
||||
setAiAuthorized(USER_ID, true)
|
||||
expect(getAiAuthorized()).toBe(true)
|
||||
expect(getAiAuthorized(USER_ID)).toBe(true)
|
||||
expect(getAiAuthorized('other-user')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -89,3 +89,39 @@ export function setOwmApiKey(userId: string, value: string): void {
|
||||
localStorage.removeItem(owmKey(userId))
|
||||
}
|
||||
}
|
||||
|
||||
function logsViewModeKey(userId: string): string {
|
||||
return `user_pref_logs_view_mode_${userId}`
|
||||
}
|
||||
|
||||
export type LogsViewModePreference = 'list' | 'live'
|
||||
|
||||
export function getLogsViewModePreference(userId?: string | null): LogsViewModePreference {
|
||||
const id = resolveUserId(userId)
|
||||
if (id) {
|
||||
const value = localStorage.getItem(logsViewModeKey(id))
|
||||
if (value === 'list' || value === 'live') return value
|
||||
}
|
||||
return 'live'
|
||||
}
|
||||
|
||||
export function setLogsViewModePreference(userId: string, value: LogsViewModePreference): void {
|
||||
localStorage.setItem(logsViewModeKey(userId), value)
|
||||
}
|
||||
|
||||
function aiAuthorizedKey(userId: string): string {
|
||||
return `user_pref_ai_authorized_${userId}`
|
||||
}
|
||||
|
||||
export function getAiAuthorized(userId?: string | null): boolean {
|
||||
const id = resolveUserId(userId)
|
||||
if (id) {
|
||||
return localStorage.getItem(aiAuthorizedKey(id)) === 'true'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function setAiAuthorized(userId: string, value: boolean): void {
|
||||
localStorage.setItem(aiAuthorizedKey(userId), String(value))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -33,7 +33,6 @@ services:
|
||||
OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-}
|
||||
OpenRouterAPIKey: ${OpenRouterAPIKey:-}
|
||||
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
|
||||
PARAKEET_URL: ${PARAKEET_URL:-http://parakeet:5092/v1/audio/transcriptions}
|
||||
SESSION_SECRET: ${SESSION_SECRET:-}
|
||||
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
|
||||
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
|
||||
@@ -61,19 +60,13 @@ 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:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
parakeet:
|
||||
image: ghcr.io/achetronic/parakeet:latest
|
||||
container_name: daagbox-staging-parakeet
|
||||
restart: always
|
||||
ports:
|
||||
- "5092:5092"
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
name: daagbox-staging-pgdata
|
||||
|
||||
@@ -34,7 +34,6 @@ services:
|
||||
OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-}
|
||||
OpenRouterAPIKey: ${OpenRouterAPIKey:-}
|
||||
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
|
||||
PARAKEET_URL: ${PARAKEET_URL:-http://parakeet:5092/v1/audio/transcriptions}
|
||||
SESSION_SECRET: ${SESSION_SECRET:-}
|
||||
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
|
||||
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
|
||||
@@ -68,13 +67,6 @@ services:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
parakeet:
|
||||
image: ghcr.io/achetronic/parakeet:latest
|
||||
container_name: daagbox-prod-parakeet
|
||||
restart: always
|
||||
ports:
|
||||
- "5092:5092"
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
name: daagbox-prod-pgdata
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -47,7 +47,9 @@ Das Script wird über `plausible-bootstrap.js` geladen; `data-domain` ist der ak
|
||||
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
|
||||
| Photo Uploaded | Foto hochgeladen (`photoAttachments.ts`, `PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `live_log` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
|
||||
| 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` |
|
||||
@@ -147,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
|
||||
@@ -161,6 +163,7 @@ trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from: 'de', to: 'da' })
|
||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'course' })
|
||||
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'live_log' })
|
||||
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_UPLOADED, { context: 'live_log' })
|
||||
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, { status: 'success', mode: 'auto' })
|
||||
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, { source: 'live_log' })
|
||||
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, { lines: 1200, candidates: 8, duplicate: false, has_position: true })
|
||||
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode: 'both', events: 6, track: true })
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -52,10 +52,11 @@ model UserNotificationPrefs {
|
||||
}
|
||||
|
||||
model UserAppearancePrefs {
|
||||
userId String @id
|
||||
theme String @default("auto")
|
||||
colorScheme String @default("auto")
|
||||
updatedAt DateTime @updatedAt
|
||||
userId String @id
|
||||
theme String @default("auto")
|
||||
colorScheme String @default("auto")
|
||||
aiAuthorized Boolean @default(false)
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -23,6 +23,11 @@ router.get('/summary', requireUser, requireAdmin, async (_req, res) => {
|
||||
prisma.aiSummaryUsage.count()
|
||||
])
|
||||
|
||||
const rawDbSize = await prisma.$queryRaw<[{ size: string }]>`
|
||||
SELECT pg_database_size(current_database())::text as size
|
||||
`
|
||||
const dbSize = Number(rawDbSize[0]?.size || '0')
|
||||
|
||||
res.json({
|
||||
totalUsers,
|
||||
totalLogbooks,
|
||||
@@ -31,7 +36,8 @@ router.get('/summary', requireUser, requireAdmin, async (_req, res) => {
|
||||
totalGpsTracks,
|
||||
totalCollaborations,
|
||||
totalInvitations,
|
||||
aiSummaryEntries
|
||||
aiSummaryEntries,
|
||||
dbSize
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
console.error('admin/summary error', error)
|
||||
@@ -91,7 +97,7 @@ async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<
|
||||
const since = new Date()
|
||||
since.setUTCDate(since.getUTCDate() - windowDays)
|
||||
|
||||
const [users, logbooks, photos] = await Promise.all([
|
||||
const [users, logbooks, photos, dbSizeRaw, photosSize, voiceSize, tracksSize, entriesSize] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where: { createdAt: { gte: since } },
|
||||
select: { createdAt: true }
|
||||
@@ -103,9 +109,72 @@ async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<
|
||||
prisma.photoPayload.findMany({
|
||||
where: { updatedAt: { gte: since } },
|
||||
select: { updatedAt: true }
|
||||
}),
|
||||
prisma.$queryRaw<[{ size: string }]>`
|
||||
SELECT pg_database_size(current_database())::text as size
|
||||
`,
|
||||
prisma.photoPayload.findMany({
|
||||
select: { updatedAt: true, encryptedData: true }
|
||||
}),
|
||||
prisma.voiceMemoPayload.findMany({
|
||||
select: { updatedAt: true, encryptedData: true }
|
||||
}),
|
||||
prisma.gpsTrackPayload.findMany({
|
||||
select: { updatedAt: true, encryptedData: true }
|
||||
}),
|
||||
prisma.entryPayload.findMany({
|
||||
select: { updatedAt: true, encryptedData: true }
|
||||
})
|
||||
])
|
||||
|
||||
const dbSizeVal = Number(dbSizeRaw[0]?.size || '0')
|
||||
|
||||
const payloads: { date: Date; size: number }[] = []
|
||||
for (const p of photosSize) {
|
||||
payloads.push({ date: p.updatedAt, size: p.encryptedData.length })
|
||||
}
|
||||
for (const v of voiceSize) {
|
||||
payloads.push({ date: v.updatedAt, size: v.encryptedData.length })
|
||||
}
|
||||
for (const g of tracksSize) {
|
||||
payloads.push({ date: g.updatedAt, size: g.encryptedData.length })
|
||||
}
|
||||
for (const e of entriesSize) {
|
||||
payloads.push({ date: e.updatedAt, size: e.encryptedData.length })
|
||||
}
|
||||
|
||||
const totalPayloadsSize = payloads.reduce((acc, p) => acc + p.size, 0)
|
||||
const baseDbSize = Math.max(0, dbSizeVal - totalPayloadsSize)
|
||||
|
||||
payloads.sort((a, b) => a.date.getTime() - b.date.getTime())
|
||||
|
||||
// Generate complete list of date keys for the window
|
||||
const dateKeys: string[] = []
|
||||
const current = new Date(since)
|
||||
const todayStr = bucketDate(new Date(), bucket)
|
||||
while (true) {
|
||||
const key = bucketDate(current, bucket)
|
||||
if (!dateKeys.includes(key)) {
|
||||
dateKeys.push(key)
|
||||
}
|
||||
if (key >= todayStr) break
|
||||
current.setUTCDate(current.getUTCDate() + 1)
|
||||
}
|
||||
|
||||
const dbSizePoints = dateKeys.map((key) => {
|
||||
let sizeSum = 0
|
||||
for (const p of payloads) {
|
||||
if (bucketDate(p.date, bucket) <= key) {
|
||||
sizeSum += p.size
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
const totalBytes = baseDbSize + sizeSum
|
||||
const sizeInMb = Math.round((totalBytes / (1024 * 1024)) * 10) / 10
|
||||
return { date: key, count: sizeInMb }
|
||||
})
|
||||
|
||||
function aggregate(dates: Date[], metric: string): TimeSeries {
|
||||
const map = new Map<string, number>()
|
||||
for (const d of dates) {
|
||||
@@ -130,7 +199,11 @@ async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<
|
||||
aggregate(
|
||||
photos.map((p) => p.updatedAt),
|
||||
'photos_updated'
|
||||
)
|
||||
),
|
||||
{
|
||||
metric: 'database_size',
|
||||
points: dbSizePoints
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
+29
-21
@@ -3,8 +3,6 @@ import { prisma } from '../db.js'
|
||||
import { requireUser } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const PARAKEET_URL = process.env.PARAKEET_URL || 'http://localhost:5092/v1/audio/transcriptions'
|
||||
const MAX_ATTEMPTS_PER_ENTRY = 3
|
||||
const DEFAULT_MODEL = 'anthropic/claude-3.5-haiku'
|
||||
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'
|
||||
@@ -238,51 +236,61 @@ router.post('/transcribe', async (req: any, res) => {
|
||||
return res.status(400).json({ error: 'audioDataUrl is required' })
|
||||
}
|
||||
|
||||
const match = audioDataUrl.match(/^data:([^;]+);base64,(.+)$/)
|
||||
const match = audioDataUrl.match(/^data:(.+);base64,(.+)$/)
|
||||
if (!match) {
|
||||
return res.status(400).json({ error: 'Invalid audio data URL format' })
|
||||
}
|
||||
|
||||
const [, mimeType, base64Data] = match
|
||||
const buffer = Buffer.from(base64Data, 'base64')
|
||||
const [, fullMimeType, base64Data] = match
|
||||
const mimeType = fullMimeType.split(';')[0]
|
||||
|
||||
let ext = 'webm'
|
||||
if (mimeType.includes('mp4')) ext = 'mp4'
|
||||
else if (mimeType.includes('ogg')) ext = 'ogg'
|
||||
else if (mimeType.includes('wav')) ext = 'wav'
|
||||
|
||||
const filename = `audio.${ext}`
|
||||
const file = new File([buffer], filename, { type: mimeType })
|
||||
const apiKey = resolveOpenRouterApiKey()
|
||||
if (!apiKey) {
|
||||
console.warn('[server] OpenRouter API key not configured, transcription unavailable')
|
||||
return res.status(503).json({ error: 'Transcription service not configured' })
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
console.log(`[server] Forwarding ASR request to ${PARAKEET_URL} (${filename}, ${buffer.length} bytes)`)
|
||||
console.log(`[server] Forwarding ASR request to OpenRouter (${ext}, ${base64Data.length} chars)`)
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 15000)
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000)
|
||||
|
||||
try {
|
||||
const parakeetRes = await fetch(PARAKEET_URL, {
|
||||
const openRouterRes = await fetch('https://openrouter.ai/api/v1/audio/transcriptions', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'openai/whisper-large-v3-turbo',
|
||||
input_audio: {
|
||||
data: base64Data,
|
||||
format: ext
|
||||
}
|
||||
}),
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
if (!parakeetRes.ok) {
|
||||
const errorText = await parakeetRes.text().catch(() => '')
|
||||
console.error(`[server] Parakeet ASR error response (status=${parakeetRes.status}):`, errorText)
|
||||
throw new Error(`Parakeet returned status ${parakeetRes.status}`)
|
||||
if (!openRouterRes.ok) {
|
||||
const errorText = await openRouterRes.text().catch(() => '')
|
||||
console.error(`[server] OpenRouter ASR error response (status=${openRouterRes.status}):`, errorText)
|
||||
throw new Error(`OpenRouter returned status ${openRouterRes.status}`)
|
||||
}
|
||||
|
||||
const data: any = await parakeetRes.json()
|
||||
const data: any = await openRouterRes.json()
|
||||
const text = (data?.text || '').trim()
|
||||
|
||||
console.log(`[server] ASR completed successfully: "${text}"`)
|
||||
console.log(`[server] OpenRouter ASR completed successfully: "${text}"`)
|
||||
return res.json({ text })
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
console.error('[server] Parakeet ASR request timed out')
|
||||
console.error('[server] OpenRouter ASR request timed out')
|
||||
return res.status(504).json({ error: 'Transcription request timed out' })
|
||||
}
|
||||
throw error
|
||||
|
||||
@@ -63,6 +63,7 @@ function isMissingAppearancePrefsTable(error: unknown): boolean {
|
||||
const DEFAULT_APPEARANCE_PREFS = {
|
||||
theme: 'auto',
|
||||
colorScheme: 'auto',
|
||||
aiAuthorized: false,
|
||||
persisted: false
|
||||
} as const
|
||||
|
||||
@@ -454,6 +455,7 @@ router.get('/appearance-prefs', requireUser, async (req: any, res) => {
|
||||
return res.json({
|
||||
theme: prefs?.theme ?? 'auto',
|
||||
colorScheme: prefs?.colorScheme ?? 'auto',
|
||||
aiAuthorized: prefs?.aiAuthorized ?? false,
|
||||
persisted: prefs != null
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
@@ -469,6 +471,7 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
|
||||
try {
|
||||
const theme = parseThemePreference(req.body?.theme)
|
||||
const colorScheme = parseColorSchemePreference(req.body?.colorScheme)
|
||||
const aiAuthorized = req.body?.aiAuthorized === true
|
||||
if (!theme || !colorScheme) {
|
||||
return res.status(400).json({ error: 'Invalid theme or colorScheme' })
|
||||
}
|
||||
@@ -479,11 +482,13 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
|
||||
userId: req.userId,
|
||||
theme,
|
||||
colorScheme,
|
||||
aiAuthorized,
|
||||
updatedAt: new Date()
|
||||
},
|
||||
update: {
|
||||
theme,
|
||||
colorScheme,
|
||||
aiAuthorized,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
})
|
||||
@@ -491,6 +496,7 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
|
||||
return res.json({
|
||||
theme: prefs.theme,
|
||||
colorScheme: prefs.colorScheme,
|
||||
aiAuthorized: prefs.aiAuthorized,
|
||||
persisted: true
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -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