Compare commits

..

12 Commits

Author SHA1 Message Date
elpatron 9634370a08 fix: ungenutzten formatAppDecimal-Import entfernen
Behebt den TypeScript-Build-Fehler TS6133 nach der Gezeiten-Ort-Anzeige.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 14:47:48 +02:00
elpatron 1bad0531b5 feat: Abfrageort bei Gezeiten speichern und anzeigen
Ort oder GPS-Koordinaten werden im Entry-Payload persistiert und im
Tiden-Accordion sowie im Live-Journal-Modal als lesbare Zeile angezeigt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 14:37:19 +02:00
elpatron 5d4e498528 feat: Gezeiten im Logbuch per Open-Meteo Marine
HW/NW-Felder im Reisetag und Live-Journal mit Server-Proxy auf Basis von
Open-Meteo Marine am GPS-Standort; neueste Position und frischer DB-Stand
vor dem Abruf, Bestätigung nach Übernehmen, Accordion-Layout bereinigt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 14:22:25 +02:00
elpatron d667062ec2 fix: prevent DEPLOY_BRANCH collapsing in ssh args during production update 2026-06-08 10:55:21 +02:00
elpatron 0bfc38f290 chore: release v0.1.1.33 2026-06-08 10:45:36 +02:00
elpatron 943ce838af chore: release v0.1.1.32 2026-06-08 08:20:55 +02:00
elpatron f7ad7001d7 Move backup restore functionality to dashboard 2026-06-08 08:05:20 +02:00
elpatron 444d347c56 chore: release v0.1.1.31 2026-06-08 07:44:12 +02:00
elpatron a185bbaf27 feat: add local photo zip download for logged-in users and swipe navigation to maximized gallery 2026-06-07 21:42:00 +02:00
elpatron 864d45714c feat(settings): add share button next to copy button on mobile devices for public share link 2026-06-07 21:19:57 +02:00
elpatron faf3b8e3cf chore: release v0.1.1.30 2026-06-07 14:32:46 +02:00
elpatron 74ff8eb16b style: fix journal entry action buttons alignment on mobile 2026-06-07 14:27:44 +02:00
35 changed files with 2183 additions and 251 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.1.30 0.1.1.34
+109
View File
@@ -1939,6 +1939,21 @@ html.scheme-dark .themed-select-option.is-selected {
pointer-events: none; pointer-events: none;
} }
.logbook-card-right-group {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
position: relative;
z-index: 2;
align-self: center;
}
.logbook-card-right-group .logbook-card-chevron {
margin-left: 0;
}
.logbook-card .logbook-title-editable, .logbook-card .logbook-title-editable,
.logbook-card .logbook-title-inline-edit, .logbook-card .logbook-title-inline-edit,
.logbook-card .card-title-row { .logbook-card .card-title-row {
@@ -2968,6 +2983,12 @@ html.scheme-dark .themed-select-option.is-selected {
opacity: 1; opacity: 1;
} }
.logbook-card-right-group .btn-pdf,
.logbook-card-right-group .btn-delete {
position: static;
opacity: 1;
}
.card-meta { .card-meta {
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -3315,6 +3336,51 @@ html.theme-cupertino .events-scroll-container {
word-break: break-word; word-break: break-word;
} }
.photo-maximized-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #f1f5f9;
border-radius: 50%;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
z-index: 11005;
}
.photo-maximized-nav:hover {
background: rgba(255, 255, 255, 0.2);
border-color: #ffffff;
transform: translateY(-50%) scale(1.08);
}
.photo-maximized-prev {
left: 24px;
}
.photo-maximized-next {
right: 24px;
}
@media (max-width: 768px) {
.photo-maximized-nav {
width: 44px;
height: 44px;
}
.photo-maximized-prev {
left: 12px;
}
.photo-maximized-next {
right: 12px;
}
}
/* Custom Dialog Modals Styling */ /* Custom Dialog Modals Styling */
.custom-dialog-overlay { .custom-dialog-overlay {
position: fixed; position: fixed;
@@ -4541,6 +4607,49 @@ html.theme-cupertino .events-scroll-container {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
/* Tides accordion (LogEntryEditor) */
.tides-panel {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 16px;
}
.tides-panel__hints {
display: flex;
flex-direction: column;
gap: 8px;
}
.tides-panel__hints .form-hint {
margin: 0;
font-size: 13px;
color: var(--app-text-muted);
line-height: 1.45;
}
.tides-panel__location {
margin: 0;
font-size: 13.5px;
font-weight: 500;
color: var(--app-text);
line-height: 1.45;
}
.tides-panel__fields {
margin: 0;
}
.tides-panel__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tides-panel__actions .btn {
width: auto;
}
.metric-range-input--compact { .metric-range-input--compact {
gap: 0; gap: 0;
margin: 0; margin: 0;
-1
View File
@@ -852,7 +852,6 @@ function App() {
{activeTab === 'settings' && ( {activeTab === 'settings' && (
<SettingsForm <SettingsForm
logbookId={activeLogbookId} logbookId={activeLogbookId}
onLogbookRestored={selectLogbook}
/> />
)} )}
</main> </main>
+154
View File
@@ -19,6 +19,7 @@ import {
Radio, Radio,
Sailboat, Sailboat,
Undo2, Undo2,
Waves,
Zap Zap
} from 'lucide-react' } from 'lucide-react'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
@@ -29,6 +30,7 @@ import {
appendTankRefill as apiAppendTankRefill, appendTankRefill as apiAppendTankRefill,
findOrCreateTodayEntry, findOrCreateTodayEntry,
loadEntry, loadEntry,
patchEntryTides,
removeLastEvent removeLastEvent
} from '../services/quickEventLog.js' } from '../services/quickEventLog.js'
import CreatorAvatar from './CreatorAvatar.tsx' import CreatorAvatar from './CreatorAvatar.tsx'
@@ -56,6 +58,13 @@ const formatSpeedKn = (speedKn: number) =>
formatAppDecimal(speedKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 }) formatAppDecimal(speedKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js' import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js' import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
import { fetchTidesByPlace, fetchTidesNearby, TidesApiError } from '../services/tides.js'
import {
buildTideLocationMeta,
formatTideLocationLabel,
resolveTideFetchLocation
} from '../utils/tideLocation.js'
import { parseTideTurtleForDate } from '../utils/tideTurtle.js'
import { import {
geolocationErrorI18nKey, geolocationErrorI18nKey,
getCurrentPosition, getCurrentPosition,
@@ -108,6 +117,7 @@ type LiveModal =
| 'sog' | 'sog'
| 'stw' | 'stw'
| 'position' | 'position'
| 'tides'
| 'photo' | 'photo'
| 'voice' | 'voice'
@@ -190,6 +200,7 @@ export default function LiveLogView({
const [entryId, setEntryId] = useState<string | null>(null) const [entryId, setEntryId] = useState<string | null>(null)
const [dayOfTravel, setDayOfTravel] = useState('') const [dayOfTravel, setDayOfTravel] = useState('')
const [date, setDate] = useState('') const [date, setDate] = useState('')
const [departure, setDeparture] = useState('')
const [events, setEvents] = useState<LogEventPayload[]>([]) const [events, setEvents] = useState<LogEventPayload[]>([])
const [crewSnapshotsById, setCrewSnapshotsById] = useState<Record<string, any>>({}) const [crewSnapshotsById, setCrewSnapshotsById] = useState<Record<string, any>>({})
const [selectedSkipperId, setSelectedSkipperId] = useState<string | null>(null) const [selectedSkipperId, setSelectedSkipperId] = useState<string | null>(null)
@@ -200,6 +211,12 @@ export default function LiveLogView({
const [modal, setModal] = useState<LiveModal>('none') const [modal, setModal] = useState<LiveModal>('none')
const [weatherExpanded, setWeatherExpanded] = useState(false) const [weatherExpanded, setWeatherExpanded] = useState(false)
const [weatherOwmLoading, setWeatherOwmLoading] = useState(false) const [weatherOwmLoading, setWeatherOwmLoading] = useState(false)
const [tidesLoading, setTidesLoading] = useState(false)
const [tidePreview, setTidePreview] = useState<{
highWater: string
lowWater: string
location: ReturnType<typeof buildTideLocationMeta>
} | null>(null)
const [isOnline, setIsOnline] = useState(navigator.onLine) const [isOnline, setIsOnline] = useState(navigator.onLine)
const [commentText, setCommentText] = useState('') const [commentText, setCommentText] = useState('')
const [valueInput, setValueInput] = useState('') const [valueInput, setValueInput] = useState('')
@@ -301,6 +318,7 @@ export default function LiveLogView({
const entryEvents = (loaded.data.events as LogEventPayload[]) || [] const entryEvents = (loaded.data.events as LogEventPayload[]) || []
setDayOfTravel(String(loaded.data.dayOfTravel || '')) setDayOfTravel(String(loaded.data.dayOfTravel || ''))
setDate(String(loaded.data.date || '')) setDate(String(loaded.data.date || ''))
setDeparture(String(loaded.data.departure || ''))
setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e })))) setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e }))))
setCrewSnapshotsById((loaded.data.crewSnapshotsById as Record<string, any>) || {}) setCrewSnapshotsById((loaded.data.crewSnapshotsById as Record<string, any>) || {})
setSelectedSkipperId(typeof loaded.data.selectedSkipperId === 'string' ? loaded.data.selectedSkipperId : null) setSelectedSkipperId(typeof loaded.data.selectedSkipperId === 'string' ? loaded.data.selectedSkipperId : null)
@@ -784,6 +802,103 @@ export default function LiveLogView({
})() })()
} }
const handleFetchTides = () => {
if (!entryId || busy || tidesLoading) return
if (!isOnline) {
void showAlert(t('logs.weather_offline'), t('logs.tides'))
return
}
setTidesLoading(true)
setError(null)
void (async () => {
try {
const loaded = await loadEntry(logbookId, entryId)
const eventsForLocation = loaded
? sortLogEventsByTime((loaded.data.events as LogEventPayload[]) || [])
: events
const entryDateForLocation = loaded ? String(loaded.data.date || date) : date
const departureForLocation = loaded ? String(loaded.data.departure || departure) : departure
const location = resolveTideFetchLocation({
events: eventsForLocation,
entryDate: entryDateForLocation,
departure: departureForLocation
})
if ('error' in location) {
void showAlert(
location.error === 'stale'
? t('logs.tide_position_stale')
: t('logs.tide_location_required'),
t('logs.tides')
)
return
}
const data =
location.mode === 'nearby'
? await fetchTidesNearby(location.lat, location.lng, {
analyticsSource: 'live_log',
locationSource: location.source
})
: await fetchTidesByPlace(location.query, { analyticsSource: 'live_log' })
const parsed = parseTideTurtleForDate(data, date)
if (!parsed.highWater && !parsed.lowWater) {
void showAlert(t('logs.tide_no_data'), t('logs.tides'))
return
}
setTidePreview({
highWater: parsed.highWater,
lowWater: parsed.lowWater,
location: buildTideLocationMeta(location, data)
})
setModal('tides')
} catch (err) {
if (err instanceof TidesApiError) {
if (err.code === 'OFFLINE') {
void showAlert(t('logs.weather_offline'), t('logs.tides'))
return
}
if (err.code === 'PLACE_NOT_FOUND') {
void showAlert(t('logs.tide_place_not_found', { place: departure.trim() }), t('logs.tides'))
return
}
if (err.code === 'NOT_FOUND') {
void showAlert(t('logs.tide_no_data'), t('logs.tides'))
return
}
}
console.error('Live log tide fetch failed:', err)
void showAlert(t('logs.tide_fetch_failed'), t('logs.tides'))
} finally {
setTidesLoading(false)
}
})()
}
const confirmTides = () => {
if (!entryId || !tidePreview || busy) return
const preview = tidePreview
void runQuickAction(async () => {
await patchEntryTides(logbookId, entryId, {
highWater: preview.highWater,
lowWater: preview.lowWater,
...preview.location
})
setTidePreview(null)
setModal('none')
void showAlert(
t('logs.tide_applied_success', {
highWater: preview.highWater || '—',
lowWater: preview.lowWater || '—'
}),
t('logs.tides')
)
}, 'tides', false)
}
const handleUndo = () => { const handleUndo = () => {
if (!entryId || busy) return if (!entryId || busy) return
const photoId = undoPhotoIdRef.current const photoId = undoPhotoIdRef.current
@@ -1257,6 +1372,10 @@ export default function LiveLogView({
<MapPin size={18} /> <MapPin size={18} />
{t('logs.live_position')} {t('logs.live_position')}
</button> </button>
<button type="button" className="live-log-action-btn" onClick={handleFetchTides} disabled={busy || tidesLoading}>
<Waves size={18} />
{tidesLoading ? t('logs.tide_fetch_loading') : t('logs.tides')}
</button>
<button type="button" className="live-log-action-btn" onClick={() => { setCommentText(''); setModal('comment') }} disabled={busy}> <button type="button" className="live-log-action-btn" onClick={() => { setCommentText(''); setModal('comment') }} disabled={busy}>
<MessageSquare size={18} /> <MessageSquare size={18} />
{t('logs.live_comment_btn')} {t('logs.live_comment_btn')}
@@ -1455,6 +1574,41 @@ export default function LiveLogView({
</div> </div>
)} )}
{modal === 'tides' && tidePreview && (
<div
className="live-log-modal-backdrop"
onClick={(e) => { if (e.target === e.currentTarget) closeModal() }}
>
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
<h3>{t('logs.tides')}</h3>
<p className="live-log-modal-hint" role="note">
{t('logs.tide_disclaimer')}
</p>
{formatTideLocationLabel(tidePreview.location, t) ? (
<p className="live-log-modal-hint" role="status">
{formatTideLocationLabel(tidePreview.location, t)}
</p>
) : null}
<dl className="live-log-tide-preview">
<div>
<dt>{t('logs.tide_high_water')}</dt>
<dd>{tidePreview.highWater || '—'}</dd>
</div>
<div>
<dt>{t('logs.tide_low_water')}</dt>
<dd>{tidePreview.lowWater || '—'}</dd>
</div>
</dl>
<div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={closeModal}>{t('logs.live_cancel')}</button>
<button type="button" className="btn primary" onClick={confirmTides} disabled={busy}>
{t('logs.tide_apply')}
</button>
</div>
</div>
</div>
)}
{modal === 'comment' && ( {modal === 'comment' && (
<div className="live-log-modal-backdrop" onClick={() => setModal('none')}> <div className="live-log-modal-backdrop" onClick={() => setModal('none')}>
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}> <div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
+141 -5
View File
@@ -1,12 +1,13 @@
import React, { useState, useEffect, useCallback, useRef } from 'react' import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js' import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js' import { getActiveMasterKey, hasUnlockedLocalCrypto } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js' import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson } from '../services/crypto.js' import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js' import { syncLogbook } from '../services/sync.js'
import { downloadCsv, shareCsv } from '../services/csvExport.js' import { downloadCsv, shareCsv } from '../services/csvExport.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js' import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { buildZipArchive } from '../services/logbookBackup/zipArchive.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js' import { getErrorMessage } from '../utils/errors.js'
import { findTodayEntryId, pruneEmptyTodayDuplicates, tryDecryptEntryPayload } from '../services/quickEventLog.js' import { findTodayEntryId, pruneEmptyTodayDuplicates, tryDecryptEntryPayload } from '../services/quickEventLog.js'
@@ -59,6 +60,42 @@ interface DecryptedEntryItem {
skipperSignStatus: SkipperSignStatus skipperSignStatus: SkipperSignStatus
} }
// Helper to convert data URL to Uint8Array for zip packaging
function dataUrlToUint8Array(dataUrl: string): { data: Uint8Array; ext: string } {
const parts = dataUrl.split(',')
if (parts.length < 2) {
throw new Error('Invalid data URL')
}
const meta = parts[0]
const base64Data = parts[1]
let ext = 'jpg'
const mimeMatch = meta.match(/data:([^;]+)/)
if (mimeMatch) {
const mime = mimeMatch[1]
if (mime === 'image/png') ext = 'png'
else if (mime === 'image/gif') ext = 'gif'
else if (mime === 'image/webp') ext = 'webp'
else if (mime === 'image/heic') ext = 'heic'
else if (mime === 'image/heif') ext = 'heif'
}
const binaryString = atob(base64Data)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
return { data: bytes, ext }
}
function sanitizeFilename(str: string): string {
return str
.replace(/[^\w\s-]/gi, '')
.trim()
.replace(/\s+/g, '_')
.slice(0, 30)
}
export default function LogEntriesList({ export default function LogEntriesList({
logbookId, logbookId,
readOnly = false, readOnly = false,
@@ -257,6 +294,90 @@ export default function LogEntriesList({
} }
} }
const handleDownloadPhotosZip = async () => {
setExporting(true)
setError(null)
try {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
// Fetch all photos for this logbook from IndexedDB
const localPhotos = await db.photos.where({ logbookId }).toArray()
if (localPhotos.length === 0) {
setError(t('logs.no_photos_to_download'))
return
}
// Build a map of entry ID to entry info for filename lookup
const entryMap = new Map<string, DecryptedEntryItem>()
entries.forEach((e) => entryMap.set(e.id, e))
const files: Record<string, Uint8Array> = {}
const usedNames = new Set<string>()
for (const photo of localPhotos) {
// Decrypt photo payload (contains base64 image data and caption)
const decrypted = await decryptJson(photo.encryptedData, photo.iv, photo.tag, masterKey)
if (!decrypted || !decrypted.image) continue
const { data, ext } = dataUrlToUint8Array(decrypted.image)
// Construct unique, friendly filename
let fileBase = `photo_${photo.payloadId}`
const entry = entryMap.get(photo.entryId)
if (entry) {
const dateStr = entry.date || 'unknown-date'
const travelDay = entry.dayOfTravel ? `day-${entry.dayOfTravel}` : ''
const sanitizedCaption = decrypted.caption ? sanitizeFilename(decrypted.caption) : ''
const parts = [dateStr]
if (travelDay) parts.push(travelDay)
if (sanitizedCaption) parts.push(sanitizedCaption)
fileBase = parts.join('_')
} else if (decrypted.caption) {
fileBase = `photo_${sanitizeFilename(decrypted.caption)}`
}
// De-duplicate name
let candidate = `${fileBase}.${ext}`
let counter = 1
while (usedNames.has(candidate.toLowerCase())) {
candidate = `${fileBase}_${counter}.${ext}`
counter++
}
usedNames.add(candidate.toLowerCase())
files[candidate] = data
}
if (Object.keys(files).length === 0) {
setError(t('logs.no_photos_to_download'))
return
}
const zipBytes = buildZipArchive(files)
const blob = new Blob([zipBytes as any], { type: 'application/zip' })
const url = URL.createObjectURL(blob)
const yachtName = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook'
const safeTitle = yachtName.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook'
const datePart = new Date().toISOString().slice(0, 10)
const filename = `${safeTitle}-photos-${datePart}.zip`
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
URL.revokeObjectURL(url)
} catch (err: any) {
console.error('Failed to download photos ZIP:', err)
setError(getErrorMessage(err, t('errors.export_failed')))
} finally {
setExporting(false)
}
}
const handleCreate = async () => { const handleCreate = async () => {
if (readOnly) return if (readOnly) return
setError(null) setError(null)
@@ -488,6 +609,21 @@ export default function LogEntriesList({
<span className="hide-mobile">{t('logs.share_csv')}</span> <span className="hide-mobile">{t('logs.share_csv')}</span>
</button> </button>
{hasUnlockedLocalCrypto() && (
<button
className="btn secondary"
onClick={handleDownloadPhotosZip}
disabled={loading || exporting || entries.length === 0}
style={{ width: 'auto', padding: '8px 16px' }}
title={t('logs.export_photos_zip')}
>
<Download size={16} />
<span className="hide-mobile">
{exporting ? t('logs.exporting_photos_zip') : t('logs.export_photos_zip')}
</span>
</button>
)}
{!readOnly && ( {!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 || exporting} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.new_entry')}>
<Plus size={16} /> <Plus size={16} />
@@ -541,17 +677,17 @@ export default function LogEntriesList({
</div> </div>
</div> </div>
<ChevronRight size={18} className="logbook-card-chevron" aria-hidden /> <div className="logbook-card-right-group">
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}> <button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
<Download size={18} /> <Download size={18} />
</button> </button>
{!readOnly && ( {!readOnly && (
<button className="btn-delete" onClick={(e) => handleDelete(item.id, e)} title={t('logs.delete_entry')}> <button className="btn-delete" onClick={(e) => handleDelete(item.id, e)} title={t('logs.delete_entry')}>
<Trash2 size={18} /> <Trash2 size={18} />
</button> </button>
)} )}
<ChevronRight size={18} className="logbook-card-chevron" aria-hidden />
</div>
</div> </div>
))} ))}
</div> </div>
+174 -3
View File
@@ -8,7 +8,7 @@ import { syncLogbook } from '../services/sync.js'
import { saveEntryDraft, clearEntryDraft } from '../services/entryDraft.js' import { saveEntryDraft, clearEntryDraft } from '../services/entryDraft.js'
import { getErrorMessage } from '../utils/errors.js' import { getErrorMessage } from '../utils/errors.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js' import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp, Sparkles, Sliders } from 'lucide-react' import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp, Sparkles, Sliders, Waves } from 'lucide-react'
import PhotoCapture from './PhotoCapture.tsx' import PhotoCapture from './PhotoCapture.tsx'
import EventRemarksCell from './EventRemarksCell.tsx' import EventRemarksCell from './EventRemarksCell.tsx'
import CreatorAvatar from './CreatorAvatar.tsx' import CreatorAvatar from './CreatorAvatar.tsx'
@@ -33,7 +33,7 @@ import {
hasAnySignature hasAnySignature
} from '../utils/signatures.js' } from '../utils/signatures.js'
import type { SignatureValue } from '../types/signatures.js' import type { SignatureValue } from '../types/signatures.js'
import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, hasUnsavedEventDraft, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js' import { buildLogEntryPayload, readLogEntryTides, sortLogEventsByTime, normalizeLogEvent, hasUnsavedEventDraft, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js'
import EventTimeInput24h from './EventTimeInput24h.tsx' import EventTimeInput24h from './EventTimeInput24h.tsx'
import CourseDialInput from './CourseDialInput.tsx' import CourseDialInput from './CourseDialInput.tsx'
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js' import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
@@ -43,13 +43,22 @@ import { putEntryRecord } from '../utils/entryListCache.js'
import { getLogbookAccess } from '../services/logbookAccess.js' import { getLogbookAccess } from '../services/logbookAccess.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js' import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
import { fetchTidesByPlace, fetchTidesNearby, TidesApiError } from '../services/tides.js'
import {
buildTideLocationMeta,
formatTideLocationLabel,
pickTideLocationMeta,
resolveTideFetchLocation,
type TideLocationMeta
} from '../utils/tideLocation.js'
import { parseTideTurtleForDate } from '../utils/tideTurtle.js'
import { import {
buildTravelDayContext, buildTravelDayContext,
fetchTravelDaySummaryUsage, fetchTravelDaySummaryUsage,
generateTravelDaySummary, generateTravelDaySummary,
TravelDaySummaryApiError TravelDaySummaryApiError
} from '../services/aiSummary.js' } from '../services/aiSummary.js'
import { tryDecryptEntryPayload } from '../services/quickEventLog.js' import { loadEntry, tryDecryptEntryPayload } from '../services/quickEventLog.js'
import { getAiAuthorized } from '../services/userPreferences.js' import { getAiAuthorized } from '../services/userPreferences.js'
import { import {
getDecryptedTrack, getDecryptedTrack,
@@ -164,6 +173,7 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
motorHoursRaw != null && motorHoursRaw !== '' motorHoursRaw != null && motorHoursRaw !== ''
? (parseAppDecimal(String(motorHoursRaw)) ?? undefined) ? (parseAppDecimal(String(motorHoursRaw)) ?? undefined)
: undefined, : undefined,
tides: readLogEntryTides(decrypted),
events: (decrypted.events as LogEventPayload[]) || [], events: (decrypted.events as LogEventPayload[]) || [],
entryCrew: entryCrewFromPreviousEntry(decrypted as Record<string, unknown>) entryCrew: entryCrewFromPreviousEntry(decrypted as Record<string, unknown>)
}) })
@@ -298,6 +308,11 @@ export default function LogEntryEditor({
const [eventsCollapsed, setEventsCollapsed] = useState(true) const [eventsCollapsed, setEventsCollapsed] = useState(true)
const [addEventFormCollapsed, setAddEventFormCollapsed] = useState(false) const [addEventFormCollapsed, setAddEventFormCollapsed] = useState(false)
const [tidesCollapsed, setTidesCollapsed] = useState(true)
const [tideHighWater, setTideHighWater] = useState('')
const [tideLowWater, setTideLowWater] = useState('')
const [tideLocation, setTideLocation] = useState<TideLocationMeta>({})
const [tidesLoading, setTidesLoading] = useState(false)
const [tanksCollapsed, setTanksCollapsed] = useState(true) const [tanksCollapsed, setTanksCollapsed] = useState(true)
const [columnSelectorOpen, setColumnSelectorOpen] = useState(false) const [columnSelectorOpen, setColumnSelectorOpen] = useState(false)
@@ -430,6 +445,7 @@ export default function LogEntryEditor({
consumption: parseAppDecimalOrZero(fuelConsumption) consumption: parseAppDecimalOrZero(fuelConsumption)
}, },
greywater: { level: parseAppDecimalOrZero(greywaterLevel) }, greywater: { level: parseAppDecimalOrZero(greywaterLevel) },
tides: { highWater: tideHighWater, lowWater: tideLowWater, ...tideLocation },
trackDistanceNm: parseOptionalFormDecimal(trackDistanceNm), trackDistanceNm: parseOptionalFormDecimal(trackDistanceNm),
trackSpeedMaxKn: parseOptionalFormDecimal(trackSpeedMaxKn), trackSpeedMaxKn: parseOptionalFormDecimal(trackSpeedMaxKn),
trackSpeedAvgKn: parseOptionalFormDecimal(trackSpeedAvgKn), trackSpeedAvgKn: parseOptionalFormDecimal(trackSpeedAvgKn),
@@ -442,6 +458,7 @@ export default function LogEntryEditor({
fwMorning, fwRefilled, fwEvening, fwConsumption, fwMorning, fwRefilled, fwEvening, fwConsumption,
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption, fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
greywaterLevel, greywaterLevel,
tideHighWater, tideLowWater, tideLocation,
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours, trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
events, events,
entryCrew entryCrew
@@ -492,6 +509,11 @@ export default function LogEntryEditor({
[fuelMorning, fuelRefilled, tankCapacities.fuelCapacityL] [fuelMorning, fuelRefilled, tankCapacities.fuelCapacityL]
) )
const tideLocationLabel = useMemo(
() => formatTideLocationLabel(tideLocation, t),
[tideLocation, t]
)
const currentFingerprint = useMemo(() => { const currentFingerprint = useMemo(() => {
const payload = buildPayloadForSigning() const payload = buildPayloadForSigning()
return JSON.stringify({ return JSON.stringify({
@@ -921,6 +943,11 @@ export default function LogEntryEditor({
setGreywaterLevel('0') setGreywaterLevel('0')
} }
const preloadedTides = readLogEntryTides(preloadedEntry as Record<string, unknown>)
setTideHighWater(preloadedTides.highWater)
setTideLowWater(preloadedTides.lowWater)
setTideLocation(pickTideLocationMeta(preloadedTides))
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '') setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '') setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
setEntryCrew(entryCrewFromPreviousEntry(preloadedEntry as Record<string, unknown>)) setEntryCrew(entryCrewFromPreviousEntry(preloadedEntry as Record<string, unknown>))
@@ -962,6 +989,11 @@ export default function LogEntryEditor({
setGreywaterLevel('0') setGreywaterLevel('0')
} }
const loadedTides = readLogEntryTides(decrypted as Record<string, unknown>)
setTideHighWater(loadedTides.highWater)
setTideLowWater(loadedTides.lowWater)
setTideLocation(pickTideLocationMeta(loadedTides))
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '') setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
setSignCrew(normalizeSignature(decrypted.signCrew) || '') setSignCrew(normalizeSignature(decrypted.signCrew) || '')
setEntryCrew(entryCrewFromPreviousEntry(decrypted as Record<string, unknown>)) setEntryCrew(entryCrewFromPreviousEntry(decrypted as Record<string, unknown>))
@@ -1271,6 +1303,74 @@ export default function LogEntryEditor({
} }
} }
const handleFetchTides = async () => {
if (!isOnline) {
showAlert(t('logs.weather_offline'), t('logs.tide_fetch_btn'))
return
}
setTidesLoading(true)
try {
const loaded = await loadEntry(logbookId, entryId)
const eventsForLocation = loaded
? sortLogEventsByTime((loaded.data.events as LogEventPayload[]) || [])
: events
const entryDateForLocation = loaded ? String(loaded.data.date || date) : date
const departureForLocation = loaded ? String(loaded.data.departure || departure) : departure
const location = resolveTideFetchLocation({
events: eventsForLocation,
entryDate: entryDateForLocation,
departure: departureForLocation
})
if ('error' in location) {
if (location.error === 'stale') {
showAlert(t('logs.tide_position_stale'), t('logs.tide_fetch_btn'))
} else {
showAlert(t('logs.tide_location_required'), t('logs.tide_fetch_btn'))
}
return
}
const data =
location.mode === 'nearby'
? await fetchTidesNearby(location.lat, location.lng, {
analyticsSource: 'entry_editor',
locationSource: location.source
})
: await fetchTidesByPlace(location.query, { analyticsSource: 'entry_editor' })
const parsed = parseTideTurtleForDate(data, date)
if (!parsed.highWater && !parsed.lowWater) {
showAlert(t('logs.tide_no_data'), t('logs.tide_fetch_btn'))
return
}
if (parsed.highWater) setTideHighWater(parsed.highWater)
if (parsed.lowWater) setTideLowWater(parsed.lowWater)
setTideLocation(buildTideLocationMeta(location, data))
} catch (err) {
if (err instanceof TidesApiError) {
if (err.code === 'OFFLINE') {
showAlert(t('logs.weather_offline'), t('logs.tide_fetch_btn'))
return
}
if (err.code === 'PLACE_NOT_FOUND') {
showAlert(t('logs.tide_place_not_found', { place: departure.trim() }), t('logs.tide_fetch_btn'))
return
}
if (err.code === 'NOT_FOUND') {
showAlert(t('logs.tide_no_data'), t('logs.tide_fetch_btn'))
return
}
}
console.error('Tide fetch failed:', err)
showAlert(t('logs.tide_fetch_failed'), t('logs.tide_fetch_btn'))
} finally {
setTidesLoading(false)
}
}
const handleGenerateAiSummary = async () => { const handleGenerateAiSummary = async () => {
if (!canSignSkipper || readOnly || aiSummaryLoading) return if (!canSignSkipper || readOnly || aiSummaryLoading) return
if (!getAiAuthorized()) { if (!getAiAuthorized()) {
@@ -2113,6 +2213,77 @@ export default function LogEntryEditor({
</div> </div>
)} )}
{/* Tides */}
<div className="form-card">
<div
className="form-header mb-4 accordion-header"
onClick={() => setTidesCollapsed(!tidesCollapsed)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setTidesCollapsed(!tidesCollapsed)
}
}}
role="button"
aria-expanded={!tidesCollapsed}
tabIndex={0}
>
<div className="accordion-header-title">
<Waves size={20} className="form-icon" />
<h3>{t('logs.tides')}</h3>
</div>
{tidesCollapsed ? <ChevronDown size={20} /> : <ChevronUp size={20} />}
</div>
{!tidesCollapsed && (
<div className="tides-panel">
<div className="tides-panel__hints">
<p className="form-hint" role="note">
{t('logs.tide_disclaimer')}
</p>
{tideLocationLabel ? (
<p className="tides-panel__location" role="status">
{tideLocationLabel}
</p>
) : null}
</div>
<div className="form-grid tides-panel__fields">
<div className="input-group">
<label>{t('logs.tide_high_water')}</label>
<EventTimeInput24h
value={tideHighWater}
onChange={setTideHighWater}
disabled={readOnly || saving || tidesLoading}
aria-label={t('logs.tide_high_water')}
/>
</div>
<div className="input-group">
<label>{t('logs.tide_low_water')}</label>
<EventTimeInput24h
value={tideLowWater}
onChange={setTideLowWater}
disabled={readOnly || saving || tidesLoading}
aria-label={t('logs.tide_low_water')}
/>
</div>
</div>
{!readOnly && (
<div className="tides-panel__actions">
<button
type="button"
className="btn secondary"
onClick={() => void handleFetchTides()}
disabled={saving || tidesLoading}
>
<Waves size={16} />
{tidesLoading ? t('logs.tide_fetch_loading') : t('logs.tide_fetch_btn')}
</button>
</div>
)}
</div>
)}
</div>
{/* Section 2: Tanks (Freshwater, Fuel, and Greywater) */} {/* Section 2: Tanks (Freshwater, Fuel, and Greywater) */}
<div className="form-card"> <div className="form-card">
<div <div
+5 -215
View File
@@ -1,24 +1,14 @@
import { useRef, useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Archive, Download, Upload, Check, AlertTriangle } from 'lucide-react' import { Archive, Download, Check, AlertTriangle } from 'lucide-react'
import { useDialog } from './ModalDialog.tsx'
import { import {
downloadBackupBlob, downloadBackupBlob,
exportLogbookBackup, exportLogbookBackup
formatBackupBytes,
parseLogbookBackupFile,
previewLogbookBackup,
restoreLogbookBackup,
BACKUP_SIZE_CONFIRM_BYTES,
type ParsedLogbookBackup,
type LogbookBackupPreview
} from '../services/logbookBackup.js' } from '../services/logbookBackup.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
interface LogbookBackupPanelProps { interface LogbookBackupPanelProps {
logbookId: string logbookId: string
onRestored?: (logbookId: string, title: string) => void
} }
function mapBackupError(code: string, t: (key: string) => string): string { function mapBackupError(code: string, t: (key: string) => string): string {
@@ -49,21 +39,12 @@ function mapBackupError(code: string, t: (key: string) => string): string {
} }
} }
export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBackupPanelProps) { export default function LogbookBackupPanel({ logbookId }: LogbookBackupPanelProps) {
const { t, i18n } = useTranslation() const { t } = useTranslation()
const { showConfirm } = useDialog()
const fileInputRef = useRef<HTMLInputElement>(null)
const [exportPassphrase, setExportPassphrase] = useState('') const [exportPassphrase, setExportPassphrase] = useState('')
const [exportConfirm, setExportConfirm] = useState('') const [exportConfirm, setExportConfirm] = useState('')
const [exporting, setExporting] = useState(false) const [exporting, setExporting] = useState(false)
const [importPassphrase, setImportPassphrase] = useState('')
const [importFile, setImportFile] = useState<File | null>(null)
const [importPreview, setImportPreview] = useState<LogbookBackupPreview | null>(null)
const [parsedBackup, setParsedBackup] = useState<ParsedLogbookBackup | null>(null)
const [importing, setImporting] = useState(false)
const [previewing, setPreviewing] = useState(false)
const [exportProgress, setExportProgress] = useState<string | null>(null) const [exportProgress, setExportProgress] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null) const [success, setSuccess] = useState<string | null>(null)
@@ -76,11 +57,6 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
await handleExport() await handleExport()
} }
const handleImportSubmit = async (e: React.FormEvent) => {
e.preventDefault()
await handleRestore()
}
const handleExport = async () => { const handleExport = async () => {
setError(null) setError(null)
setSuccess(null) setSuccess(null)
@@ -128,105 +104,6 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
} }
} }
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
setError(null)
setSuccess(null)
setImportPreview(null)
setParsedBackup(null)
const file = e.target.files?.[0]
setImportFile(file ?? null)
if (!file) return
try {
const backup = await parseLogbookBackupFile(file)
setParsedBackup(backup)
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
setError(mapBackupError(message, t))
setImportFile(null)
}
}
const handlePreviewImport = async () => {
if (!parsedBackup || !importPassphrase) return
setPreviewing(true)
setError(null)
try {
const preview = await previewLogbookBackup(parsedBackup, importPassphrase)
setImportPreview(preview)
} catch (err: unknown) {
setImportPreview(null)
setError(t('settings.backup_wrong_passphrase'))
} finally {
setPreviewing(false)
}
}
const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => {
if (!parsedBackup || !importPassphrase) return
if (parsedBackup.manifest.totalUncompressedBytes > BACKUP_SIZE_CONFIRM_BYTES) {
const ok = await showConfirm(
t('settings.backup_import_size_confirm', {
size: formatBackupBytes(parsedBackup.manifest.totalUncompressedBytes)
}),
t('settings.backup_restore_title'),
t('logs.confirm_yes'),
t('logs.confirm_no')
)
if (!ok) return
}
setImporting(true)
setError(null)
try {
const result = await restoreLogbookBackup(parsedBackup, importPassphrase, options)
setSuccess(t('settings.backup_restore_success', { title: result.title }))
setImportFile(null)
setImportPassphrase('')
setImportPreview(null)
setParsedBackup(null)
if (fileInputRef.current) fileInputRef.current.value = ''
trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, {
entries: parsedBackup.manifest.counts.entries,
photos: parsedBackup.manifest.counts.photos,
voiceMemos: parsedBackup.manifest.counts.voiceMemos,
bytes: parsedBackup.manifest.totalUncompressedBytes,
mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id'
})
onRestored?.(result.logbookId, result.title)
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
if (message === 'BACKUP_ID_CONFLICT') {
const overwrite = await showConfirm(
t('settings.backup_overwrite_confirm'),
t('settings.backup_restore_title'),
t('logs.confirm_yes'),
t('logs.confirm_no')
)
if (overwrite) {
setImporting(false)
return handleRestore({ overwrite: true })
}
const asNew = await showConfirm(
t('settings.backup_new_id_confirm'),
t('settings.backup_restore_title'),
t('logs.confirm_yes'),
t('logs.confirm_no')
)
if (asNew) {
setImporting(false)
return handleRestore({ assignNewId: true })
}
setError(t('settings.backup_restore_cancelled'))
} else {
setError(mapBackupError(message, t))
}
} finally {
setImporting(false)
}
}
return ( return (
<div className="member-editor-card glass mt-6 backup-panel" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}> <div className="member-editor-card glass mt-6 backup-panel" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
@@ -306,93 +183,6 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
)} )}
</form> </form>
</section> </section>
<section className="backup-section backup-section--import" aria-labelledby="backup-import-heading">
<h4 id="backup-import-heading" className="backup-section-title">
<Upload size={16} aria-hidden="true" />
{t('settings.backup_restore_title')}
</h4>
<p className="text-muted backup-section-desc">{t('settings.backup_restore_desc')}</p>
<form onSubmit={handleImportSubmit} className="backup-import-form">
<div className="input-group">
<label htmlFor="backup-import-file">{t('settings.backup_file_label')}</label>
<input
id="backup-import-file"
ref={fileInputRef}
type="file"
accept=".daagbok,application/zip"
className="input-text"
onChange={handleFileChange}
disabled={importing}
/>
</div>
{importFile && (
<>
<div className="input-group">
<label htmlFor="backup-import-passphrase">{t('settings.backup_passphrase')}</label>
<input
id="backup-import-passphrase"
name="backup-import-passphrase"
type="password"
className="input-text"
value={importPassphrase}
onChange={(e) => {
setImportPassphrase(e.target.value)
setImportPreview(null)
}}
autoComplete="current-password"
disabled={importing}
required
/>
</div>
<div className="backup-actions-row">
<button
type="button"
className="btn secondary"
onClick={handlePreviewImport}
disabled={previewing || importing || !importPassphrase}
>
{previewing ? t('settings.backup_previewing') : t('settings.backup_preview_btn')}
</button>
<button
type="submit"
className="btn primary"
disabled={importing || !importPassphrase}
>
<Upload size={16} />
{importing ? t('settings.backup_restoring') : t('settings.backup_restore_btn')}
</button>
</div>
</>
)}
</form>
{importPreview && (
<div className="backup-preview glass">
<p className="backup-preview-title">{importPreview.title}</p>
<ul className="backup-preview-stats">
<li>{t('settings.backup_stat_entries', { count: importPreview.counts.entries })}</li>
<li>{t('settings.backup_stat_photos', { count: importPreview.counts.photos })}</li>
<li>{t('settings.backup_stat_voice', { count: importPreview.counts.voiceMemos })}</li>
<li>{t('settings.backup_stat_crew', { count: importPreview.counts.crews })}</li>
<li>{t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}</li>
<li className="text-muted">
{t('settings.backup_stat_size', {
size: formatBackupBytes(importPreview.totalUncompressedBytes)
})}
</li>
</ul>
<p className="text-muted backup-preview-date">
{t('settings.backup_exported_at', {
date: formatAppDateTime(importPreview.exportedAt, i18n.language)
})}
</p>
</div>
)}
</section>
</div> </div>
) )
} }
+21 -1
View File
@@ -11,11 +11,12 @@ import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js' import { getErrorMessage } from '../utils/errors.js'
import { logoutUser } from '../services/auth.js' import { logoutUser } from '../services/auth.js'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import { BookOpen, Plus, Trash2, LogOut, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react' import { BookOpen, Plus, Trash2, LogOut, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown, Upload } from 'lucide-react'
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx' import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx' import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
import ProfileHeaderButton from './ProfileHeaderButton.tsx' import ProfileHeaderButton from './ProfileHeaderButton.tsx'
import AdminHeaderButton from './AdminHeaderButton.tsx' import AdminHeaderButton from './AdminHeaderButton.tsx'
import LogbookRestorePanel from './LogbookRestorePanel.tsx'
interface LogbookDashboardProps { interface LogbookDashboardProps {
onSelectLogbook: (id: string, title: string) => void onSelectLogbook: (id: string, title: string) => void
@@ -67,6 +68,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc') const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
const filterInputRef = useRef<HTMLInputElement>(null) const filterInputRef = useRef<HTMLInputElement>(null)
const [online, setOnline] = useState(navigator.onLine) const [online, setOnline] = useState(navigator.onLine)
const [showRestore, setShowRestore] = useState(false)
const { pendingCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator() const { pendingCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator()
@@ -434,6 +436,24 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
</form> </form>
{error && <div className="auth-error mt-4">{error}</div>} {error && <div className="auth-error mt-4">{error}</div>}
<div style={{ marginTop: '20px', borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: '16px', textAlign: 'center' }}>
<button
type="button"
className="btn-link"
style={{ fontSize: '13.5px', color: 'var(--app-text-muted)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: '6px' }}
onClick={() => setShowRestore(!showRestore)}
>
<Upload size={14} />
{t('settings.backup_restore_title')}
</button>
</div>
{showRestore && (
<div style={{ marginTop: '16px', textAlign: 'left' }}>
<LogbookRestorePanel onRestored={onSelectLogbook} />
</div>
)}
</section> </section>
{/* Right Side: Logbooks list */} {/* Right Side: Logbooks list */}
@@ -0,0 +1,275 @@
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Upload, Check, AlertTriangle } from 'lucide-react'
import { useDialog } from './ModalDialog.tsx'
import {
parseLogbookBackupFile,
previewLogbookBackup,
restoreLogbookBackup,
formatBackupBytes,
BACKUP_SIZE_CONFIRM_BYTES,
type ParsedLogbookBackup,
type LogbookBackupPreview
} from '../services/logbookBackup.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
interface LogbookRestorePanelProps {
onRestored?: (logbookId: string, title: string) => void
}
function mapBackupError(code: string, t: (key: string) => string): string {
switch (code) {
case 'BACKUP_PASSPHRASE_TOO_SHORT':
return t('settings.backup_passphrase_short')
case 'BACKUP_NOT_OWNER':
return t('settings.backup_not_owner')
case 'BACKUP_INVALID_JSON':
return t('settings.backup_invalid_json')
case 'BACKUP_INVALID_ARCHIVE':
return t('settings.backup_invalid_archive')
case 'BACKUP_VERSION_UNSUPPORTED':
return t('settings.backup_version_unsupported')
case 'BACKUP_WRONG_PASSPHRASE':
return t('settings.backup_wrong_passphrase')
case 'BACKUP_INVALID_FORMAT':
return t('settings.backup_invalid_format')
case 'BACKUP_NOT_AUTHENTICATED':
return t('settings.backup_not_authenticated')
case 'BACKUP_ID_CONFLICT':
return t('settings.backup_id_conflict')
default:
if (code.includes('decrypt') || code.includes('operation')) {
return t('settings.backup_wrong_passphrase')
}
return code
}
}
export default function LogbookRestorePanel({ onRestored }: LogbookRestorePanelProps) {
const { t, i18n } = useTranslation()
const { showConfirm } = useDialog()
const fileInputRef = useRef<HTMLInputElement>(null)
const [importPassphrase, setImportPassphrase] = useState('')
const [importFile, setImportFile] = useState<File | null>(null)
const [importPreview, setImportPreview] = useState<LogbookBackupPreview | null>(null)
const [parsedBackup, setParsedBackup] = useState<ParsedLogbookBackup | null>(null)
const [importing, setImporting] = useState(false)
const [previewing, setPreviewing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const handleImportSubmit = async (e: React.FormEvent) => {
e.preventDefault()
await handleRestore()
}
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
setError(null)
setSuccess(null)
setImportPreview(null)
setParsedBackup(null)
const file = e.target.files?.[0]
setImportFile(file ?? null)
if (!file) return
try {
const backup = await parseLogbookBackupFile(file)
setParsedBackup(backup)
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
setError(mapBackupError(message, t))
setImportFile(null)
}
}
const handlePreviewImport = async () => {
if (!parsedBackup || !importPassphrase) return
setPreviewing(true)
setError(null)
try {
const preview = await previewLogbookBackup(parsedBackup, importPassphrase)
setImportPreview(preview)
} catch (err: unknown) {
setImportPreview(null)
setError(t('settings.backup_wrong_passphrase'))
} finally {
setPreviewing(false)
}
}
const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => {
if (!parsedBackup || !importPassphrase) return
if (parsedBackup.manifest.totalUncompressedBytes > BACKUP_SIZE_CONFIRM_BYTES) {
const ok = await showConfirm(
t('settings.backup_import_size_confirm', {
size: formatBackupBytes(parsedBackup.manifest.totalUncompressedBytes)
}),
t('settings.backup_restore_title'),
t('logs.confirm_yes'),
t('logs.confirm_no')
)
if (!ok) return
}
setImporting(true)
setError(null)
try {
const result = await restoreLogbookBackup(parsedBackup, importPassphrase, options)
setSuccess(t('settings.backup_restore_success', { title: result.title }))
setImportFile(null)
setImportPassphrase('')
setImportPreview(null)
setParsedBackup(null)
if (fileInputRef.current) fileInputRef.current.value = ''
trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, {
entries: parsedBackup.manifest.counts.entries,
photos: parsedBackup.manifest.counts.photos,
voiceMemos: parsedBackup.manifest.counts.voiceMemos,
bytes: parsedBackup.manifest.totalUncompressedBytes,
mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id'
})
onRestored?.(result.logbookId, result.title)
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
if (message === 'BACKUP_ID_CONFLICT') {
const overwrite = await showConfirm(
t('settings.backup_overwrite_confirm'),
t('settings.backup_restore_title'),
t('logs.confirm_yes'),
t('logs.confirm_no')
)
if (overwrite) {
setImporting(false)
return handleRestore({ overwrite: true })
}
const asNew = await showConfirm(
t('settings.backup_new_id_confirm'),
t('settings.backup_restore_title'),
t('logs.confirm_yes'),
t('logs.confirm_no')
)
if (asNew) {
setImporting(false)
return handleRestore({ assignNewId: true })
}
setError(t('settings.backup_restore_cancelled'))
} else {
setError(mapBackupError(message, t))
}
} finally {
setImporting(false)
}
}
return (
<div className="backup-section backup-section--import" aria-labelledby="backup-import-heading" style={{ marginTop: '8px' }}>
<p className="text-muted backup-section-desc" style={{ fontSize: '13px', margin: '0 0 16px 0', textAlign: 'left', lineHeight: '1.4' }}>
{t('settings.backup_restore_desc')}
</p>
{error && (
<div className="auth-error mb-4" role="alert" style={{ textAlign: 'left' }}>
<AlertTriangle size={16} style={{ display: 'inline', marginRight: 6, verticalAlign: 'text-bottom' }} />
{error}
</div>
)}
{success && (
<div className="success-toast mb-4" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Check size={16} />
<span>{success}</span>
</div>
)}
<form onSubmit={handleImportSubmit} className="backup-import-form" style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div className="input-group" style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<label htmlFor="backup-import-file" style={{ fontSize: '12px', fontWeight: 600, color: 'var(--app-text-muted)', textAlign: 'left' }}>
{t('settings.backup_file_label')}
</label>
<input
id="backup-import-file"
ref={fileInputRef}
type="file"
accept=".daagbok,application/zip"
className="input-text"
onChange={handleFileChange}
disabled={importing}
style={{ width: '100%', boxSizing: 'border-box' }}
/>
</div>
{importFile && (
<>
<div className="input-group" style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<label htmlFor="backup-import-passphrase" style={{ fontSize: '12px', fontWeight: 600, color: 'var(--app-text-muted)', textAlign: 'left' }}>
{t('settings.backup_passphrase')}
</label>
<input
id="backup-import-passphrase"
name="backup-import-passphrase"
type="password"
className="input-text"
value={importPassphrase}
onChange={(e) => {
setImportPassphrase(e.target.value)
setImportPreview(null)
}}
autoComplete="current-password"
disabled={importing}
required
style={{ width: '100%', boxSizing: 'border-box' }}
/>
</div>
<div className="backup-actions-row" style={{ display: 'flex', gap: '10px' }}>
<button
type="button"
className="btn secondary"
onClick={handlePreviewImport}
disabled={previewing || importing || !importPassphrase}
style={{ flex: 1, padding: '10px' }}
>
{previewing ? t('settings.backup_previewing') : t('settings.backup_preview_btn')}
</button>
<button
type="submit"
className="btn primary"
disabled={importing || !importPassphrase}
style={{ flex: 1, padding: '10px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: '6px' }}
>
<Upload size={16} />
{importing ? t('settings.backup_restoring') : t('settings.backup_restore_btn')}
</button>
</div>
</>
)}
</form>
{importPreview && (
<div className="backup-preview glass" style={{ marginTop: '16px', padding: '16px', borderRadius: '12px', border: '1px solid var(--app-border-subtle)', background: 'var(--app-surface-inset, rgba(0, 0, 0, 0.2))', textAlign: 'left' }}>
<p className="backup-preview-title" style={{ fontWeight: 600, margin: '0 0 10px 0', fontSize: '14px', color: 'var(--app-text-heading)' }}>{importPreview.title}</p>
<ul className="backup-preview-stats" style={{ listStyle: 'none', padding: 0, margin: '0 0 10px 0', display: 'flex', flexDirection: 'column', gap: '6px', fontSize: '13px', color: 'var(--app-text)' }}>
<li>{t('settings.backup_stat_entries', { count: importPreview.counts.entries })}</li>
<li>{t('settings.backup_stat_photos', { count: importPreview.counts.photos })}</li>
<li>{t('settings.backup_stat_voice', { count: importPreview.counts.voiceMemos })}</li>
<li>{t('settings.backup_stat_crew', { count: importPreview.counts.crews })}</li>
<li>{t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}</li>
<li style={{ color: 'var(--app-text-muted)' }}>
{t('settings.backup_stat_size', {
size: formatBackupBytes(importPreview.totalUncompressedBytes)
})}
</li>
</ul>
<p className="text-muted backup-preview-date" style={{ fontSize: '11px', margin: 0, color: 'var(--app-text-muted)' }}>
{t('settings.backup_exported_at', {
date: formatAppDateTime(importPreview.exportedAt, i18n.language)
})}
</p>
</div>
)}
</div>
)
}
+76 -2
View File
@@ -9,7 +9,7 @@ import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.j
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js' import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
import { useLiveQuery } from 'dexie-react-hooks' import { useLiveQuery } from 'dexie-react-hooks'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import { Camera, Image, Trash2, X, ChevronDown, ChevronUp } from 'lucide-react' import { Camera, Image, Trash2, X, ChevronDown, ChevronUp, ChevronLeft, ChevronRight } from 'lucide-react'
import { probeCameraAvailability } from '../utils/cameraAvailability.js' import { probeCameraAvailability } from '../utils/cameraAvailability.js'
interface PhotoCaptureProps { interface PhotoCaptureProps {
@@ -39,6 +39,46 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const cameraInputRef = 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(() => { useEffect(() => {
if (!maximizedPhoto) return if (!maximizedPhoto) return
@@ -46,6 +86,10 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
setMaximizedPhoto(null) setMaximizedPhoto(null)
} else if (e.key === 'ArrowLeft' || e.key === 'Left') {
goToPrev()
} else if (e.key === 'ArrowRight' || e.key === 'Right') {
goToNext()
} }
} }
@@ -53,7 +97,7 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
return () => { return () => {
window.removeEventListener('keydown', handleKeyDown) window.removeEventListener('keydown', handleKeyDown)
} }
}, [maximizedPhoto]) }, [maximizedPhoto, decryptedPhotos])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -323,7 +367,37 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
<div <div
className="photo-maximized-overlay" className="photo-maximized-overlay"
onClick={() => setMaximizedPhoto(null)} 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()}> <div className="photo-maximized-container" onClick={(e) => e.stopPropagation()}>
<button <button
type="button" type="button"
+32 -4
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next' 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 { ensureLogbookKey } from '../services/logbookKeys.js'
import LogbookBackupPanel from './LogbookBackupPanel.tsx' import LogbookBackupPanel from './LogbookBackupPanel.tsx'
import LinkQrCode from './LinkQrCode.tsx' import LinkQrCode from './LinkQrCode.tsx'
@@ -17,7 +17,6 @@ import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
interface SettingsFormProps { interface SettingsFormProps {
logbookId?: string | null logbookId?: string | null
onLogbookRestored?: (logbookId: string, title: string) => void
} }
interface Collaborator { interface Collaborator {
@@ -34,7 +33,7 @@ const bufferToHex = (buffer: ArrayBuffer): string => {
.join('') .join('')
} }
export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsFormProps) { export default function SettingsForm({ logbookId }: SettingsFormProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { showConfirm, showAlert } = useDialog() const { showConfirm, showAlert } = useDialog()
@@ -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 () => { const loadCollaborators = async () => {
setLoadingCollabs(true) setLoadingCollabs(true)
setCollabError(null) setCollabError(null)
@@ -337,6 +354,17 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
> >
{shareCopied ? <Check size={16} /> : <Copy size={16} />} {shareCopied ? <Check size={16} /> : <Copy size={16} />}
</button> </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> </div>
<LinkQrCode value={shareLink} /> <LinkQrCode value={shareLink} />
</div> </div>
@@ -345,7 +373,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
)} )}
{logbookId && isOwner && ( {logbookId && isOwner && (
<LogbookBackupPanel logbookId={logbookId} onRestored={onLogbookRestored} /> <LogbookBackupPanel logbookId={logbookId} />
)} )}
{logbookId && isOwner && ( {logbookId && isOwner && (
+26 -1
View File
@@ -36,7 +36,9 @@
"unsaved_changes_stay": "Blive", "unsaved_changes_stay": "Blive",
"unsaved_changes_save_leave": "Gem og afslut", "unsaved_changes_save_leave": "Gem og afslut",
"unsaved_changes_discard": "Afvis", "unsaved_changes_discard": "Afvis",
"unsaved_changes_leave": "Forladt" "unsaved_changes_leave": "Forladt",
"previous": "Forrige",
"next": "Næste"
}, },
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -188,6 +190,25 @@
"departure": "Afgangshavn (rejse fra)", "departure": "Afgangshavn (rejse fra)",
"destination": "Ankomsthavn (til)", "destination": "Ankomsthavn (til)",
"route": "Rejse fra/til", "route": "Rejse fra/til",
"tides": "Tidevand",
"tide_high_water": "Højvande",
"tide_low_water": "Lavvande",
"tide_fetch_btn": "Hent tidevand",
"tide_fetch_loading": "Henter tidevand…",
"tide_disclaimer": "Ingen garanti for rigtighed — kontrollér oplysningerne mod officielle kilder!",
"tide_location_required": "Tidevandsopslag kræver en aktuel position (max. 2 timer) eller en afgangshavn.",
"tide_position_stale": "Den sidste position er ældre end 2 timer. Log position igen eller angiv afgangshavn.",
"tide_fetch_failed": "Tidevand kunne ikke hentes.",
"tide_no_data": "Ingen tidevandsdata for dette sted.",
"tide_place_not_found": "“{{place}}” kunne ikke findes — angiv en kystby eller havn.",
"tide_fetched_at_position": "Modelprognose ved aktuel position (Open-Meteo Marine).",
"tide_data_for_position": "Forespørgsel for position {{lat}}, {{lng}}",
"tide_data_for_place": "Forespørgsel for {{place}}",
"tide_data_for_place_and_position": "Forespørgsel for {{place}} ({{lat}}, {{lng}})",
"tide_fetched_from": "Data fra {{place}} (ca. {{distance}} km væk)",
"tide_fetched_from_departure": "Tidevand baseret på afgang “{{place}}” (ingen aktuel GPS-position).",
"tide_applied_success": "Tidevand overført: højvande {{highWater}}, lavvande {{lowWater}}. Synligt i rejsedagseditoren under “Tidevand”.",
"tide_apply": "Anvend",
"tanks": "Tanke", "tanks": "Tanke",
"customize_columns": "Tilpas kolonner", "customize_columns": "Tilpas kolonner",
"column_selector_title": "Kolonner, der skal vises", "column_selector_title": "Kolonner, der skal vises",
@@ -445,6 +466,9 @@
"ai_summary_error_forbidden": "Kun skipperen må generere AI-opsummeringer.", "ai_summary_error_forbidden": "Kun skipperen må generere AI-opsummeringer.",
"ai_summary_offline": "AI-opsummeringen kræver en internetforbindelse. Du er i øjeblikket offline.", "ai_summary_offline": "AI-opsummeringen kræver en internetforbindelse. Du er i øjeblikket offline.",
"photos_title": "Fotobilag", "photos_title": "Fotobilag",
"export_photos_zip": "Download fotos (ZIP)",
"exporting_photos_zip": "Opretter ZIP...",
"no_photos_to_download": "Ingen fotos fundet i denne logbog.",
"photo_caption_label": "Billedbeskrivelse / Etiket (valgfrit)", "photo_caption_label": "Billedbeskrivelse / Etiket (valgfrit)",
"photo_caption_placeholder": "f.eks. sætte sejl tæt på havneindsejlingen", "photo_caption_placeholder": "f.eks. sætte sejl tæt på havneindsejlingen",
"photo_btn": "Tag/upload et billede", "photo_btn": "Tag/upload et billede",
@@ -820,6 +844,7 @@
"share_enable": "Aktivér offentligt link", "share_enable": "Aktivér offentligt link",
"share_copied": "Linket er kopieret!", "share_copied": "Linket er kopieret!",
"share_copy_btn": "Kopier link", "share_copy_btn": "Kopier link",
"share_btn": "Del link",
"link_qr_hint": "QR-kode til scanning med en smartphone", "link_qr_hint": "QR-kode til scanning med en smartphone",
"link_qr_alt": "QR-kode til linket", "link_qr_alt": "QR-kode til linket",
"danger_zone_title": "Farezone", "danger_zone_title": "Farezone",
+26 -1
View File
@@ -36,7 +36,9 @@
"unsaved_changes_stay": "Bleiben", "unsaved_changes_stay": "Bleiben",
"unsaved_changes_save_leave": "Speichern & verlassen", "unsaved_changes_save_leave": "Speichern & verlassen",
"unsaved_changes_discard": "Verwerfen", "unsaved_changes_discard": "Verwerfen",
"unsaved_changes_leave": "Verlassen" "unsaved_changes_leave": "Verlassen",
"previous": "Zurück",
"next": "Weiter"
}, },
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -188,6 +190,25 @@
"departure": "Start-Hafen (Reise von)", "departure": "Start-Hafen (Reise von)",
"destination": "Ziel-Hafen (nach)", "destination": "Ziel-Hafen (nach)",
"route": "Reise von/nach", "route": "Reise von/nach",
"tides": "Tiden",
"tide_high_water": "Hochwasser",
"tide_low_water": "Niedrigwasser",
"tide_fetch_btn": "Gezeiten abrufen",
"tide_fetch_loading": "Gezeiten werden geladen…",
"tide_disclaimer": "Keine Gewähr auf Richtigkeit — überprüfe die Informationen anhand offizieller Quellen!",
"tide_location_required": "Für den Gezeiten-Abruf wird eine aktuelle Position (max. 2 Stunden alt) oder ein Abfahrtsort benötigt.",
"tide_position_stale": "Die letzte Position ist älter als 2 Stunden. Bitte Position erneut setzen oder Abfahrtsort eintragen.",
"tide_fetch_failed": "Gezeiten konnten nicht abgerufen werden.",
"tide_no_data": "Für diesen Ort liegen keine Gezeitendaten vor.",
"tide_place_not_found": "„{{place}}“ konnte nicht geortet werden — bitte einen Küstenort oder Hafen angeben.",
"tide_fetched_at_position": "Modellprognose am aktuellen Standort (Open-Meteo Marine).",
"tide_data_for_position": "Abfrage für Position {{lat}}, {{lng}}",
"tide_data_for_place": "Abfrage für {{place}}",
"tide_data_for_place_and_position": "Abfrage für {{place}} ({{lat}}, {{lng}})",
"tide_fetched_from": "Daten von {{place}} (ca. {{distance}} km entfernt)",
"tide_fetched_from_departure": "Gezeiten basierend auf Abfahrtsort „{{place}}“ (keine aktuelle GPS-Position).",
"tide_applied_success": "Gezeiten übernommen: Hochwasser {{highWater}}, Niedrigwasser {{lowWater}}. Im Reisetag-Editor unter „Tiden“ sichtbar.",
"tide_apply": "Übernehmen",
"tanks": "Tanks", "tanks": "Tanks",
"customize_columns": "Spalten anpassen", "customize_columns": "Spalten anpassen",
"column_selector_title": "Anzuzeigende Spalten", "column_selector_title": "Anzuzeigende Spalten",
@@ -445,6 +466,9 @@
"ai_summary_error_forbidden": "Nur der Skipper darf KI-Zusammenfassungen generieren.", "ai_summary_error_forbidden": "Nur der Skipper darf KI-Zusammenfassungen generieren.",
"ai_summary_offline": "Die KI-Zusammenfassung erfordert eine Internetverbindung. Du bist derzeit offline.", "ai_summary_offline": "Die KI-Zusammenfassung erfordert eine Internetverbindung. Du bist derzeit offline.",
"photos_title": "Foto-Anhänge", "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_label": "Foto-Beschreibung / Label (Optional)",
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt", "photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
"photo_btn": "Foto aufnehmen / Hochladen", "photo_btn": "Foto aufnehmen / Hochladen",
@@ -820,6 +844,7 @@
"share_enable": "Öffentlichen Link aktivieren", "share_enable": "Öffentlichen Link aktivieren",
"share_copied": "Link kopiert!", "share_copied": "Link kopiert!",
"share_copy_btn": "Link kopieren", "share_copy_btn": "Link kopieren",
"share_btn": "Link teilen",
"link_qr_hint": "QR-Code zum Scannen mit dem Smartphone", "link_qr_hint": "QR-Code zum Scannen mit dem Smartphone",
"link_qr_alt": "QR-Code für den Link", "link_qr_alt": "QR-Code für den Link",
"danger_zone_title": "Gefahrenzone", "danger_zone_title": "Gefahrenzone",
+26 -1
View File
@@ -36,7 +36,9 @@
"unsaved_changes_stay": "Stay", "unsaved_changes_stay": "Stay",
"unsaved_changes_save_leave": "Save & leave", "unsaved_changes_save_leave": "Save & leave",
"unsaved_changes_discard": "Discard", "unsaved_changes_discard": "Discard",
"unsaved_changes_leave": "Leave" "unsaved_changes_leave": "Leave",
"previous": "Previous",
"next": "Next"
}, },
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -188,6 +190,25 @@
"departure": "Departure Port (von)", "departure": "Departure Port (von)",
"destination": "Destination Port (nach)", "destination": "Destination Port (nach)",
"route": "Route / Journey", "route": "Route / Journey",
"tides": "Tides",
"tide_high_water": "High water",
"tide_low_water": "Low water",
"tide_fetch_btn": "Fetch tides",
"tide_fetch_loading": "Loading tides…",
"tide_disclaimer": "No guarantee of accuracy — verify against official sources!",
"tide_location_required": "Tide lookup needs a current position (max. 2 hours old) or a departure port.",
"tide_position_stale": "The last position is older than 2 hours. Log position again or enter a departure port.",
"tide_fetch_failed": "Could not fetch tide data.",
"tide_no_data": "No tide data available for this location.",
"tide_place_not_found": "“{{place}}” could not be geocoded — please use a coastal place or harbour name.",
"tide_fetched_at_position": "Model forecast at current position (Open-Meteo Marine).",
"tide_data_for_position": "Query for position {{lat}}, {{lng}}",
"tide_data_for_place": "Query for {{place}}",
"tide_data_for_place_and_position": "Query for {{place}} ({{lat}}, {{lng}})",
"tide_fetched_from": "Data from {{place}} (about {{distance}} km away)",
"tide_fetched_from_departure": "Tides based on departure “{{place}}” (no current GPS position).",
"tide_applied_success": "Tides applied: high water {{highWater}}, low water {{lowWater}}. Visible in the travel day editor under “Tides”.",
"tide_apply": "Apply",
"tanks": "Tanks", "tanks": "Tanks",
"customize_columns": "Customize columns", "customize_columns": "Customize columns",
"column_selector_title": "Columns to Show", "column_selector_title": "Columns to Show",
@@ -445,6 +466,9 @@
"ai_summary_error_forbidden": "Only the skipper may generate AI summaries.", "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.", "ai_summary_offline": "AI summary generation requires an internet connection. You are currently offline.",
"photos_title": "Photo Attachments", "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_label": "Photo Caption / Label (Optional)",
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance", "photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
"photo_btn": "Take Photo / Upload", "photo_btn": "Take Photo / Upload",
@@ -820,6 +844,7 @@
"share_enable": "Enable Public Link", "share_enable": "Enable Public Link",
"share_copied": "Link copied!", "share_copied": "Link copied!",
"share_copy_btn": "Copy Link", "share_copy_btn": "Copy Link",
"share_btn": "Share Link",
"link_qr_hint": "Scan this QR code with your phone", "link_qr_hint": "Scan this QR code with your phone",
"link_qr_alt": "QR code for the link", "link_qr_alt": "QR code for the link",
"danger_zone_title": "Danger Zone", "danger_zone_title": "Danger Zone",
+26 -1
View File
@@ -36,7 +36,9 @@
"unsaved_changes_stay": "Quedarse", "unsaved_changes_stay": "Quedarse",
"unsaved_changes_save_leave": "Guardar y salir", "unsaved_changes_save_leave": "Guardar y salir",
"unsaved_changes_discard": "Descartar", "unsaved_changes_discard": "Descartar",
"unsaved_changes_leave": "Abandonado" "unsaved_changes_leave": "Abandonado",
"previous": "Anterior",
"next": "Siguiente"
}, },
"nav": { "nav": {
"dashboard": "Panel de control", "dashboard": "Panel de control",
@@ -188,6 +190,25 @@
"departure": "Puerto de salida (viaje desde)", "departure": "Puerto de salida (viaje desde)",
"destination": "Puerto de destino (a)", "destination": "Puerto de destino (a)",
"route": "Viaje desde/hacia", "route": "Viaje desde/hacia",
"tides": "Mareas",
"tide_high_water": "Pleamar",
"tide_low_water": "Bajamar",
"tide_fetch_btn": "Obtener mareas",
"tide_fetch_loading": "Cargando mareas…",
"tide_disclaimer": "Sin garantía de exactitud — comprueba con fuentes oficiales.",
"tide_location_required": "Las mareas requieren una posición actual (máx. 2 h) o un puerto de salida.",
"tide_position_stale": "La última posición tiene más de 2 horas. Registra la posición o indica el puerto de salida.",
"tide_fetch_failed": "No se pudieron obtener las mareas.",
"tide_no_data": "No hay datos de marea para este lugar.",
"tide_place_not_found": "«{{place}}» no se encontró — indica un lugar costero o puerto.",
"tide_fetched_at_position": "Pronóstico modelo en la posición actual (Open-Meteo Marine).",
"tide_data_for_position": "Consulta para la posición {{lat}}, {{lng}}",
"tide_data_for_place": "Consulta para {{place}}",
"tide_data_for_place_and_position": "Consulta para {{place}} ({{lat}}, {{lng}})",
"tide_fetched_from": "Datos de {{place}} (aprox. {{distance}} km)",
"tide_fetched_from_departure": "Mareas según salida «{{place}}» (sin posición GPS actual).",
"tide_applied_success": "Mareas guardadas: pleamar {{highWater}}, bajamar {{lowWater}}. Visible en el editor del día de viaje, sección «Mareas».",
"tide_apply": "Aplicar",
"tanks": "Depósitos", "tanks": "Depósitos",
"customize_columns": "Ajustar columnas", "customize_columns": "Ajustar columnas",
"column_selector_title": "Columnas que se deben mostrar", "column_selector_title": "Columnas que se deben mostrar",
@@ -445,6 +466,9 @@
"ai_summary_error_forbidden": "Solo el capitán puede generar resúmenes de IA.", "ai_summary_error_forbidden": "Solo el capitán puede generar resúmenes de IA.",
"ai_summary_offline": "El resumen generado por IA requiere una conexión a Internet. Actualmente no tienes conexión.", "ai_summary_offline": "El resumen generado por IA requiere una conexión a Internet. Actualmente no tienes conexión.",
"photos_title": "Archivos adjuntos con fotos", "photos_title": "Archivos adjuntos con fotos",
"export_photos_zip": "Descargar fotos (ZIP)",
"exporting_photos_zip": "Creando archivo ZIP...",
"no_photos_to_download": "No hay fotos disponibles en este cuaderno de bitácora.",
"photo_caption_label": "Descripción de la foto / Etiqueta (opcional)", "photo_caption_label": "Descripción de la foto / Etiqueta (opcional)",
"photo_caption_placeholder": "p. ej., izar las velas cerca de la entrada del puerto", "photo_caption_placeholder": "p. ej., izar las velas cerca de la entrada del puerto",
"photo_btn": "Hacer una foto / Subir una foto", "photo_btn": "Hacer una foto / Subir una foto",
@@ -820,6 +844,7 @@
"share_enable": "Activar enlace público", "share_enable": "Activar enlace público",
"share_copied": "¡Enlace copiado!", "share_copied": "¡Enlace copiado!",
"share_copy_btn": "Copiar enlace", "share_copy_btn": "Copiar enlace",
"share_btn": "Compartir enlace",
"link_qr_hint": "Código QR para escanear con el smartphone", "link_qr_hint": "Código QR para escanear con el smartphone",
"link_qr_alt": "Código QR del enlace", "link_qr_alt": "Código QR del enlace",
"danger_zone_title": "Zona de peligro", "danger_zone_title": "Zona de peligro",
+26 -1
View File
@@ -36,7 +36,9 @@
"unsaved_changes_stay": "Rester", "unsaved_changes_stay": "Rester",
"unsaved_changes_save_leave": "Enregistrer et quitter", "unsaved_changes_save_leave": "Enregistrer et quitter",
"unsaved_changes_discard": "Rejeter", "unsaved_changes_discard": "Rejeter",
"unsaved_changes_leave": "Quitter" "unsaved_changes_leave": "Quitter",
"previous": "Précédent",
"next": "Suivant"
}, },
"nav": { "nav": {
"dashboard": "Tableau de bord", "dashboard": "Tableau de bord",
@@ -188,6 +190,25 @@
"departure": "Port de départ (départ de)", "departure": "Port de départ (départ de)",
"destination": "Port de destination (vers)", "destination": "Port de destination (vers)",
"route": "Voyage au départ de/à destination de", "route": "Voyage au départ de/à destination de",
"tides": "Marées",
"tide_high_water": "Pleine mer",
"tide_low_water": "Basse mer",
"tide_fetch_btn": "Récupérer les marées",
"tide_fetch_loading": "Chargement des marées…",
"tide_disclaimer": "Aucune garantie d'exactitude — vérifiez auprès de sources officielles !",
"tide_location_required": "Les marées nécessitent une position actuelle (max. 2 h) ou un port de départ.",
"tide_position_stale": "La dernière position date de plus de 2 heures. Enregistrez la position ou indiquez le port de départ.",
"tide_fetch_failed": "Impossible de récupérer les marées.",
"tide_no_data": "Aucune donnée de marée pour cet endroit.",
"tide_place_not_found": "« {{place}} » introuvable — indiquez un lieu côtier ou un port.",
"tide_fetched_at_position": "Prévision modèle à la position actuelle (Open-Meteo Marine).",
"tide_data_for_position": "Requête pour la position {{lat}}, {{lng}}",
"tide_data_for_place": "Requête pour {{place}}",
"tide_data_for_place_and_position": "Requête pour {{place}} ({{lat}}, {{lng}})",
"tide_fetched_from": "Données de {{place}} (env. {{distance}} km)",
"tide_fetched_from_departure": "Marées basées sur le départ « {{place}} » (pas de position GPS actuelle).",
"tide_applied_success": "Marées enregistrées : pleine mer {{highWater}}, basse mer {{lowWater}}. Visible dans l’éditeur du jour de voyage, section « Marées ».",
"tide_apply": "Appliquer",
"tanks": "Réservoirs", "tanks": "Réservoirs",
"customize_columns": "Ajuster les colonnes", "customize_columns": "Ajuster les colonnes",
"column_selector_title": "Colonnes à afficher", "column_selector_title": "Colonnes à afficher",
@@ -445,6 +466,9 @@
"ai_summary_error_forbidden": "Seul le skipper est autorisé à générer des résumés basés sur l'IA.", "ai_summary_error_forbidden": "Seul le skipper est autorisé à générer des résumés basés sur l'IA.",
"ai_summary_offline": "Le résumé généré par l'IA nécessite une connexion Internet. Tu es actuellement hors ligne.", "ai_summary_offline": "Le résumé généré par l'IA nécessite une connexion Internet. Tu es actuellement hors ligne.",
"photos_title": "Pièces jointes (photos)", "photos_title": "Pièces jointes (photos)",
"export_photos_zip": "Télécharger les photos (ZIP)",
"exporting_photos_zip": "Création du fichier ZIP...",
"no_photos_to_download": "Aucune photo disponible dans ce journal.",
"photo_caption_label": "Description de la photo / Étiquette (facultatif)", "photo_caption_label": "Description de la photo / Étiquette (facultatif)",
"photo_caption_placeholder": "par exemple, hisser les voiles près de l'entrée du port", "photo_caption_placeholder": "par exemple, hisser les voiles près de l'entrée du port",
"photo_btn": "Prendre une photo / Télécharger une photo", "photo_btn": "Prendre une photo / Télécharger une photo",
@@ -820,6 +844,7 @@
"share_enable": "Activer le lien public", "share_enable": "Activer le lien public",
"share_copied": "Lien copié !", "share_copied": "Lien copié !",
"share_copy_btn": "Copier le lien", "share_copy_btn": "Copier le lien",
"share_btn": "Partager le lien",
"link_qr_hint": "Code QR à scanner avec un smartphone", "link_qr_hint": "Code QR à scanner avec un smartphone",
"link_qr_alt": "Code QR pour le lien", "link_qr_alt": "Code QR pour le lien",
"danger_zone_title": "Zone dangereuse", "danger_zone_title": "Zone dangereuse",
+26 -1
View File
@@ -36,7 +36,9 @@
"unsaved_changes_stay": "Bli", "unsaved_changes_stay": "Bli",
"unsaved_changes_save_leave": "Lagre og avslutt", "unsaved_changes_save_leave": "Lagre og avslutt",
"unsaved_changes_discard": "Avvis", "unsaved_changes_discard": "Avvis",
"unsaved_changes_leave": "Forlatt" "unsaved_changes_leave": "Forlatt",
"previous": "Forrige",
"next": "Neste"
}, },
"nav": { "nav": {
"dashboard": "Dashbord", "dashboard": "Dashbord",
@@ -188,6 +190,25 @@
"departure": "Avreisehavn (reise fra)", "departure": "Avreisehavn (reise fra)",
"destination": "Ankomsthavn (til)", "destination": "Ankomsthavn (til)",
"route": "Reise fra/til", "route": "Reise fra/til",
"tides": "Tidevann",
"tide_high_water": "Høyvann",
"tide_low_water": "Lavvann",
"tide_fetch_btn": "Hent tidevann",
"tide_fetch_loading": "Henter tidevann…",
"tide_disclaimer": "Ingen garanti for riktighet — kontroller opplysningene mot offisielle kilder!",
"tide_location_required": "Tidevann krever aktuell posisjon (maks 2 timer) eller avreisehavn.",
"tide_position_stale": "Siste posisjon er eldre enn 2 timer. Logg posisjon på nytt eller angi avreisehavn.",
"tide_fetch_failed": "Kunne ikke hente tidevann.",
"tide_no_data": "Ingen tidevannsdata for dette stedet.",
"tide_place_not_found": "«{{place}}» ble ikke funnet — oppgi en kyststad eller havn.",
"tide_fetched_at_position": "Modellprognose ved gjeldende posisjon (Open-Meteo Marine).",
"tide_data_for_position": "Forespørsel for posisjon {{lat}}, {{lng}}",
"tide_data_for_place": "Forespørsel for {{place}}",
"tide_data_for_place_and_position": "Forespørsel for {{place}} ({{lat}}, {{lng}})",
"tide_fetched_from": "Data fra {{place}} (ca. {{distance}} km unna)",
"tide_fetched_from_departure": "Tidevann basert på avreise «{{place}}» (ingen aktuell GPS-posisjon).",
"tide_applied_success": "Tidevann lagret: høyvann {{highWater}}, lavvann {{lowWater}}. Synlig i reisedagseditoren under «Tidevann».",
"tide_apply": "Bruk",
"tanks": "Tanker", "tanks": "Tanker",
"customize_columns": "Tilpass kolonner", "customize_columns": "Tilpass kolonner",
"column_selector_title": "Kolonner som skal vises", "column_selector_title": "Kolonner som skal vises",
@@ -445,6 +466,9 @@
"ai_summary_error_forbidden": "Bare skipperen har lov til å generere AI-sammendrag.", "ai_summary_error_forbidden": "Bare skipperen har lov til å generere AI-sammendrag.",
"ai_summary_offline": "AI-sammendraget krever en internettforbindelse. Du er for øyeblikket frakoblet.", "ai_summary_offline": "AI-sammendraget krever en internettforbindelse. Du er for øyeblikket frakoblet.",
"photos_title": "Bildevedlegg", "photos_title": "Bildevedlegg",
"export_photos_zip": "Last ned bilder (ZIP)",
"exporting_photos_zip": "Oppretter ZIP...",
"no_photos_to_download": "Ingen bilder i denne loggboken.",
"photo_caption_label": "Bildetekst / Etikett (valgfritt)", "photo_caption_label": "Bildetekst / Etikett (valgfritt)",
"photo_caption_placeholder": "f.eks. sette seil nær havneinnløpet", "photo_caption_placeholder": "f.eks. sette seil nær havneinnløpet",
"photo_btn": "Ta bilde / Last opp", "photo_btn": "Ta bilde / Last opp",
@@ -820,6 +844,7 @@
"share_enable": "Aktiver offentlig lenke", "share_enable": "Aktiver offentlig lenke",
"share_copied": "Koblingen er kopiert!", "share_copied": "Koblingen er kopiert!",
"share_copy_btn": "Kopier lenken", "share_copy_btn": "Kopier lenken",
"share_btn": "Del lenke",
"link_qr_hint": "QR-kode som kan skannes med smarttelefonen", "link_qr_hint": "QR-kode som kan skannes med smarttelefonen",
"link_qr_alt": "QR-kode for lenken", "link_qr_alt": "QR-kode for lenken",
"danger_zone_title": "Fareområde", "danger_zone_title": "Fareområde",
+26 -1
View File
@@ -36,7 +36,9 @@
"unsaved_changes_stay": "Stanna kvar", "unsaved_changes_stay": "Stanna kvar",
"unsaved_changes_save_leave": "Spara och avsluta", "unsaved_changes_save_leave": "Spara och avsluta",
"unsaved_changes_discard": "Avvisa", "unsaved_changes_discard": "Avvisa",
"unsaved_changes_leave": "Lämna" "unsaved_changes_leave": "Lämna",
"previous": "Föregående",
"next": "Nästa"
}, },
"nav": { "nav": {
"dashboard": "Instrumentpanelen", "dashboard": "Instrumentpanelen",
@@ -188,6 +190,25 @@
"departure": "Avgångshamn (avresa från)", "departure": "Avgångshamn (avresa från)",
"destination": "Ankomsthamn (till)", "destination": "Ankomsthamn (till)",
"route": "Resa från/till", "route": "Resa från/till",
"tides": "Tidvatten",
"tide_high_water": "Högvatten",
"tide_low_water": "Lågvatten",
"tide_fetch_btn": "Hämta tidvatten",
"tide_fetch_loading": "Hämtar tidvatten…",
"tide_disclaimer": "Ingen garanti för riktighet — verifiera mot officiella källor!",
"tide_location_required": "Tidvatten kräver aktuell position (max 2 timmar) eller avgångshamn.",
"tide_position_stale": "Senaste positionen är äldre än 2 timmar. Logga position igen eller ange avgångshamn.",
"tide_fetch_failed": "Kunde inte hämta tidvatten.",
"tide_no_data": "Inga tidvattendata för denna plats.",
"tide_place_not_found": "“{{place}}” kunde inte hittas — ange en kustort eller hamn.",
"tide_fetched_at_position": "Modellprognos vid aktuell position (Open-Meteo Marine).",
"tide_data_for_position": "Förfrågan för position {{lat}}, {{lng}}",
"tide_data_for_place": "Förfrågan för {{place}}",
"tide_data_for_place_and_position": "Förfrågan för {{place}} ({{lat}}, {{lng}})",
"tide_fetched_from": "Data från {{place}} (ca {{distance}} km bort)",
"tide_fetched_from_departure": "Tidvatten baserat på avgång “{{place}}” (ingen aktuell GPS-position).",
"tide_applied_success": "Tidvatten tillämpat: högvatten {{highWater}}, lågvatten {{lowWater}}. Syns i resedagseditorn under “Tidvatten”.",
"tide_apply": "Använd",
"tanks": "Tankar", "tanks": "Tankar",
"customize_columns": "Anpassa kolumnerna", "customize_columns": "Anpassa kolumnerna",
"column_selector_title": "Kolumner som ska visas", "column_selector_title": "Kolumner som ska visas",
@@ -445,6 +466,9 @@
"ai_summary_error_forbidden": "Endast skepparen får skapa AI-sammanfattningar.", "ai_summary_error_forbidden": "Endast skepparen får skapa AI-sammanfattningar.",
"ai_summary_offline": "AI-sammanfattningen kräver en internetanslutning. Du är för närvarande offline.", "ai_summary_offline": "AI-sammanfattningen kräver en internetanslutning. Du är för närvarande offline.",
"photos_title": "Bilagor med bilder", "photos_title": "Bilagor med bilder",
"export_photos_zip": "Ladda ner bilder (ZIP)",
"exporting_photos_zip": "Skapar ZIP...",
"no_photos_to_download": "Inga bilder i denna loggbok.",
"photo_caption_label": "Bildbeskrivning / Etikett (valfritt)", "photo_caption_label": "Bildbeskrivning / Etikett (valfritt)",
"photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet", "photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet",
"photo_btn": "Ta en bild / Ladda upp", "photo_btn": "Ta en bild / Ladda upp",
@@ -820,6 +844,7 @@
"share_enable": "Aktivera offentlig länk", "share_enable": "Aktivera offentlig länk",
"share_copied": "Länken har kopierats!", "share_copied": "Länken har kopierats!",
"share_copy_btn": "Kopiera länken", "share_copy_btn": "Kopiera länken",
"share_btn": "Dela länk",
"link_qr_hint": "QR-kod att skanna med smarttelefonen", "link_qr_hint": "QR-kod att skanna med smarttelefonen",
"link_qr_alt": "QR-kod för länken", "link_qr_alt": "QR-kod för länken",
"danger_zone_title": "Farlig zon", "danger_zone_title": "Farlig zon",
+3
View File
@@ -44,6 +44,7 @@ export const PlausibleEvents = {
VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded', VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded',
VOICE_MEMO_TRANSCRIBED: 'Voice Memo Transcribed', VOICE_MEMO_TRANSCRIBED: 'Voice Memo Transcribed',
OWM_WEATHER_FETCHED: 'OWM Weather Fetched', OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
TIDE_FETCHED: 'Tide Fetched',
AI_SUMMARY_GENERATED: 'AI Summary Generated', AI_SUMMARY_GENERATED: 'AI Summary Generated',
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft', PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
PWA_BOOT_WATCHDOG_HARD: 'PWA Boot Watchdog Hard', PWA_BOOT_WATCHDOG_HARD: 'PWA Boot Watchdog Hard',
@@ -54,6 +55,8 @@ export const PlausibleEvents = {
/** Where a successful OpenWeatherMap API call originated (no coordinates or place names). */ /** Where a successful OpenWeatherMap API call originated (no coordinates or place names). */
export type OwmAnalyticsSource = 'live_log' | 'entry_editor' | 'entry_editor_gps_lookup' export type OwmAnalyticsSource = 'live_log' | 'entry_editor' | 'entry_editor_gps_lookup'
export type TideAnalyticsSource = 'live_log' | 'entry_editor'
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents] export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
export type PlausibleEventProps = Record<string, string | number | boolean> export type PlausibleEventProps = Record<string, string | number | boolean>
+22
View File
@@ -7,9 +7,11 @@ import { putEntryRecord } from '../utils/entryListCache.js'
import { import {
buildLogEntryPayload, buildLogEntryPayload,
normalizeLogEvent, normalizeLogEvent,
readLogEntryTides,
sortLogEventsByTime, sortLogEventsByTime,
currentLocalTimeHHMM, currentLocalTimeHHMM,
localDateString, localDateString,
type LogEntryTides,
type LogEventPayload type LogEventPayload
} from '../utils/logEntryPayload.js' } from '../utils/logEntryPayload.js'
import { import {
@@ -75,6 +77,7 @@ function buildEncryptedPayload(
destination?: string destination?: string
freshwater?: { morning: number; refilled: number; evening: number; consumption: number } freshwater?: { morning: number; refilled: number; evening: number; consumption: number }
fuel?: { morning: number; refilled: number; evening: number; consumption: number } fuel?: { morning: number; refilled: number; evening: number; consumption: number }
tides?: LogEntryTides
clearSignatures?: boolean clearSignatures?: boolean
} }
): Record<string, unknown> { ): Record<string, unknown> {
@@ -113,6 +116,7 @@ function buildEncryptedPayload(
freshwater, freshwater,
fuel: fuelLevels, fuel: fuelLevels,
greywater: gw ? { level: gw.level || 0 } : undefined, greywater: gw ? { level: gw.level || 0 } : undefined,
tides: options.tides ?? readLogEntryTides(data),
trackDistanceNm: trackDistanceNm:
trackDistance != null && trackDistance !== '' trackDistance != null && trackDistance !== ''
? parseFloat(String(trackDistance)) ? parseFloat(String(trackDistance))
@@ -398,6 +402,24 @@ export async function appendQuickEvents(
return { events: nextEvents, hadSignature } return { events: nextEvents, hadSignature }
} }
export async function patchEntryTides(
logbookId: string,
entryId: string,
tides: LogEntryTides
): Promise<void> {
const loaded = await loadEntry(logbookId, entryId)
if (!loaded) throw new Error('Entry not found')
const hadSignature = !!(loaded.data.signSkipper || loaded.data.signCrew)
const currentEvents = (loaded.data.events as LogEventPayload[]) || []
await persistEntry(logbookId, entryId, loaded.data, {
events: currentEvents,
tides,
clearSignatures: hadSignature
})
}
async function persistEntry( async function persistEntry(
logbookId: string, logbookId: string,
entryId: string, entryId: string,
+91
View File
@@ -0,0 +1,91 @@
import { apiFetch } from './api.js'
import {
type TideAnalyticsSource,
PlausibleEvents,
trackPlausibleEvent
} from './analytics.js'
export class TidesApiError extends Error {
code: 'OFFLINE' | 'NOT_FOUND' | 'PLACE_NOT_FOUND' | 'BAD_REQUEST' | 'REQUEST_FAILED'
constructor(
message: string,
code: 'OFFLINE' | 'NOT_FOUND' | 'PLACE_NOT_FOUND' | 'BAD_REQUEST' | 'REQUEST_FAILED' = 'REQUEST_FAILED'
) {
super(message)
this.name = 'TidesApiError'
this.code = code
}
}
const TIDES_FETCH_TIMEOUT_MS = 20_000
async function fetchTides(path: string): Promise<Record<string, unknown>> {
if (!navigator.onLine) {
throw new TidesApiError('Offline', 'OFFLINE')
}
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), TIDES_FETCH_TIMEOUT_MS)
let res: Response
try {
res = await apiFetch(path, { signal: controller.signal })
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new TidesApiError('Tide request timed out')
}
throw err
} finally {
window.clearTimeout(timeoutId)
}
const data = await res.json().catch(() => ({}))
if (res.status === 400) {
throw new TidesApiError('Invalid tide request parameters', 'BAD_REQUEST')
}
if (res.status === 404) {
const code =
typeof data?.error === 'string' && data.error === 'place_not_found'
? 'PLACE_NOT_FOUND'
: 'NOT_FOUND'
throw new TidesApiError('Tide data not found', code)
}
if (!res.ok) {
throw new TidesApiError(
typeof data?.error === 'string' ? data.error : 'Tide API rejected the request'
)
}
return data as Record<string, unknown>
}
export async function fetchTidesNearby(
lat: string,
lon: string,
options?: { analyticsSource?: TideAnalyticsSource; locationSource?: 'gps' | 'departure' }
): Promise<Record<string, unknown>> {
const searchParams = new URLSearchParams({ lat, lon })
const data = await fetchTides(`/api/tides/nearby?${searchParams.toString()}`)
if (options?.analyticsSource) {
trackPlausibleEvent(PlausibleEvents.TIDE_FETCHED, {
source: options.analyticsSource,
location_source: options.locationSource ?? 'gps'
})
}
return data
}
export async function fetchTidesByPlace(
placeQuery: string,
options?: { analyticsSource?: TideAnalyticsSource }
): Promise<Record<string, unknown>> {
const searchParams = new URLSearchParams({ q: placeQuery.trim() })
const data = await fetchTides(`/api/tides/by-place?${searchParams.toString()}`)
if (options?.analyticsSource) {
trackPlausibleEvent(PlausibleEvents.TIDE_FETCHED, {
source: options.analyticsSource,
location_source: 'departure'
})
}
return data
}
+20 -3
View File
@@ -154,6 +154,9 @@ export function getLastAutoPositionMs(
/** Max age of a logged position for OpenWeatherMap lookups in live log. */ /** Max age of a logged position for OpenWeatherMap lookups in live log. */
export const LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS = 6 * 60 * 60 * 1000 export const LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS = 6 * 60 * 60 * 1000
/** Max age of a logged position for tide lookups (TideTurtle). */
export const LIVE_LOG_TIDE_POSITION_MAX_AGE_MS = 2 * 60 * 60 * 1000
export type LiveLogPositionSource = 'position' | 'auto_position' export type LiveLogPositionSource = 'position' | 'auto_position'
export interface LiveLogPosition { export interface LiveLogPosition {
@@ -176,7 +179,10 @@ export function getLatestLoggedPosition(
events: Array<{ remarks: string; time: string; gpsLat?: string; gpsLng?: string }>, events: Array<{ remarks: string; time: string; gpsLat?: string; gpsLng?: string }>,
entryDate: string entryDate: string
): LiveLogPosition | null { ): LiveLogPosition | null {
for (let i = events.length - 1; i >= 0; i--) { let best: LiveLogPosition | null = null
let bestIndex = -1
for (let i = 0; i < events.length; i++) {
const event = events[i] const event = events[i]
const code = event.remarks.trim() const code = event.remarks.trim()
if (!isPositionEventCode(code)) continue if (!isPositionEventCode(code)) continue
@@ -185,14 +191,25 @@ export function getLatestLoggedPosition(
if (!lat || !lng) continue if (!lat || !lng) continue
const loggedAtMs = eventTimestampMs(entryDate, event.time) const loggedAtMs = eventTimestampMs(entryDate, event.time)
if (loggedAtMs == null) continue if (loggedAtMs == null) continue
return {
const candidate: LiveLogPosition = {
lat, lat,
lng, lng,
loggedAtMs, loggedAtMs,
source: isManualPositionEventCode(code) ? 'position' : 'auto_position' source: isManualPositionEventCode(code) ? 'position' : 'auto_position'
} }
if (
!best ||
candidate.loggedAtMs > best.loggedAtMs ||
(candidate.loggedAtMs === best.loggedAtMs && i > bestIndex)
) {
best = candidate
bestIndex = i
} }
return null }
return best
} }
/** Logged position for weather if recorded within `maxAgeMs` (default 6 h). */ /** Logged position for weather if recorded within `maxAgeMs` (default 6 h). */
+10
View File
@@ -19,6 +19,16 @@ describe('live log position', () => {
expect(position?.source).toBe('position') expect(position?.source).toBe('position')
}) })
it('picks latest position by event time even when array is not sorted', () => {
const entryDate = '2026-06-01'
const events = [
{ remarks: LIVE_EVENT_CODES.POSITION, time: '14:16', gpsLat: '54.12', gpsLng: '10.65' },
{ remarks: LIVE_EVENT_CODES.POSITION, time: '14:03', gpsLat: '53.62', gpsLng: '7.15' }
]
const position = getLatestLoggedPosition(events, entryDate)
expect(position?.lat).toBe('54.12')
})
it('reads legacy __live:fix remarks', () => { it('reads legacy __live:fix remarks', () => {
const entryDate = '2026-06-01' const entryDate = '2026-06-01'
const events = [ const events = [
+40
View File
@@ -72,3 +72,43 @@ describe('buildLogEntryPayload greywater', () => {
expect(payload.greywater).toBeUndefined() expect(payload.greywater).toBeUndefined()
}) })
}) })
describe('buildLogEntryPayload tides', () => {
const base = {
date: '2026-06-11',
dayOfTravel: '1',
departure: 'Norddeich',
destination: 'Juist',
freshwater: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
fuel: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
events: [] as LogEventPayload[]
}
it('persists high and low water times', () => {
const payload = buildLogEntryPayload({
...base,
tides: { highWater: '18:34', lowWater: '12:05' }
})
expect(payload.tides).toEqual({ highWater: '18:34', lowWater: '12:05' })
})
it('persists tide location metadata', () => {
const payload = buildLogEntryPayload({
...base,
tides: {
highWater: '06:00',
lowWater: '00:04',
locationSource: 'gps',
lat: '53.624526',
lng: '7.155263'
}
})
expect(payload.tides).toEqual({
highWater: '06:00',
lowWater: '00:04',
locationSource: 'gps',
lat: '53.624526',
lng: '7.155263'
})
})
})
+53
View File
@@ -150,6 +150,17 @@ export function sortLogEventsByTime<T extends LogEventPayload>(events: T[]): T[]
return [...events].sort((a, b) => (a.time || '').localeCompare(b.time || '')) return [...events].sort((a, b) => (a.time || '').localeCompare(b.time || ''))
} }
export type TideLocationSource = 'gps' | 'departure' | 'geocoded'
export interface LogEntryTides {
highWater: string
lowWater: string
locationSource?: TideLocationSource
placeName?: string
lat?: string
lng?: string
}
export interface LogEntryPayloadInput { export interface LogEntryPayloadInput {
date: string date: string
dayOfTravel: string dayOfTravel: string
@@ -158,6 +169,7 @@ export interface LogEntryPayloadInput {
freshwater: { morning: number; refilled: number; evening: number; consumption: number } freshwater: { morning: number; refilled: number; evening: number; consumption: number }
fuel: { morning: number; refilled: number; evening: number; consumption: number } fuel: { morning: number; refilled: number; evening: number; consumption: number }
greywater?: { level: number } greywater?: { level: number }
tides?: LogEntryTides
trackDistanceNm?: number trackDistanceNm?: number
trackSpeedMaxKn?: number trackSpeedMaxKn?: number
trackSpeedAvgKn?: number trackSpeedAvgKn?: number
@@ -166,6 +178,31 @@ export interface LogEntryPayloadInput {
entryCrew?: EntryCrewFields entryCrew?: EntryCrewFields
} }
function readTideLocationSource(value: unknown): TideLocationSource | undefined {
const source = String(value ?? '').trim()
if (source === 'gps' || source === 'departure' || source === 'geocoded') return source
return undefined
}
export function readLogEntryTides(data: Record<string, unknown>): LogEntryTides {
const tides = data.tides as Record<string, unknown> | undefined
const highRaw = String(tides?.highWater ?? '').trim()
const lowRaw = String(tides?.lowWater ?? '').trim()
const placeName = String(tides?.placeName ?? '').trim()
const lat = String(tides?.lat ?? '').trim()
const lng = String(tides?.lng ?? '').trim()
const locationSource = readTideLocationSource(tides?.locationSource)
return {
highWater: parseTimeToHHMM(highRaw) ?? '',
lowWater: parseTimeToHHMM(lowRaw) ?? '',
...(locationSource ? { locationSource } : {}),
...(placeName ? { placeName } : {}),
...(lat ? { lat } : {}),
...(lng ? { lng } : {})
}
}
export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string, unknown> { export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string, unknown> {
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
date: input.date, date: input.date,
@@ -191,6 +228,22 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string
} }
} }
if (input.tides) {
const highWater = parseTimeToHHMM(input.tides.highWater) ?? ''
const lowWater = parseTimeToHHMM(input.tides.lowWater) ?? ''
if (highWater || lowWater) {
const tides: Record<string, string> = { highWater, lowWater }
if (input.tides.locationSource) tides.locationSource = input.tides.locationSource
const placeName = input.tides.placeName?.trim()
if (placeName) tides.placeName = placeName
const lat = input.tides.lat?.trim()
if (lat) tides.lat = lat
const lng = input.tides.lng?.trim()
if (lng) tides.lng = lng
payload.tides = tides
}
}
if (input.entryCrew) { if (input.entryCrew) {
payload.selectedSkipperId = input.entryCrew.selectedSkipperId payload.selectedSkipperId = input.entryCrew.selectedSkipperId
payload.selectedCrewIds = [...input.entryCrew.selectedCrewIds] payload.selectedCrewIds = [...input.entryCrew.selectedCrewIds]
+151
View File
@@ -0,0 +1,151 @@
import { describe, expect, it } from 'vitest'
import { LIVE_EVENT_CODES } from './liveEventCodes.js'
import {
buildTideLocationMeta,
formatTideLocationLabel,
resolveTideFetchLocation
} from './tideLocation.js'
const entryDate = '2026-06-11'
const nowMs = new Date('2026-06-11T12:00:00').getTime()
describe('resolveTideFetchLocation', () => {
it('uses chronologically latest position when several are logged', () => {
const result = resolveTideFetchLocation({
events: [
{
time: '14:03',
remarks: LIVE_EVENT_CODES.POSITION,
gpsLat: '53.624526',
gpsLng: '7.155263'
},
{
time: '14:16',
remarks: LIVE_EVENT_CODES.POSITION,
gpsLat: '54.120000',
gpsLng: '10.650000'
}
],
entryDate,
departure: 'Norddeich',
nowMs
})
expect(result).toEqual({
mode: 'nearby',
lat: '54.120000',
lng: '10.650000',
source: 'gps'
})
})
it('prefers fresh GPS position', () => {
const result = resolveTideFetchLocation({
events: [
{
time: '11:30',
remarks: LIVE_EVENT_CODES.POSITION,
gpsLat: '54.32',
gpsLng: '10.14'
}
],
entryDate,
departure: 'Kiel',
nowMs
})
expect(result).toEqual({
mode: 'nearby',
lat: '54.32',
lng: '10.14',
source: 'gps'
})
})
it('falls back to departure when no position', () => {
const result = resolveTideFetchLocation({
events: [],
entryDate,
departure: 'Sylt',
nowMs
})
expect(result).toEqual({
mode: 'by-place',
query: 'Sylt',
source: 'departure'
})
})
it('falls back to departure when position is stale', () => {
const result = resolveTideFetchLocation({
events: [
{
time: '08:00',
remarks: LIVE_EVENT_CODES.POSITION,
gpsLat: '54.32',
gpsLng: '10.14'
}
],
entryDate,
departure: 'Kiel',
nowMs
})
expect(result).toEqual({
mode: 'by-place',
query: 'Kiel',
source: 'departure'
})
})
it('returns stale without departure', () => {
const result = resolveTideFetchLocation({
events: [
{
time: '08:00',
remarks: LIVE_EVENT_CODES.POSITION,
gpsLat: '54.32',
gpsLng: '10.14'
}
],
entryDate,
departure: '',
nowMs
})
expect(result).toEqual({ error: 'stale' })
})
it('builds GPS location metadata from nearby fetch', () => {
const meta = buildTideLocationMeta(
{ mode: 'nearby', lat: '53.624526', lng: '7.155263', source: 'gps' },
{ location: { name: 'Norddeich', lat: 53.62, lon: 7.15, source: 'coordinates' } }
)
expect(meta).toEqual({
locationSource: 'gps',
lat: '53.624526',
lng: '7.155263',
placeName: 'Norddeich'
})
})
it('formats coordinate and place labels', () => {
const t = (key: string, options?: Record<string, string>) =>
`${key}:${JSON.stringify(options ?? {})}`
expect(
formatTideLocationLabel(
{ locationSource: 'gps', lat: '53.62', lng: '7.15', placeName: 'Norddeich' },
t
)
).toContain('tide_data_for_place_and_position')
expect(
formatTideLocationLabel({ locationSource: 'gps', lat: '53.62', lng: '7.15' }, t)
).toContain('tide_data_for_position')
})
it('returns missing without position or departure', () => {
const result = resolveTideFetchLocation({
events: [],
entryDate,
departure: '',
nowMs
})
expect(result).toEqual({ error: 'missing' })
})
})
+119
View File
@@ -0,0 +1,119 @@
import {
getLastLoggedPositionWithin,
getLatestLoggedPosition,
LIVE_LOG_TIDE_POSITION_MAX_AGE_MS
} from './liveEventCodes.js'
import type { LogEntryTides, LogEventPayload, TideLocationSource } from './logEntryPayload.js'
export type { TideLocationSource }
export type TideLocationMeta = Pick<LogEntryTides, 'locationSource' | 'placeName' | 'lat' | 'lng'>
export type TideFetchLocation =
| { mode: 'nearby'; lat: string; lng: string; source: 'gps' }
| { mode: 'by-place'; query: string; source: 'departure' }
export type TideLocationError = 'stale' | 'missing'
export function resolveTideFetchLocation(options: {
events: Array<Pick<LogEventPayload, 'remarks' | 'time' | 'gpsLat' | 'gpsLng'>>
entryDate: string
departure: string
maxAgeMs?: number
nowMs?: number
}): TideFetchLocation | { error: TideLocationError } {
const maxAgeMs = options.maxAgeMs ?? LIVE_LOG_TIDE_POSITION_MAX_AGE_MS
const nowMs = options.nowMs ?? Date.now()
const departure = options.departure.trim()
const fresh = getLastLoggedPositionWithin(
options.events,
options.entryDate,
maxAgeMs,
nowMs
)
if (fresh) {
return { mode: 'nearby', lat: fresh.lat, lng: fresh.lng, source: 'gps' }
}
if (departure) {
return { mode: 'by-place', query: departure, source: 'departure' }
}
const latest = getLatestLoggedPosition(options.events, options.entryDate)
if (latest && nowMs - latest.loggedAtMs > maxAgeMs) {
return { error: 'stale' }
}
return { error: 'missing' }
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
export function buildTideLocationMeta(
fetchLocation: TideFetchLocation,
apiData: Record<string, unknown>
): TideLocationMeta {
const apiLocation = asRecord(apiData.location)
if (fetchLocation.mode === 'nearby') {
return {
locationSource: 'gps',
lat: fetchLocation.lat,
lng: fetchLocation.lng,
placeName: apiLocation?.name ? String(apiLocation.name) : undefined
}
}
const placeName = apiLocation?.name ? String(apiLocation.name) : fetchLocation.query
const lat = apiLocation?.lat != null && apiLocation.lat !== '' ? String(apiLocation.lat) : undefined
const lng = apiLocation?.lon != null && apiLocation.lon !== '' ? String(apiLocation.lon) : undefined
return {
locationSource: apiLocation?.source === 'geocoded' ? 'geocoded' : 'departure',
placeName,
lat,
lng
}
}
type TideLocationLabelT = (
key: string,
options?: Record<string, string | undefined>
) => string
export function formatTideLocationLabel(
tides: TideLocationMeta,
t: TideLocationLabelT
): string {
const placeName = tides.placeName?.trim()
const lat = tides.lat?.trim()
const lng = tides.lng?.trim()
if (placeName && lat && lng) {
return t('logs.tide_data_for_place_and_position', { place: placeName, lat, lng })
}
if (lat && lng) {
return t('logs.tide_data_for_position', { lat, lng })
}
if (placeName) {
if (tides.locationSource === 'departure') {
return t('logs.tide_fetched_from_departure', { place: placeName })
}
return t('logs.tide_data_for_place', { place: placeName })
}
return ''
}
export function pickTideLocationMeta(tides: LogEntryTides): TideLocationMeta {
return {
locationSource: tides.locationSource,
placeName: tides.placeName,
lat: tides.lat,
lng: tides.lng
}
}
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { parseTideTurtleForDate } from './tideTurtle.js'
const sampleNearby = {
distanceKm: 1.2,
place: { name: 'Kiel' },
tides: {
data: {
timezone: 'Europe/Berlin',
extrema: [
{ time: '2026-06-11T08:50:00.000Z', date: '2026-06-11', height: 0.5, isHigh: true },
{ time: '2026-06-11T14:34:00.000Z', date: '2026-06-11', height: -0.2, isHigh: false },
{ time: '2026-06-12T09:00:00.000Z', date: '2026-06-12', height: 0.6, isHigh: true }
]
}
}
}
describe('parseTideTurtleForDate', () => {
it('returns first high and low on entry date in local timezone', () => {
const parsed = parseTideTurtleForDate(sampleNearby, '2026-06-11')
expect(parsed.highWater).toBe('10:50')
expect(parsed.lowWater).toBe('16:34')
expect(parsed.placeName).toBe('Kiel')
expect(parsed.distanceKm).toBe(1.2)
})
it('reads Open-Meteo coordinate response without distance', () => {
const parsed = parseTideTurtleForDate(
{
location: { source: 'coordinates', lat: 53.62, lon: 7.15 },
tides: sampleNearby.tides
},
'2026-06-11'
)
expect(parsed.highWater).toBe('10:50')
expect(parsed.distanceKm).toBeUndefined()
})
it('leaves missing tide type empty', () => {
const parsed = parseTideTurtleForDate(
{
data: {
timezone: 'UTC',
extrema: [{ time: '2026-06-11T12:00:00.000Z', date: '2026-06-11', height: 1, isHigh: true }]
}
},
'2026-06-11'
)
expect(parsed.highWater).toBe('12:00')
expect(parsed.lowWater).toBe('')
})
})
+108
View File
@@ -0,0 +1,108 @@
export interface TideExtreme {
time: string
date: string
height: number
isHigh: boolean
}
export interface ParsedTideTimes {
highWater: string
lowWater: string
placeName?: string
distanceKm?: number
timezone: string
}
function isoToHHMM(iso: string, timeZone: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone,
hour: '2-digit',
minute: '2-digit',
hour12: false
}).formatToParts(d)
const hour = parts.find((p) => p.type === 'hour')?.value ?? '00'
const minute = parts.find((p) => p.type === 'minute')?.value ?? '00'
return `${hour}:${minute}`
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
function readExtrema(data: Record<string, unknown>): TideExtreme[] {
const raw = data.extrema
if (!Array.isArray(raw)) return []
const out: TideExtreme[] = []
for (const item of raw) {
const row = asRecord(item)
if (!row) continue
const time = String(row.time ?? '').trim()
const date = String(row.date ?? '').trim()
if (!time || !date) continue
out.push({
time,
date,
height: Number(row.height ?? 0),
isHigh: row.isHigh === true || row.type === 'high'
})
}
return out
}
/** Normalize TideTurtle nearby or place JSON into extrema + metadata. */
export function extractTideTurtlePayload(data: Record<string, unknown>): {
extrema: TideExtreme[]
timezone: string
placeName?: string
distanceKm?: number
} {
const place = asRecord(data.place)
const location = asRecord(data.location)
const tidesRoot = asRecord(data.tides) ?? data
const tidesData = asRecord(tidesRoot.data) ?? tidesRoot
const spatial = asRecord(tidesData.spatialCoverage) ?? asRecord(data.spatialCoverage)
const timezone = String(tidesData.timezone ?? 'UTC')
const extrema = readExtrema(tidesData)
let placeName = place?.name ? String(place.name) : undefined
if (!placeName && location?.name) placeName = String(location.name)
if (!placeName && spatial?.name) placeName = String(spatial.name)
const distanceKm =
location?.source === 'coordinates'
? undefined
: data.distanceKm != null && data.distanceKm !== ''
? Number(data.distanceKm)
: undefined
return { extrema, timezone, placeName, distanceKm }
}
/** First high and first low tide on entryDate (YYYY-MM-DD). */
export function parseTideTurtleForDate(
data: Record<string, unknown>,
entryDate: string
): ParsedTideTimes {
const { extrema, timezone, placeName, distanceKm } = extractTideTurtlePayload(data)
let highWater = ''
let lowWater = ''
for (const extreme of extrema) {
if (extreme.date !== entryDate) continue
if (extreme.isHigh && !highWater) {
highWater = isoToHHMM(extreme.time, timezone)
}
if (!extreme.isHigh && !lowWater) {
lowWater = isoToHHMM(extreme.time, timezone)
}
if (highWater && lowWater) break
}
return { highWater, lowWater, placeName, distanceKm, timezone }
}
+2 -1
View File
@@ -49,6 +49,7 @@ Das Script wird über `plausible-bootstrap.js` geladen; `data-domain` ist der ak
| Voice Memo Uploaded | Sprachnotiz gespeichert (`voiceAttachments.ts`) | `context`: `logbook` \| `live_log` | | Voice Memo Uploaded | Sprachnotiz gespeichert (`voiceAttachments.ts`) | `context`: `logbook` \| `live_log` |
| Voice Memo Transcribed | Sprachmemo transkribiert (`LiveLogView.tsx`, `EventRemarksCell.tsx`) | `status`: `success` \| `failed`, `mode`: `auto` (beim Speichern) \| `manual` (nachträglich) | | Voice Memo Transcribed | Sprachmemo transkribiert (`LiveLogView.tsx`, `EventRemarksCell.tsx`) | `status`: `success` \| `failed`, `mode`: `auto` (beim Speichern) \| `manual` (nachträglich) |
| OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) | | OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) |
| Tide Fetched | Erfolgreicher TideTurtle-Abruf (`tides.ts`) | `source`: `live_log` \| `entry_editor`; `location_source`: `gps` \| `departure` |
| AI Summary Generated | Erfolgreiche KI-Zusammenfassung eines Reisetags (`aiSummary.ts`) | — | | AI Summary Generated | Erfolgreiche KI-Zusammenfassung eines Reisetags (`aiSummary.ts`) | — |
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes` (Anzahlen/Größe, keine Inhalte) | | Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes` (Anzahlen/Größe, keine Inhalte) |
| Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes`, `mode`: `same_id` \| `overwrite` \| `new_id` | | Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes`, `mode`: `same_id` \| `overwrite` \| `new_id` |
@@ -148,7 +149,7 @@ Empfohlene Goal-Ketten für Auswertung (nur Business!):
8. **Internationalisierung:** Language Changed (Verteilung `to`, Pfade mit Übersetzungs-Feedback) 8. **Internationalisierung:** Language Changed (Verteilung `to`, Pfade mit Übersetzungs-Feedback)
9. **NMEA-Import:** NMEA Uploaded → NMEA Imported (Modus, `events`, optional Track; Upload-Funnel vs. Abbruch) 9. **NMEA-Import:** NMEA Uploaded → NMEA Imported (Modus, `events`, optional Track; Upload-Funnel vs. Abbruch)
10. **Live-Journal:** Live Log Opened → Live Log Event Logged (Verteilung `action`; z. B. `position`, `course`, `motor_start`) → Photo Uploaded / Voice Memo Uploaded (Filter `context`: `live_log`) 10. **Live-Journal:** Live Log Opened → Live Log Event Logged (Verteilung `action`; z. B. `position`, `course`, `motor_start`) → Photo Uploaded / Voice Memo Uploaded (Filter `context`: `live_log`)
11. **OpenWeatherMap:** OWM Weather Fetched (Verteilung `source`; Live-Journal vs. Reisetag-Editor) 11. **OpenWeatherMap / Gezeiten:** OWM Weather Fetched (Verteilung `source`); Tide Fetched (Verteilung `location_source`)
12. **PWA-Stabilitaet:** PWA Boot Watchdog Soft → PWA Boot Watchdog Hard → PWA Boot Watchdog Fallback → PWA Boot Watchdog Manual Repair 12. **PWA-Stabilitaet:** PWA Boot Watchdog Soft → PWA Boot Watchdog Hard → PWA Boot Watchdog Fallback → PWA Boot Watchdog Manual Repair
## Entwicklung ## Entwicklung
+1 -1
View File
@@ -90,7 +90,7 @@ else
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}" COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-prod-backend}" BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-prod-backend}"
APP_URL="${APP_URL:-https://kapteins-daagbok.eu}" APP_URL="${APP_URL:-https://kapteins-daagbok.eu}"
DEPLOY_BRANCH="" DEPLOY_BRANCH="none"
ENV_LABEL="Production" ENV_LABEL="Production"
fi fi
+2
View File
@@ -10,6 +10,7 @@ import collaborationRouter from './routes/collaboration.js'
import signRouter from './routes/sign.js' import signRouter from './routes/sign.js'
import pushRouter from './routes/push.js' import pushRouter from './routes/push.js'
import weatherRouter from './routes/weather.js' import weatherRouter from './routes/weather.js'
import tidesRouter from './routes/tides.js'
import aiRouter from './routes/ai.js' import aiRouter from './routes/ai.js'
import feedbackRouter from './routes/feedback.js' import feedbackRouter from './routes/feedback.js'
import adminRouter from './routes/admin.js' import adminRouter from './routes/admin.js'
@@ -120,6 +121,7 @@ export function createApp(): express.Express {
app.use('/api/sign', signRouter) app.use('/api/sign', signRouter)
app.use('/api/push', pushRouter) app.use('/api/push', pushRouter)
app.use('/api/weather', weatherRouter) app.use('/api/weather', weatherRouter)
app.use('/api/tides', tidesRouter)
app.use('/api/ai', aiRouter) app.use('/api/ai', aiRouter)
app.use('/api/feedback', feedbackRouter) app.use('/api/feedback', feedbackRouter)
app.use('/api/admin', adminRouter) app.use('/api/admin', adminRouter)
+60
View File
@@ -0,0 +1,60 @@
import { Router } from 'express'
import { requireUser } from '../middleware/auth.js'
import {
fetchTidesForCoordinates,
fetchTidesForPlace
} from '../utils/openMeteoTides.js'
const router = Router()
function parseLatLon(lat: unknown, lon: unknown): { lat: number; lon: number } | null {
const latNum = Number(lat)
const lonNum = Number(lon)
if (Number.isNaN(latNum) || Number.isNaN(lonNum)) return null
if (latNum < -90 || latNum > 90 || lonNum < -180 || lonNum > 180) return null
return { lat: latNum, lon: lonNum }
}
router.get('/nearby', requireUser, async (req, res) => {
try {
const coords = parseLatLon(req.query.lat, req.query.lon)
if (!coords) {
return res.status(400).json({ error: 'lat and lon are required' })
}
const data = await fetchTidesForCoordinates(coords.lat, coords.lon)
return res.json(data)
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Tide lookup failed'
if (message === 'no_tide_data') {
return res.status(404).json({ error: 'no_tide_data' })
}
console.error('Error fetching nearby tides:', error)
return res.status(502).json({ error: message })
}
})
router.get('/by-place', requireUser, async (req, res) => {
try {
const query = typeof req.query.q === 'string' ? req.query.q.trim() : ''
if (!query) {
return res.status(400).json({ error: 'q is required' })
}
const data = await fetchTidesForPlace(query)
return res.json(data)
} catch (error: unknown) {
const status = (error as { status?: number }).status
const message = error instanceof Error ? error.message : 'Tide lookup failed'
if (status === 404 || message === 'place_not_found') {
return res.status(404).json({ error: 'place_not_found' })
}
if (message === 'no_tide_data') {
return res.status(404).json({ error: 'no_tide_data' })
}
console.error('Error fetching place tides:', error)
return res.status(502).json({ error: message })
}
})
export default router
+22
View File
@@ -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)
})
})
+249
View File
@@ -0,0 +1,249 @@
const MARINE_API = 'https://marine-api.open-meteo.com/v1/marine'
const GEOCODING_API = 'https://geocoding-api.open-meteo.com/v1/search'
const FETCH_TIMEOUT_MS = 15_000
const FORECAST_DAYS = 7
export interface TideExtreme {
time: string
date: string
height: number
isHigh: boolean
}
export interface TideLookupResult {
location: {
name?: string
lat: number
lon: number
source: 'coordinates' | 'geocoded'
}
tides: {
data: {
timezone: string
datum: 'MSL'
source: string
extrema: TideExtreme[]
}
}
}
interface MarineResponse {
timezone?: string
utc_offset_seconds?: number
hourly?: {
time?: string[]
sea_level_height_msl?: Array<number | null>
}
}
interface GeocodingResult {
name: string
latitude: number
longitude: number
country_code?: string
admin1?: string
}
async function fetchJson<T>(url: string): Promise<T> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
try {
const res = await fetch(url, { signal: controller.signal })
const data = await res.json()
if (!res.ok) {
const message =
typeof (data as { reason?: string })?.reason === 'string'
? (data as { reason: string }).reason
: `Upstream HTTP ${res.status}`
throw new Error(message)
}
return data as T
} finally {
clearTimeout(timeout)
}
}
function localDateFromIso(iso: string, timeZone: string): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return ''
return new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).format(date)
}
function interpolateExtremumTime(
t0: number,
y0: number,
t1: number,
y1: number,
t2: number,
y2: number
): { timeOffsetHours: number; height: number } {
const denom = y0 - 2 * y1 + y2
if (Math.abs(denom) < 1e-6) {
return { timeOffsetHours: t1, height: y1 }
}
const offset = 0.5 * (y0 - y2) / denom
const clamped = Math.max(t0, Math.min(t2, offset))
const height = y1 + 0.25 * (y0 - y2) * (clamped - t1)
return { timeOffsetHours: clamped, height }
}
function localHourlyTimeToUtcIso(localIso: string, utcOffsetSeconds: number): string {
const [datePart, timePart] = localIso.split('T')
if (!datePart || !timePart) return localIso
const [year, month, day] = datePart.split('-').map(Number)
const [hour, minute] = timePart.split(':').map(Number)
if ([year, month, day, hour, minute].some((n) => Number.isNaN(n))) return localIso
const utcMs = Date.UTC(year, month - 1, day, hour, minute) - utcOffsetSeconds * 1000
return new Date(utcMs).toISOString()
}
function addFractionalHoursToLocalIso(localIso: string, deltaHours: number): string {
const [datePart, timePart] = localIso.split('T')
if (!datePart || !timePart) return localIso
const [year, month, day] = datePart.split('-').map(Number)
const [hour, minute] = timePart.split(':').map(Number)
if ([year, month, day, hour, minute].some((n) => Number.isNaN(n))) return localIso
const totalMinutes = hour * 60 + minute + Math.round(deltaHours * 60)
const dayOffset = Math.floor(totalMinutes / (24 * 60))
const minutesInDay = ((totalMinutes % (24 * 60)) + 24 * 60) % (24 * 60)
const nextDay = new Date(Date.UTC(year, month - 1, day + dayOffset))
const y = nextDay.getUTCFullYear()
const m = String(nextDay.getUTCMonth() + 1).padStart(2, '0')
const d = String(nextDay.getUTCDate()).padStart(2, '0')
const hh = String(Math.floor(minutesInDay / 60)).padStart(2, '0')
const mm = String(minutesInDay % 60).padStart(2, '0')
return `${y}-${m}-${d}T${hh}:${mm}`
}
export function findSeaLevelExtrema(
times: string[],
levels: Array<number | null>,
timeZone: string,
utcOffsetSeconds = 0
): TideExtreme[] {
const extrema: TideExtreme[] = []
if (times.length < 3) return extrema
for (let i = 1; i < times.length - 1; i += 1) {
const prev = levels[i - 1]
const curr = levels[i]
const next = levels[i + 1]
if (prev == null || curr == null || next == null) continue
const isHigh = curr >= prev && curr >= next && (curr > prev || curr > next)
const isLow = curr <= prev && curr <= next && (curr < prev || curr < next)
if (!isHigh && !isLow) continue
const { timeOffsetHours, height } = interpolateExtremumTime(
i - 1,
prev,
i,
curr,
i + 1,
next
)
const localIso = addFractionalHoursToLocalIso(times[i], timeOffsetHours - i)
const iso = localHourlyTimeToUtcIso(localIso, utcOffsetSeconds)
extrema.push({
time: iso,
date: localDateFromIso(iso, timeZone),
height: Number(height.toFixed(2)),
isHigh
})
}
return extrema
}
export async function fetchTidesForCoordinates(
lat: number,
lon: number,
options?: { name?: string; source?: 'coordinates' | 'geocoded' }
): Promise<TideLookupResult> {
const url = new URL(MARINE_API)
url.searchParams.set('latitude', String(lat))
url.searchParams.set('longitude', String(lon))
url.searchParams.set('hourly', 'sea_level_height_msl')
url.searchParams.set('timezone', 'auto')
url.searchParams.set('forecast_days', String(FORECAST_DAYS))
const data = await fetchJson<MarineResponse>(url.toString())
const times = data.hourly?.time ?? []
const levels = data.hourly?.sea_level_height_msl ?? []
const timezone = data.timezone || 'UTC'
const utcOffsetSeconds = data.utc_offset_seconds ?? 0
if (times.length === 0 || levels.length === 0) {
throw new Error('no_tide_data')
}
const extrema = findSeaLevelExtrema(times, levels, timezone, utcOffsetSeconds)
if (extrema.length === 0) {
throw new Error('no_tide_data')
}
return {
location: {
name: options?.name,
lat,
lon,
source: options?.source ?? 'coordinates'
},
tides: {
data: {
timezone,
datum: 'MSL',
source:
'Open-Meteo Marine (MeteoFrance SMOC, 0.08° grid) — model-derived, MSL not chart datum',
extrema
}
}
}
}
function scoreGeocodingResult(query: string, result: GeocodingResult): number {
const q = query.trim().toLowerCase()
const name = result.name.toLowerCase()
let score = 0
if (name === q) score += 100
if (name.startsWith(q) || q.startsWith(name)) score += 40
if (result.country_code === 'DE' || result.country_code === 'NO' || result.country_code === 'DK') {
score += 10
}
if (result.admin1?.toLowerCase().includes('niedersachsen') || result.admin1?.toLowerCase().includes('lower saxony')) {
score += 5
}
return score
}
export async function geocodePlace(query: string): Promise<GeocodingResult | null> {
const url = new URL(GEOCODING_API)
url.searchParams.set('name', query.trim())
url.searchParams.set('count', '10')
url.searchParams.set('language', 'de')
const data = await fetchJson<{ results?: GeocodingResult[] }>(url.toString())
const results = data.results ?? []
if (results.length === 0) return null
return [...results].sort((a, b) => scoreGeocodingResult(query, b) - scoreGeocodingResult(query, a))[0]
}
export async function fetchTidesForPlace(query: string): Promise<TideLookupResult> {
const place = await geocodePlace(query)
if (!place) {
const err = new Error('place_not_found') as Error & { status?: number }
err.status = 404
throw err
}
return fetchTidesForCoordinates(place.latitude, place.longitude, {
name: place.name,
source: 'geocoded'
})
}