Compare commits

...

25 Commits

Author SHA1 Message Date
elpatron b1500f8361 chore: release v0.1.0.96 2026-06-02 20:26:41 +02:00
elpatron bc7512003e fix: retrieve Service Worker registration directly via getRegistration() to avoid ready promise hangs 2026-06-02 20:26:04 +02:00
elpatron eaf126b584 chore: release v0.1.0.95 2026-06-02 20:19:51 +02:00
elpatron a9c712be45 fix: add timeouts to SW ready and push subscribe promises to prevent silent hangs during push activation 2026-06-02 20:19:32 +02:00
elpatron b0195601de chore: release v0.1.0.94 2026-06-02 20:08:22 +02:00
elpatron c2b58baa6e fix: implement callback-based Notification.requestPermission compatibility and manual key extraction fallback to fix mobile push subscription 2026-06-02 20:07:44 +02:00
elpatron a85d6e42fc chore: release v0.1.0.93 2026-06-02 19:41:54 +02:00
elpatron 53da4a14a0 fix: delay PWA update checks on visibilitychange/online events to allow network stack stabilization 2026-06-02 19:39:48 +02:00
elpatron 2453134c51 chore: release v0.1.0.92 2026-06-02 19:28:24 +02:00
elpatron 671cb2dd9a fix: resolve push notification issues on iPad and Android by preloading VAPID keys and ready service worker to preserve user gesture context and by forcing clean re-subscription 2026-06-02 19:28:03 +02:00
elpatron 1d511e0f8c chore: release v0.1.0.91 2026-06-02 19:18:28 +02:00
elpatron 18a68367bc fix: resolve PWA freeze caused by infinite microtask loop in sync.ts and hung fetches without timeout 2026-06-02 19:17:36 +02:00
elpatron 90518372d8 chore: release v0.1.0.90 2026-06-02 15:48:31 +02:00
elpatron 9d22cb61c7 fix: prevent UI freeze after saving signed log entries
Cache plaintext list metadata on entry save so the journal list avoids
full decrypt per row, and batch sync pull writes with main-thread yields.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 15:47:18 +02:00
elpatron bb501ba644 chore: release v0.1.0.89 2026-06-01 22:44:01 +02:00
elpatron f51f088f1e chore: release v0.1.0.88 2026-06-01 22:32:53 +02:00
elpatron 3d2918e0fe feat: logbook filter by crew/vessel and save-on-leave dialog
Extend dashboard search with ship name and crew name parts from local data.
When leaving a dirty travel day, offer save, discard, or stay instead of only leave/cancel.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 22:30:41 +02:00
elpatron c5a9b39057 chore: release v0.1.0.87 2026-06-01 22:14:57 +02:00
elpatron 2c8a858c89 fix(pwa): keep bootstrap watchdog active on startup errors
Prevents failed app bootstrap from being marked as successful so the boot watchdog can continue recovery attempts instead of suppressing them.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 22:14:07 +02:00
elpatron ee94a5be10 chore: release v0.1.0.86 2026-06-01 22:05:32 +02:00
elpatron 08798dc9b2 fix(ui): Wetter-Slider mit Tank-Slider-Styling vereinheitlichen
Die Slider-Styles galten nur unter .tank-liter-input; MetricRangeInput nutzt dieselbe Klasse, lag aber in einem anderen Container.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 22:04:39 +02:00
elpatron ddeb69437a fix(pwa): harden startup recovery and track watchdog events
Adds a production boot watchdog to self-heal white/black-screen startup stalls without clearing IndexedDB data offline, reducing failure loops after frequent deploys. Also records watchdog recovery paths in Plausible and documents the new events/properties for monitoring stability.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 22:03:42 +02:00
elpatron cdcef2e106 feat(logs): Sichtweite und kompakte Wetter-Slider im Ereignisprotokoll
Ergänzt visibility in Editor und Live-Log inkl. OWM-Übernahme, CSV-Export
und touch-taugliche Slider für Luftdruck, Seegang, Sichtweite und Krängung.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 21:50:05 +02:00
elpatron 847c73fda9 fix(dev): Prisma db push beim Start und sichere Vessel-Pool-Sync-Abfragen
start-dev.sh synchronisiert Schema vor dem Backend; Sync/Collaboration
liefern bei fehlenden Tabellen null statt 500.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 21:33:41 +02:00
elpatron ec11dd8d2b feat(vessel): Schiffsflotte im Profil und Logbuch-Auswahl
Benutzerweiter Vessel-Pool (E2E, Sync, Migration von Legacy-Yachts) mit
LogbookVesselSelection und LogbookVesselPicker. Profil mit Accordion
(Flotte & Crew); Demo und Onboarding-Tour inkl. profile_vessel_pool.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 21:25:08 +02:00
72 changed files with 3975 additions and 351 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.0.86
0.1.0.97
+1
View File
@@ -22,6 +22,7 @@
<meta name="apple-mobile-web-app-title" content="Daagbok" />
<meta name="theme-color" content="#0b0c10" />
<script src="/appearance-bootstrap.js"></script>
<script src="/bootstrap-watchdog.js"></script>
<link rel="apple-touch-icon" href="/logo.png" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Kapteins Daagbok" />
+221
View File
@@ -0,0 +1,221 @@
/**
* Boot watchdog for production PWAs.
* Recovers from white/black screens when stale HTML points to missing JS chunks.
* Does not clear caches automatically while offline to protect unsynced data.
*/
(function () {
if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') {
return
}
var BOOT_TIMEOUT_MS = 12000
var ATTEMPT_WINDOW_MS = 120000
var ATTEMPT_COUNT_KEY = 'pwa_boot_watchdog_attempt_count'
var ATTEMPT_LAST_KEY = 'pwa_boot_watchdog_attempt_last_ts'
var PENDING_EVENTS_KEY = 'pwa_boot_pending_events'
var MAX_PENDING_EVENTS = 12
function enqueueEvent(name, props) {
try {
var current = JSON.parse(sessionStorage.getItem(PENDING_EVENTS_KEY) || '[]')
if (!Array.isArray(current)) current = []
current.push({ name: name, props: props, ts: Date.now() })
if (current.length > MAX_PENDING_EVENTS) {
current = current.slice(current.length - MAX_PENDING_EVENTS)
}
sessionStorage.setItem(PENDING_EVENTS_KEY, JSON.stringify(current))
} catch (_) {
/* ignore analytics queue errors */
}
}
function emit(name, props) {
if (typeof window.plausible === 'function') {
if (props && Object.keys(props).length > 0) {
window.plausible(name, { props: props })
} else {
window.plausible(name)
}
return
}
enqueueEvent(name, props)
}
function hasBootstrapped() {
return window.__KDB_APP_BOOTSTRAPPED === true
}
function resetAttempts() {
try {
sessionStorage.removeItem(ATTEMPT_COUNT_KEY)
sessionStorage.removeItem(ATTEMPT_LAST_KEY)
} catch (_) {
/* ignore storage errors */
}
}
function nextAttempt() {
try {
var now = Date.now()
var last = Number(sessionStorage.getItem(ATTEMPT_LAST_KEY) || '0')
var count = Number(sessionStorage.getItem(ATTEMPT_COUNT_KEY) || '0')
if (now - last > ATTEMPT_WINDOW_MS) {
count = 0
}
count += 1
sessionStorage.setItem(ATTEMPT_COUNT_KEY, String(count))
sessionStorage.setItem(ATTEMPT_LAST_KEY, String(now))
return count
} catch (_) {
return 1
}
}
function createRecoveryUrl(reason) {
try {
var url = new URL(location.href)
url.searchParams.set('boot_recover', reason)
url.searchParams.set('_', String(Date.now()))
return url.toString()
} catch (_) {
return location.href
}
}
async function clearServiceWorkerCaches() {
if ('serviceWorker' in navigator) {
try {
var registrations = await navigator.serviceWorker.getRegistrations()
await Promise.all(
registrations.map(function (registration) {
return registration.unregister()
})
)
} catch (_) {
/* ignore SW cleanup errors */
}
}
if ('caches' in window) {
try {
var keys = await caches.keys()
await Promise.all(
keys.map(function (key) {
return caches.delete(key)
})
)
} catch (_) {
/* ignore cache cleanup errors */
}
}
}
function renderFallback(isOffline) {
var root = document.getElementById('root')
if (!root) return
root.innerHTML =
'<div class="auth-screen">' +
'<div class="auth-card glass" role="alert" style="max-width:460px">' +
'<h2 style="margin-top:0">Kapteins Daagbok</h2>' +
'<p style="color:var(--app-text-muted);line-height:1.5;margin-bottom:8px">' +
(isOffline
? 'Die App konnte offline nicht sauber starten. Deine lokalen, nicht synchronisierten Daten bleiben erhalten.'
: 'Die App konnte nicht sauber starten. Deine lokalen, nicht synchronisierten Daten bleiben erhalten.') +
'</p>' +
'<p style="color:var(--app-text-muted);line-height:1.5;margin-top:0">' +
(isOffline
? 'Bitte neu laden. Wenn wieder Netz verfügbar ist, kann die App-Engine automatisch repariert werden.'
: 'Du kannst jetzt eine App-Reparatur ausfuehren, ohne IndexedDB-Logbuchdaten zu loeschen.') +
'</p>' +
'<button type="button" class="btn primary" id="boot-reload-btn" style="width:100%">' +
'Neu laden' +
'</button>' +
(!isOffline
? '<button type="button" class="btn secondary" id="boot-repair-btn" style="width:100%;margin-top:12px">' +
'App-Reparatur (Cache + Service Worker)' +
'</button>'
: '') +
'</div>' +
'</div>'
var reloadBtn = document.getElementById('boot-reload-btn')
if (reloadBtn) {
reloadBtn.addEventListener('click', function () {
location.replace(createRecoveryUrl('retry'))
})
}
var repairBtn = document.getElementById('boot-repair-btn')
if (repairBtn) {
repairBtn.addEventListener('click', function () {
emit('PWA Boot Watchdog Manual Repair', {
attempt: Number(sessionStorage.getItem(ATTEMPT_COUNT_KEY) || '0'),
online: navigator.onLine
})
Promise.resolve()
.then(clearServiceWorkerCaches)
.finally(function () {
resetAttempts()
location.replace(createRecoveryUrl('manual-hard-recovery'))
})
})
}
}
function runWatchdog() {
window.setTimeout(function () {
if (hasBootstrapped()) {
resetAttempts()
return
}
var attempt = nextAttempt()
var online = navigator.onLine
if (attempt === 1) {
emit('PWA Boot Watchdog Soft', {
attempt: attempt,
online: online,
reason: online ? 'soft-reload' : 'offline-retry'
})
Promise.resolve()
.then(function () {
if ('serviceWorker' in navigator && navigator.serviceWorker.getRegistration) {
return navigator.serviceWorker.getRegistration().then(function (registration) {
if (registration) {
return registration.update().catch(function () {})
}
})
}
})
.finally(function () {
location.replace(createRecoveryUrl(online ? 'soft-reload' : 'offline-retry'))
})
return
}
if (attempt === 2 && online) {
emit('PWA Boot Watchdog Hard', {
attempt: attempt,
online: online,
reason: 'hard-recovery'
})
Promise.resolve()
.then(clearServiceWorkerCaches)
.finally(function () {
location.replace(createRecoveryUrl('hard-recovery'))
})
return
}
emit('PWA Boot Watchdog Fallback', {
attempt: attempt,
online: online,
reason: online ? 'retries-exhausted' : 'offline-retries-exhausted'
})
renderFallback(!online)
}, BOOT_TIMEOUT_MS)
}
runWatchdog()
})()
+142 -6
View File
@@ -1270,6 +1270,70 @@ html.scheme-dark .themed-select-option.is-selected {
flex-shrink: 0;
}
.profile-accordion {
margin-bottom: 12px;
border: 1px solid var(--app-border-muted);
border-radius: var(--app-radius-card);
background: var(--app-surface);
overflow: hidden;
}
.profile-accordion__summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
cursor: pointer;
list-style: none;
font-weight: 600;
color: var(--app-text-heading);
}
.profile-accordion__summary::-webkit-details-marker {
display: none;
}
.profile-accordion__title {
display: flex;
align-items: center;
gap: 10px;
}
.profile-accordion__chevron {
flex-shrink: 0;
transition: transform 0.2s ease;
opacity: 0.7;
}
.profile-accordion[open] .profile-accordion__chevron {
transform: rotate(180deg);
}
.profile-accordion__body {
padding: 0 16px 16px;
border-top: 1px solid var(--app-border-muted);
}
.profile-accordion-inner-card {
margin-top: 12px;
}
.profile-accordion-inner-card.form-card,
.profile-accordion-inner-card.member-editor-card {
margin-bottom: 0;
}
.btn-link {
background: none;
border: none;
padding: 0;
color: var(--app-accent, #f59e0b);
text-decoration: underline;
cursor: pointer;
font: inherit;
}
.profile-section-header {
display: flex;
align-items: center;
@@ -4110,7 +4174,7 @@ html.theme-cupertino .events-scroll-container {
}
}
.tank-liter-input .tank-liter-slider {
.tank-liter-slider {
--tank-slider-track-h: 10px;
--tank-slider-thumb: 26px;
width: 100%;
@@ -4125,13 +4189,13 @@ html.theme-cupertino .events-scroll-container {
touch-action: none;
}
.tank-liter-input .tank-liter-slider::-webkit-slider-runnable-track {
.tank-liter-slider::-webkit-slider-runnable-track {
height: var(--tank-slider-track-h);
border-radius: 999px;
background: rgba(148, 163, 184, 0.35);
}
.tank-liter-input .tank-liter-slider::-webkit-slider-thumb {
.tank-liter-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: var(--tank-slider-thumb);
height: var(--tank-slider-thumb);
@@ -4142,13 +4206,13 @@ html.theme-cupertino .events-scroll-container {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
}
.tank-liter-input .tank-liter-slider::-moz-range-track {
.tank-liter-slider::-moz-range-track {
height: var(--tank-slider-track-h);
border-radius: 999px;
background: rgba(148, 163, 184, 0.35);
}
.tank-liter-input .tank-liter-slider::-moz-range-thumb {
.tank-liter-slider::-moz-range-thumb {
width: var(--tank-slider-thumb);
height: var(--tank-slider-thumb);
border-radius: 50%;
@@ -4157,7 +4221,7 @@ html.theme-cupertino .events-scroll-container {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
}
.tank-liter-input .tank-liter-slider:disabled {
.tank-liter-slider:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@@ -4169,12 +4233,84 @@ html.theme-cupertino .events-scroll-container {
text-align: center;
}
/* Compact weather metric sliders (LogEntryEditor) */
.weather-metrics-grid {
gap: 12px 16px;
}
.weather-metrics-grid .weather-metrics-span-2 {
grid-column: 1 / -1;
}
.metric-range-input--compact {
gap: 0;
margin: 0;
}
.metric-range-input--compact .metric-range-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.metric-range-input--compact .metric-range-header label {
margin: 0;
font-size: 13px;
line-height: 1.25;
}
.metric-range-input--compact .metric-range-value {
font-size: 0.8125rem;
font-weight: 600;
color: #cbd5e1;
font-variant-numeric: tabular-nums;
white-space: nowrap;
flex-shrink: 0;
}
.metric-range-input--compact .metric-range-control-row {
display: flex;
align-items: center;
gap: 10px;
}
.metric-range-input--compact .metric-range-slider {
flex: 1;
min-width: 0;
width: auto;
margin: 0;
}
.metric-range-input--compact .metric-range-number {
width: 4.25rem;
min-width: 4.25rem;
max-width: 4.25rem;
flex-shrink: 0;
padding: 8px 6px;
text-align: center;
font-size: 0.9375rem;
}
@media (max-width: 480px) {
.tank-liter-input .tank-liter-slider {
--tank-slider-track-h: 12px;
--tank-slider-thumb: 32px;
margin: 12px 0 8px;
}
.metric-range-input--compact .metric-range-slider {
--tank-slider-track-h: 12px;
--tank-slider-thumb: 28px;
}
.metric-range-input--compact .metric-range-number {
width: 3.75rem;
min-width: 3.75rem;
max-width: 3.75rem;
padding: 10px 4px;
}
}
.vessel-tanks-section {
+10 -2
View File
@@ -3,9 +3,11 @@ import { DialogProvider } from './components/ModalDialog.tsx'
import AuthOnboarding from './components/AuthOnboarding.tsx'
import UserProfilePage from './components/UserProfilePage.tsx'
import LogbookDashboard from './components/LogbookDashboard.tsx'
import VesselForm from './components/VesselForm.tsx'
import LogbookVesselPicker from './components/LogbookVesselPicker.tsx'
import LogbookCrewPicker from './components/LogbookCrewPicker.tsx'
import { migrateLegacyCrewToPoolIfNeeded } from './services/crewMigration.js'
import { migrateLegacyYachtsToPoolIfNeeded } from './services/vesselMigration.js'
import { syncVesselPool } from './services/vesselPoolSync.js'
import { syncPersonPool } from './services/personPoolSync.js'
// Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert (Komponente bleibt erhalten)
// import DeviationForm from './components/DeviationForm.tsx'
@@ -164,6 +166,7 @@ function App() {
if (!userId) return
void syncAppearancePrefs(userId)
void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool())
void migrateLegacyYachtsToPoolIfNeeded().then(() => syncVesselPool())
}, [isAuthenticated])
useEffect(() => {
@@ -751,7 +754,12 @@ function App() {
)}
{activeTab === 'vessel' && (
<VesselForm logbookId={activeLogbookId} readOnly={logbookReadOnly || !isLogbookOwner} />
<LogbookVesselPicker
logbookId={activeLogbookId}
readOnly={logbookReadOnly || !isLogbookOwner}
selectionOnly={!isLogbookOwner && activeLogbookRecord?.isShared === 1}
onOpenProfile={isLogbookOwner ? () => setShowUserProfile(true) : undefined}
/>
)}
{activeTab === 'crew' && (
+24 -4
View File
@@ -1,13 +1,15 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import VesselForm from './VesselForm.tsx'
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
import type { LogbookCrewSelectionData } from '../types/person.js'
import { personToSnapshot } from '../utils/personSnapshots.js'
import LogEntriesList from './LogEntriesList.tsx'
import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react'
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
import type { VesselData } from '../types/vessel.js'
import type { LogbookVesselSelectionData } from '../types/vessel.js'
import { useAppTour, type AppTab } from '../context/AppTourContext.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
@@ -54,8 +56,18 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
cycleAppLanguage(i18n)
}
const { title, yacht, personPool, logbookCrewSelection, entries, gpsTracks, photos, firstEntryId } =
fixture
const {
title,
yacht,
vesselPool,
logbookVesselSelection,
personPool,
logbookCrewSelection,
entries,
gpsTracks,
photos,
firstEntryId
} = fixture
const demoSelection: LogbookCrewSelectionData = {
activeSkipperId: logbookCrewSelection.activeSkipperId,
@@ -152,7 +164,15 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
)}
{activeTab === 'vessel' && (
<VesselForm logbookId="demo" readOnly={true} preloadedData={yacht} />
<LogbookVesselPicker
logbookId="demo"
readOnly={true}
preloadedPool={vesselPool.map((v) => ({
payloadId: v.payloadId,
data: v.data as VesselData
}))}
preloadedSelection={logbookVesselSelection as LogbookVesselSelectionData}
/>
)}
{activeTab === 'crew' && (
+31 -22
View File
@@ -20,9 +20,6 @@ import {
Undo2,
Zap
} from 'lucide-react'
import { db } from '../services/db.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { decryptJson } from '../services/crypto.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import {
appendQuickEvent,
@@ -85,6 +82,7 @@ type LiveModal =
| 'temp'
| 'precip'
| 'sea_state'
| 'visibility'
| 'course'
| 'fuel'
| 'water'
@@ -243,24 +241,14 @@ export default function LiveLogView({
if (seq !== initSeqRef.current) return
setEntryId(id)
const logbookKey = await getLogbookKey(logbookId)
if (logbookKey) {
const yacht = await db.yachts.get(logbookId)
if (yacht) {
try {
const decrypted = await decryptJson(
yacht.encryptedData,
yacht.iv,
yacht.tag,
logbookKey
)
if (decrypted?.sails && Array.isArray(decrypted.sails)) {
setYachtSails(decrypted.sails as string[])
}
} catch {
// Yacht profile optional for live log
}
try {
const { resolveVesselForLogbook } = await import('../services/resolveVessel.js')
const vessel = await resolveVesselForLogbook(logbookId)
if (vessel?.sails && Array.isArray(vessel.sails)) {
setYachtSails(vessel.sails)
}
} catch {
// Vessel profile optional for live log
}
const loaded = await loadEntry(logbookId, id)
@@ -570,6 +558,12 @@ export default function LiveLogView({
remarks: LIVE_EVENT_CODES.PRESSURE
})
}
if (parsed.visibility) {
partials.push({
visibility: parsed.visibility,
remarks: LIVE_EVENT_CODES.VISIBILITY
})
}
if (parsed.tempC) {
partials.push({ remarks: liveTempRemark(parsed.tempC) })
}
@@ -737,6 +731,16 @@ export default function LiveLogView({
})
}, 'sea_state')
break
case 'visibility':
if (!primary) return
setModal('none')
void runQuickAction(async () => {
await appendQuickEvent(logbookId, entryId, {
visibility: primary,
remarks: LIVE_EVENT_CODES.VISIBILITY
})
}, 'visibility')
break
case 'course': {
const course = primary || lastCourseFromEvents(events)
if (!course) return
@@ -936,6 +940,9 @@ export default function LiveLogView({
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('sea_state')} disabled={busy}>
{t('logs.live_sea_state_btn')}
</button>
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('visibility')} disabled={busy}>
{t('logs.live_visibility_btn')}
</button>
</div>
)}
</div>
@@ -1178,7 +1185,7 @@ export default function LiveLogView({
</div>
)}
{['pressure', 'temp', 'precip', 'sea_state', 'fuel', 'water', 'sog', 'stw'].includes(modal) && (
{['pressure', 'temp', 'precip', 'sea_state', 'visibility', 'fuel', 'water', 'sog', 'stw'].includes(modal) && (
<div className="live-log-modal-backdrop" onClick={() => setModal('none')}>
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
<h3>
@@ -1186,6 +1193,7 @@ export default function LiveLogView({
{modal === 'temp' && t('logs.live_temp_btn')}
{modal === 'precip' && t('logs.live_precip_btn')}
{modal === 'sea_state' && t('logs.live_sea_state_btn')}
{modal === 'visibility' && t('logs.live_visibility_btn')}
{modal === 'fuel' && t('logs.live_fuel_btn')}
{modal === 'water' && t('logs.live_water_btn')}
{modal === 'sog' && t('logs.live_sog_btn')}
@@ -1205,7 +1213,8 @@ export default function LiveLogView({
: modal === 'temp' ? t('logs.live_temp_placeholder')
: modal === 'precip' ? t('logs.live_precip_placeholder')
: modal === 'sea_state' ? t('logs.live_sea_state_placeholder')
: modal === 'fuel' ? t('logs.live_fuel_placeholder')
: modal === 'visibility' ? t('logs.live_visibility_placeholder')
: modal === 'fuel' ? t('logs.live_fuel_placeholder')
: modal === 'water' ? t('logs.live_water_placeholder')
: modal === 'sog' ? t('logs.live_sog_placeholder')
: t('logs.live_stw_placeholder')
+40 -21
View File
@@ -15,6 +15,12 @@ import LiveLogView from './LiveLogView.tsx'
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
import { useDialog } from './ModalDialog.tsx'
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
import {
buildEntryListCache,
entryListItemFromLocal,
putEntryRecord
} from '../utils/entryListCache.js'
import { forEachInBatches } from '../utils/yieldToMain.js'
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react'
import {
carryOverFromPreviousDay,
@@ -116,24 +122,34 @@ export default function LogEntriesList({
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const local = await db.entries.where({ logbookId }).toArray()
const list: DecryptedEntryItem[] = []
const needsDecrypt: typeof local = []
for (const entry of local) {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
if (decrypted) {
list.push({
id: entry.payloadId,
date: decrypted.date || '',
dayOfTravel: decrypted.dayOfTravel || '',
departure: decrypted.departure || '',
destination: decrypted.destination || '',
updatedAt: entry.updatedAt,
skipperSignStatus: await getSkipperSignStatus(decrypted as Record<string, unknown>)
})
const cached = entryListItemFromLocal(entry)
if (cached) {
list.push(cached)
} else {
needsDecrypt.push(entry)
}
}
await forEachInBatches(needsDecrypt, 8, async (entry) => {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
if (!decrypted) return
const listCache = await buildEntryListCache(decrypted as Record<string, unknown>)
list.push({
id: entry.payloadId,
...listCache,
updatedAt: entry.updatedAt
})
void db.entries.update(entry.payloadId, { listCache }).catch((err) => {
console.warn('Failed to persist entry list cache:', err)
})
})
// Sort chronological descending (by date, or dayOfTravel numerical)
list.sort((a, b) => {
const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime()
@@ -309,14 +325,17 @@ export default function LogEntriesList({
const encrypted = await encryptJson(initialPayload, masterKey)
// Save locally
await db.entries.put({
payloadId: localId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: nowStr
})
await putEntryRecord(
{
payloadId: localId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: nowStr
},
initialPayload
)
// Queue for background sync
await db.syncQueue.put({
+148 -72
View File
@@ -33,6 +33,7 @@ import CourseDialInput from './CourseDialInput.tsx'
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
import { signLogEntry } from '../services/entrySigning.js'
import { putEntryRecord } from '../utils/entryListCache.js'
import { getLogbookAccess } from '../services/logbookAccess.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
@@ -56,6 +57,25 @@ import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
import TankLiterInput from './TankLiterInput.tsx'
import MetricRangeInput from './MetricRangeInput.tsx'
import {
formatHeelDeg,
formatPressureHpa,
formatSeaState,
formatVisibilityMeters,
HEEL_MAX_DEG,
HEEL_MIN_DEG,
parseHeelDeg,
parsePressureHpa,
parseSeaState,
parseVisibilityMeters,
PRESSURE_DEFAULT_HPA,
PRESSURE_MAX_HPA,
PRESSURE_MIN_HPA,
SEA_STATE_MAX,
SEA_STATE_MIN,
VISIBILITY_STEPS_M
} from '../utils/weatherMetrics.js'
import {
computeEveningTankMaxLiters,
computeRefilledTankMaxLiters,
@@ -201,6 +221,7 @@ export default function LogEntryEditor({
const [evWindDirection, setEvWindDirection] = useState('')
const [evWindStrength, setEvWindStrength] = useState('')
const [evSeaState, setEvSeaState] = useState('')
const [evVisibility, setEvVisibility] = useState('')
const [evWeatherIcon, setEvWeatherIcon] = useState('')
const [evCurrent, setEvCurrent] = useState('')
const [evHeel, setEvHeel] = useState('')
@@ -361,6 +382,7 @@ export default function LogEntryEditor({
windDirection: evWindDirection,
windStrength: evWindStrength,
seaState: evSeaState,
visibility: evVisibility,
weatherIcon: evWeatherIcon,
current: evCurrent,
heel: evHeel,
@@ -383,7 +405,7 @@ export default function LogEntryEditor({
return hasUnsavedEventDraft(buildEventFromForm(), editingEventIndex, events)
}, [
evTime, evMgk, evRwk, evWindPressure, evWindDirection, evWindStrength, evSeaState,
evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance,
evVisibility, evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance,
evGpsLat, evGpsLng, evRemarks, editingEventIndex, events
])
@@ -391,9 +413,15 @@ export default function LogEntryEditor({
currentFingerprint !== savedFingerprint || hasPendingEventForm
)
const saveBeforeLeaveRef = useRef<(() => Promise<void>) | null>(null)
const invokeSaveBeforeLeave = useCallback(async () => {
if (saveBeforeLeaveRef.current) await saveBeforeLeaveRef.current()
}, [])
const { confirmLeave } = useRegisterUnsavedChanges(
`log-entry-${entryId}`,
!readOnly && !loading && isDirty
!readOnly && !loading && isDirty,
invokeSaveBeforeLeave
)
const handleBack = async () => {
@@ -427,14 +455,17 @@ export default function LogEntryEditor({
const encrypted = await encryptJson(entryData, masterKey)
const now = new Date().toISOString()
await db.entries.put({
payloadId: entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await putEntryRecord(
{
payloadId: entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
},
entryData
)
await db.syncQueue.put({
action: 'update',
@@ -649,33 +680,25 @@ export default function LogEntryEditor({
}
}, [fuelEveningMax, fuelEvening])
// Load yacht sails and tank capacities
// Load vessel sails and tank capacities
useEffect(() => {
async function loadYachtMeta() {
if (readOnly && preloadedYacht) {
if (preloadedYacht.sails) setYachtSails(preloadedYacht.sails)
setTankCapacities(extractTankCapacitiesFromYacht(preloadedYacht))
return
}
try {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) return
const yacht = await db.yachts.get(logbookId)
if (yacht) {
const decrypted = await decryptJson(yacht.encryptedData, yacht.iv, yacht.tag, masterKey)
if (decrypted) {
if (decrypted.sails && Array.isArray(decrypted.sails)) {
setYachtSails(decrypted.sails)
}
setTankCapacities(extractTankCapacitiesFromYacht(decrypted))
}
const { resolveVesselForLogbook } = await import('../services/resolveVessel.js')
const vessel =
readOnly && preloadedYacht
? (preloadedYacht as Record<string, unknown>)
: await resolveVesselForLogbook(logbookId, { preloadedYacht: preloadedYacht ?? undefined })
if (!vessel) return
if (vessel.sails && Array.isArray(vessel.sails)) {
setYachtSails(vessel.sails)
}
setTankCapacities(extractTankCapacitiesFromYacht(vessel))
} catch (err) {
console.error('Failed to load yacht meta in editor:', err)
console.error('Failed to load vessel meta in editor:', err)
}
}
loadYachtMeta()
void loadYachtMeta()
}, [logbookId, preloadedYacht, readOnly])
// Load entry details
@@ -993,6 +1016,7 @@ export default function LogEntryEditor({
setEvWindStrength(parsed.windStrength)
setEvWindPressure(parsed.windPressure)
if (parsed.windDirection) setEvWindDirection(parsed.windDirection)
if (parsed.visibility) setEvVisibility(parsed.visibility)
if (parsed.weatherIcon) setEvWeatherIcon(parsed.weatherIcon)
showAlert(t('settings.weather_success'))
@@ -1055,6 +1079,7 @@ export default function LogEntryEditor({
setEvWindDirection('')
setEvWindStrength('')
setEvSeaState('')
setEvVisibility('')
setEvWeatherIcon('')
setEvCurrent('')
setEvHeel('')
@@ -1078,6 +1103,7 @@ export default function LogEntryEditor({
setEvWindDirection(normalized.windDirection)
setEvWindStrength(normalized.windStrength)
setEvSeaState(normalized.seaState)
setEvVisibility(normalized.visibility)
setEvWeatherIcon(normalized.weatherIcon)
setEvCurrent(normalized.current)
setEvHeel(normalized.heel)
@@ -1191,8 +1217,7 @@ export default function LogEntryEditor({
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const saveEntryChanges = useCallback(async () => {
if (readOnly) return
let eventsToSave = events
@@ -1220,7 +1245,6 @@ export default function LogEntryEditor({
setSaving(true)
setError(null)
setSuccess(false)
try {
await persistEntryToDb({
@@ -1229,9 +1253,28 @@ export default function LogEntryEditor({
})
await clearEntryDraft(logbookId, entryId)
setSuccess(true)
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
} finally {
setSaving(false)
}
}, [
readOnly, events, hasPendingEventForm, editingEventIndex, isDirty,
resolveSignaturesAfterContentChange, applyEventFormToEvents, buildEventFromForm,
clearEventForm, persistEntryToDb, logbookId, entryId, t
])
useEffect(() => {
saveBeforeLeaveRef.current = readOnly ? null : saveEntryChanges
}, [readOnly, saveEntryChanges])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (readOnly) return
setSuccess(false)
try {
await saveEntryChanges()
setSuccess(true)
setTimeout(() => {
setSuccess(false)
onBack()
@@ -1239,8 +1282,6 @@ export default function LogEntryEditor({
} catch (err: unknown) {
console.error('Failed to save entry details:', err)
setError(getErrorMessage(err, t('errors.save_failed')))
} finally {
setSaving(false)
}
}
@@ -1706,8 +1747,8 @@ export default function LogEntryEditor({
</div>
</div>
<div className="form-grid mb-4">
<div className="input-group course-dial-section">
<div className="form-grid weather-metrics-grid mb-4">
<div className="input-group course-dial-section weather-metrics-span-2">
<label>{t('logs.event_wind_direction')}</label>
<CourseDialInput
value={evWindDirection}
@@ -1731,41 +1772,76 @@ export default function LogEntryEditor({
/>
</div>
<div className="input-group">
<label>{t('logs.event_wind_pressure')}</label>
<input
type="text"
placeholder="e.g. 1013 hPa"
className="input-text"
value={evWindPressure}
onChange={(e) => setEvWindPressure(e.target.value)}
disabled={saving || weatherLoading}
/>
</div>
<MetricRangeInput
label={t('logs.event_wind_pressure')}
value={evWindPressure}
onChange={setEvWindPressure}
disabled={saving || weatherLoading}
min={PRESSURE_MIN_HPA}
max={PRESSURE_MAX_HPA}
step={1}
defaultNumeric={PRESSURE_DEFAULT_HPA}
parse={parsePressureHpa}
format={formatPressureHpa}
formatDisplay={(hpa) =>
t('logs.weather_slider_pressure', { value: hpa, defaultValue: `${hpa} hPa` })}
numberMin={PRESSURE_MIN_HPA}
numberMax={PRESSURE_MAX_HPA}
numberStep={1}
numberPlaceholder="1013"
/>
<div className="input-group">
<label>{t('logs.event_sea_state')}</label>
<input
type="text"
placeholder="e.g. 3"
className="input-text"
value={evSeaState}
onChange={(e) => setEvSeaState(e.target.value)}
disabled={saving}
/>
</div>
<MetricRangeInput
label={t('logs.event_sea_state')}
value={evSeaState}
onChange={setEvSeaState}
disabled={saving}
min={SEA_STATE_MIN}
max={SEA_STATE_MAX}
step={1}
defaultNumeric={0}
parse={parseSeaState}
format={formatSeaState}
formatDisplay={(level) =>
t('logs.weather_slider_sea_state', { value: level, defaultValue: `${level}` })}
numberMin={SEA_STATE_MIN}
numberMax={SEA_STATE_MAX}
numberStep={1}
numberPlaceholder="3"
allowLegacyText
/>
<div className="input-group">
<label>{t('logs.event_heel')}</label>
<input
type="text"
placeholder="e.g. 5"
className="input-text"
value={evHeel}
onChange={(e) => setEvHeel(e.target.value)}
disabled={saving}
/>
</div>
<MetricRangeInput
label={t('logs.event_visibility')}
value={evVisibility}
onChange={setEvVisibility}
disabled={saving || weatherLoading}
discreteValues={VISIBILITY_STEPS_M}
defaultNumeric={10000}
parse={parseVisibilityMeters}
format={formatVisibilityMeters}
formatDisplay={(m) => formatVisibilityMeters(m)}
hideNumberInput
/>
<MetricRangeInput
label={t('logs.event_heel')}
value={evHeel}
onChange={setEvHeel}
disabled={saving}
min={HEEL_MIN_DEG}
max={HEEL_MAX_DEG}
step={1}
defaultNumeric={0}
parse={parseHeelDeg}
format={formatHeelDeg}
formatDisplay={(deg) =>
t('logs.weather_slider_heel', { value: deg, defaultValue: `${deg}°` })}
numberMin={HEEL_MIN_DEG}
numberMax={HEEL_MAX_DEG}
numberStep={1}
numberPlaceholder="5"
/>
</div>
<div className="form-grid mb-4">
+32 -24
View File
@@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next'
import { cycleAppLanguage } from '../utils/i18nLanguages.js'
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
import { loadLogbookSearchFieldsBatch } from '../services/logbookSearchIndex.js'
import { logbookMatchesFilter, type LogbookSearchFields } from '../utils/logbookFilter.js'
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
import BetaBadge from './BetaBadge.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
@@ -20,26 +22,6 @@ interface LogbookDashboardProps {
onOpenProfile: () => void
}
function logbookMatchesFilter(lb: DecryptedLogbook, query: string, locale: string): boolean {
const q = query.trim().toLowerCase()
if (!q) return true
if (lb.title.toLowerCase().includes(q)) return true
const updated = new Date(lb.updatedAt)
const year = updated.getFullYear().toString()
if (year.includes(q)) return true
const dateLabel = updated.toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric'
}).toLowerCase()
if (dateLabel.includes(q)) return true
return false
}
type LogbookSortKey = 'name' | 'date'
type LogbookSortDirection = 'asc' | 'desc'
@@ -72,6 +54,9 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
const [refreshing, setRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [filterQuery, setFilterQuery] = useState('')
const [searchFieldsByLogbookId, setSearchFieldsByLogbookId] = useState<Map<string, LogbookSearchFields>>(
() => new Map()
)
const [sortBy, setSortBy] = useState<LogbookSortKey>('date')
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
const filterInputRef = useRef<HTMLInputElement>(null)
@@ -96,6 +81,23 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
loadLogbooks()
}, [])
useEffect(() => {
const ids = logbooks.map((lb) => lb.id)
if (ids.length === 0) {
setSearchFieldsByLogbookId(new Map())
return
}
let cancelled = false
void loadLogbookSearchFieldsBatch(ids).then((index) => {
if (!cancelled) setSearchFieldsByLogbookId(index)
})
return () => {
cancelled = true
}
}, [logbooks])
const loadLogbooks = async (isRefresh = false) => {
if (isRefresh) setRefreshing(true)
else setLoading(true)
@@ -203,12 +205,18 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
const filterActive = filterQuery.trim().length > 0
const filteredOwnedLogbooks = useMemo(
() => ownedLogbooks.filter((lb) => logbookMatchesFilter(lb, filterQuery, i18n.language)),
[ownedLogbooks, filterQuery, i18n.language]
() =>
ownedLogbooks.filter((lb) =>
logbookMatchesFilter(lb, filterQuery, i18n.language, searchFieldsByLogbookId.get(lb.id))
),
[ownedLogbooks, filterQuery, i18n.language, searchFieldsByLogbookId]
)
const filteredSharedLogbooks = useMemo(
() => sharedLogbooks.filter((lb) => logbookMatchesFilter(lb, filterQuery, i18n.language)),
[sharedLogbooks, filterQuery, i18n.language]
() =>
sharedLogbooks.filter((lb) =>
logbookMatchesFilter(lb, filterQuery, i18n.language, searchFieldsByLogbookId.get(lb.id))
),
[sharedLogbooks, filterQuery, i18n.language, searchFieldsByLogbookId]
)
const sortedOwnedLogbooks = useMemo(
() => sortLogbooks(filteredOwnedLogbooks, sortBy, sortDirection, i18n.language),
@@ -0,0 +1,199 @@
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Ship, Save, Check } from 'lucide-react'
import type { LogbookVesselSelectionData, VesselData } from '../types/vessel.js'
import type { DecryptedVessel } from '../services/vesselPool.js'
import { loadVesselPool } from '../services/vesselPool.js'
import { loadLogbookVesselSelection, saveLogbookVesselSelectionFromId } from '../services/logbookVesselSelection.js'
import { resolveVesselForLogbook } from '../services/resolveVessel.js'
import { vesselDataFromSnapshot } from '../utils/vesselSnapshot.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
export interface LogbookVesselPickerProps {
logbookId: string
readOnly?: boolean
preloadedPool?: Array<{ payloadId: string; data: VesselData }>
preloadedSelection?: LogbookVesselSelectionData
selectionOnly?: boolean
onOpenProfile?: () => void
}
export default function LogbookVesselPicker({
logbookId,
readOnly = false,
preloadedPool,
preloadedSelection,
selectionOnly = false,
onOpenProfile
}: LogbookVesselPickerProps) {
const { t } = useTranslation()
const [loading, setLoading] = useState(!preloadedSelection)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(null)
const [pool, setPool] = useState<DecryptedVessel[]>([])
const [activeVesselId, setActiveVesselId] = useState<string | null>(null)
const [resolvedVessel, setResolvedVessel] = useState<VesselData | null>(null)
const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const selection =
preloadedSelection ??
(logbookId === 'demo' ? null : await loadLogbookVesselSelection(logbookId))
if (selection) {
setActiveVesselId(selection.activeVesselId)
}
if (preloadedPool) {
setPool(preloadedPool.map((p) => ({ payloadId: p.payloadId, data: p.data })))
} else if (selectionOnly && selection?.vesselSnapshot) {
const data = vesselDataFromSnapshot(selection.vesselSnapshot)
if (data) {
setPool([{ payloadId: selection.vesselSnapshot.id, data }])
}
} else {
setPool(await loadVesselPool())
}
const vessel = await resolveVesselForLogbook(logbookId, {
preloadedSelection: selection ?? undefined
})
setResolvedVessel(vessel)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to load vessel selection')
} finally {
setLoading(false)
}
}, [logbookId, preloadedPool, preloadedSelection, selectionOnly])
useEffect(() => {
void loadData()
}, [loadData])
const handleSave = async () => {
if (readOnly || logbookId === 'demo') return
setSaving(true)
setError(null)
setSaved(false)
try {
const selection = await saveLogbookVesselSelectionFromId(logbookId, activeVesselId)
const vessel = vesselDataFromSnapshot(selection.vesselSnapshot)
setResolvedVessel(vessel)
setSaved(true)
trackPlausibleEvent(PlausibleEvents.VESSEL_SAVED, { context: 'logbook_selection' })
setTimeout(() => setSaved(false), 3000)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to save')
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div className="tab-placeholder">
<Ship className="header-logo spin" size={48} />
<p>{t('vessel_pool.loading')}</p>
</div>
)
}
return (
<div className="crew-dashboard-layout" data-tour="logbook-vessel-picker">
<div className="form-card">
<div className="form-header">
<Ship size={24} className="form-icon" />
<h2>{t('logbook_vessel.title')}</h2>
</div>
<p className="help-text mb-4">{t('logbook_vessel.subtitle')}</p>
{selectionOnly && <p className="help-text mb-4">{t('logbook_vessel.selection_only_hint')}</p>}
{!selectionOnly && !readOnly && onOpenProfile && (
<p className="help-text mb-4">
<button type="button" className="btn-link" onClick={onOpenProfile}>
{t('logbook_vessel.manage_in_profile')}
</button>
</p>
)}
{error && <div className="auth-error mb-4">{error}</div>}
<div className="input-group mb-4">
<label>{t('logbook_vessel.active_vessel')}</label>
{pool.length === 0 ? (
<p className="help-text">{t('logbook_vessel.no_vessels_in_pool')}</p>
) : (
<div className="crew-selection-list">
{pool.map((v) => (
<label key={v.payloadId} className="crew-selection-item">
<input
type="radio"
name={`vessel-${logbookId}`}
checked={activeVesselId === v.payloadId}
onChange={() => !readOnly && setActiveVesselId(v.payloadId)}
disabled={readOnly}
/>
<Ship size={16} aria-hidden="true" />
<span>{v.data.name || t('logbook_vessel.unnamed')}</span>
</label>
))}
{!readOnly && (
<label className="crew-selection-item">
<input
type="radio"
name={`vessel-${logbookId}`}
checked={activeVesselId === null}
onChange={() => setActiveVesselId(null)}
/>
<span>{t('logbook_vessel.no_vessel')}</span>
</label>
)}
</div>
)}
</div>
{resolvedVessel && (
<div className="member-editor-card glass mb-4 logbook-vessel-summary">
<h3 className="mb-2">{resolvedVessel.name}</h3>
<dl className="profile-dl">
{resolvedVessel.homePort && (
<div className="profile-dl-row">
<dt>{t('vessel.port')}</dt>
<dd>{resolvedVessel.homePort}</dd>
</div>
)}
{resolvedVessel.registrationNumber && (
<div className="profile-dl-row">
<dt>{t('vessel.registration')}</dt>
<dd>{resolvedVessel.registrationNumber}</dd>
</div>
)}
{resolvedVessel.mmsi && (
<div className="profile-dl-row">
<dt>{t('vessel.mmsi')}</dt>
<dd>{resolvedVessel.mmsi}</dd>
</div>
)}
</dl>
</div>
)}
{!readOnly && logbookId !== 'demo' && (
<div className="form-actions">
{saved && (
<div className="success-toast">
<Check size={16} />
<span>{t('logbook_vessel.saved')}</span>
</div>
)}
<button type="button" className="btn primary" onClick={() => void handleSave()} disabled={saving}>
<Save size={18} />
{t('logbook_vessel.save')}
</button>
</div>
)}
</div>
</div>
)
}
+196
View File
@@ -0,0 +1,196 @@
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
export interface MetricRangeInputProps {
id?: string
label: string
value: string
onChange: (value: string) => void
disabled?: boolean
min?: number
max?: number
step?: number
discreteValues?: readonly number[]
parse: (value: string) => number | null
format: (numeric: number) => string
defaultNumeric: number
/** Shown next to the label (current value). */
formatDisplay: (numeric: number, unset: boolean) => string
numberMin?: number
numberMax?: number
numberStep?: number | 'any'
numberPlaceholder?: string
allowLegacyText?: boolean
hideNumberInput?: boolean
}
function clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n))
}
export default function MetricRangeInput({
id,
label,
value,
onChange,
disabled = false,
min,
max,
discreteValues,
parse,
format,
defaultNumeric,
formatDisplay,
numberMin,
numberMax,
numberStep = 'any',
numberPlaceholder,
allowLegacyText = false,
hideNumberInput = false
}: MetricRangeInputProps) {
const { t } = useTranslation()
const unsetLabel = t('logs.weather_slider_unset', { defaultValue: '—' })
const isLegacyText =
allowLegacyText && value.trim() !== '' && parse(value) === null
const emitNumeric = useCallback(
(numeric: number) => {
onChange(format(numeric))
},
[onChange, format]
)
const parsed = parse(value)
const unset = parsed === null
const sliderNumeric = unset ? defaultNumeric : parsed
const useDiscrete = discreteValues != null && discreteValues.length > 1
let sliderMin = 0
let sliderMax = 0
let sliderValue = 0
if (useDiscrete) {
sliderMin = 0
sliderMax = discreteValues.length - 1
if (unset) {
sliderValue = 0
} else {
let bestIdx = 0
let bestDiff = Math.abs(discreteValues[0] - sliderNumeric)
for (let i = 1; i < discreteValues.length; i++) {
const diff = Math.abs(discreteValues[i] - sliderNumeric)
if (diff < bestDiff) {
bestDiff = diff
bestIdx = i
}
}
sliderValue = bestIdx
}
} else if (min != null && max != null) {
sliderMin = min
sliderMax = max
sliderValue = clamp(sliderNumeric, min, max)
}
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const idx = Number(e.target.value)
if (useDiscrete && discreteValues) {
emitNumeric(discreteValues[clamp(idx, 0, discreteValues.length - 1)])
return
}
if (min != null && max != null) {
emitNumeric(Number(e.target.value))
}
}
const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value)
}
const handleNumberBlur = () => {
const next = parse(value)
if (next == null) {
if (!value.trim()) onChange('')
return
}
onChange(format(next))
}
const hintNumeric = useDiscrete && discreteValues
? discreteValues[sliderValue]
: sliderValue
const displayLabel = unset ? unsetLabel : formatDisplay(hintNumeric, false)
if (isLegacyText) {
return (
<div className="input-group metric-range-input metric-range-input--compact">
<div className="metric-range-header">
<label htmlFor={id}>{label}</label>
</div>
<input
id={id}
type="text"
className="input-text"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
placeholder={numberPlaceholder}
/>
</div>
)
}
const hasSlider = useDiscrete || (min != null && max != null)
return (
<div className="input-group metric-range-input metric-range-input--compact">
<div className="metric-range-header">
<label htmlFor={hideNumberInput ? undefined : id}>{label}</label>
{hasSlider && (
<span className="metric-range-value" aria-live="polite">
{displayLabel}
</span>
)}
</div>
{hasSlider && (
<div className="metric-range-control-row">
<input
type="range"
className="tank-liter-slider metric-range-slider"
min={sliderMin}
max={sliderMax}
step={1}
value={sliderValue}
onChange={handleSliderChange}
disabled={disabled}
aria-valuemin={sliderMin}
aria-valuemax={sliderMax}
aria-valuenow={sliderValue}
aria-label={label}
aria-valuetext={displayLabel}
/>
{!hideNumberInput && (
<input
id={id}
type="number"
className="input-text metric-range-number"
value={unset ? '' : value.replace(/\s*hPa\s*$/i, '').replace(/°\s*$/, '')}
onChange={handleNumberChange}
onBlur={handleNumberBlur}
disabled={disabled}
min={numberMin}
max={numberMax}
step={numberStep}
placeholder={numberPlaceholder}
inputMode="decimal"
aria-label={label}
/>
)}
</div>
)}
</div>
)
}
+106 -24
View File
@@ -10,9 +10,19 @@ import React, {
} from 'react'
import { useTranslation } from 'react-i18next'
export type ConfirmLeaveChoice = 'stay' | 'save' | 'discard'
interface DialogContextType {
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
showConfirm: (message: string, title?: string, confirmText?: string, cancelText?: string) => Promise<boolean>
showConfirmLeave: (
message: string,
title?: string,
stayLabel?: string,
saveLabel?: string,
discardLabel?: string,
options?: { showSave?: boolean }
) => Promise<ConfirmLeaveChoice>
}
const DialogContext = createContext<DialogContextType | undefined>(undefined)
@@ -34,12 +44,16 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false)
const [title, setTitle] = useState('')
const [message, setMessage] = useState('')
const [type, setType] = useState<'alert' | 'confirm'>('alert')
const [type, setType] = useState<'alert' | 'confirm' | 'confirm-leave'>('alert')
const [confirmLabel, setConfirmLabel] = useState('OK')
const [cancelLabel, setCancelLabel] = useState('Cancel')
const [saveLabel, setSaveLabel] = useState('')
const [discardLabel, setDiscardLabel] = useState('')
const [showSaveOption, setShowSaveOption] = useState(false)
const alertResolveRef = useRef<(() => void) | null>(null)
const confirmResolveRef = useRef<((val: boolean) => void) | null>(null)
const confirmLeaveResolveRef = useRef<((val: ConfirmLeaveChoice) => void) | null>(null)
const showAlert = useCallback((msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
setMessage(msg)
@@ -71,6 +85,36 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
})
}, [t])
const showConfirmLeave = useCallback((
msg: string,
headerTitle?: string,
btnStay?: string,
btnSave?: string,
btnDiscard?: string,
options?: { showSave?: boolean }
): Promise<ConfirmLeaveChoice> => {
setMessage(msg)
setTitle(headerTitle || '')
setType('confirm-leave')
setCancelLabel(btnStay || t('common.unsaved_changes_stay'))
setSaveLabel(btnSave || t('common.unsaved_changes_save_leave'))
setDiscardLabel(btnDiscard || t('common.unsaved_changes_discard'))
setShowSaveOption(options?.showSave !== false)
setIsOpen(true)
return new Promise<ConfirmLeaveChoice>((resolve) => {
confirmLeaveResolveRef.current = resolve
})
}, [t])
const closeConfirmLeave = useCallback((choice: ConfirmLeaveChoice) => {
setIsOpen(false)
if (confirmLeaveResolveRef.current) {
confirmLeaveResolveRef.current(choice)
confirmLeaveResolveRef.current = null
}
}, [])
const handleConfirm = useCallback(() => {
setIsOpen(false)
if (type === 'confirm' && confirmResolveRef.current) {
@@ -83,19 +127,23 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
}, [type])
const handleCancel = useCallback(() => {
if (type === 'confirm-leave') {
closeConfirmLeave('stay')
return
}
setIsOpen(false)
if (confirmResolveRef.current) {
confirmResolveRef.current(false)
confirmResolveRef.current = null
}
}, [])
}, [type, closeConfirmLeave])
useEffect(() => {
if (!isOpen) return
confirmRef.current?.focus()
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (type === 'confirm') handleCancel()
if (type === 'confirm' || type === 'confirm-leave') handleCancel()
else handleConfirm()
}
}
@@ -104,8 +152,8 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
}, [isOpen, type, handleCancel, handleConfirm])
const contextValue = useMemo(
() => ({ showAlert, showConfirm }),
[showAlert, showConfirm]
() => ({ showAlert, showConfirm, showConfirmLeave }),
[showAlert, showConfirm, showConfirmLeave]
)
return (
@@ -114,7 +162,7 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
{isOpen && (
<div
className="custom-dialog-overlay"
onClick={type === 'confirm' ? handleCancel : handleConfirm}
onClick={type === 'confirm' || type === 'confirm-leave' ? handleCancel : handleConfirm}
>
<div
className="custom-dialog-card glass scale-in"
@@ -133,25 +181,59 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
{message}
</p>
<div className="custom-dialog-actions">
{type === 'confirm' && (
<button
type="button"
className="btn secondary"
onClick={handleCancel}
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
>
{cancelLabel}
</button>
{type === 'confirm-leave' ? (
<>
<button
ref={confirmRef}
type="button"
className="btn secondary"
onClick={handleCancel}
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
>
{cancelLabel}
</button>
{showSaveOption && (
<button
type="button"
className="btn primary"
onClick={() => closeConfirmLeave('save')}
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
>
{saveLabel}
</button>
)}
<button
type="button"
className="btn danger"
onClick={() => closeConfirmLeave('discard')}
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
>
{discardLabel}
</button>
</>
) : (
<>
{type === 'confirm' && (
<button
type="button"
className="btn secondary"
onClick={handleCancel}
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
>
{cancelLabel}
</button>
)}
<button
ref={confirmRef}
type="button"
className="btn primary"
onClick={handleConfirm}
style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}
>
{confirmLabel}
</button>
</>
)}
<button
ref={confirmRef}
type="button"
className="btn primary"
onClick={handleConfirm}
style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}
>
{confirmLabel}
</button>
</div>
</div>
</div>
@@ -0,0 +1,36 @@
import { ChevronDown } from 'lucide-react'
import type { ReactNode } from 'react'
interface ProfileAccordionSectionProps {
id: string
title: string
icon?: ReactNode
defaultOpen?: boolean
/** When set, forces the section open (e.g. during onboarding tour). */
forceOpen?: boolean
children: ReactNode
}
export default function ProfileAccordionSection({
id,
title,
icon,
defaultOpen = false,
forceOpen,
children
}: ProfileAccordionSectionProps) {
const isOpen = forceOpen !== undefined ? forceOpen : defaultOpen
return (
<details className="profile-accordion" open={isOpen || undefined} data-section={id}>
<summary className="profile-accordion__summary">
<span className="profile-accordion__title">
{icon}
<span>{title}</span>
</span>
<ChevronDown size={20} className="profile-accordion__chevron" aria-hidden="true" />
</summary>
<div className="profile-accordion__body">{children}</div>
</details>
)
}
@@ -6,7 +6,8 @@ import {
enableCollaboratorChangePush,
fetchPushPrefs,
getNotificationPermission,
isPushSupported
isPushSupported,
preloadPushService
} from '../services/pushNotifications.js'
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
@@ -28,6 +29,7 @@ export default function PushNotificationSettings() {
setLoading(false)
return
}
void preloadPushService()
try {
const prefs = await fetchPushPrefs()
setEnabled(prefs.collaboratorChangesEnabled)
@@ -56,7 +58,8 @@ export default function PushNotificationSettings() {
trackPlausibleEvent(PlausibleEvents.PUSH_DISABLED)
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('profile.push_error')
console.error('Failed to toggle push notifications:', err)
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
showAlert(message)
void loadPrefs()
} finally {
+34 -3
View File
@@ -3,8 +3,10 @@ import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18nLanguages.js'
import { decryptJson } from '../services/crypto.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import VesselForm from './VesselForm.tsx'
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
import type { LogbookVesselSelectionData } from '../types/vessel.js'
import { emptyLogbookVesselSelection } from '../types/vessel.js'
import type { LogbookCrewSelectionData } from '../types/person.js'
import { emptyLogbookCrewSelection } from '../types/person.js'
import { legacyCrewRecordsToLogbookSelection } from '../utils/personSnapshots.js'
@@ -38,6 +40,9 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
const [logbookCrewSelection, setLogbookCrewSelection] = useState<LogbookCrewSelectionData>(
emptyLogbookCrewSelection()
)
const [logbookVesselSelection, setLogbookVesselSelection] = useState<LogbookVesselSelectionData>(
emptyLogbookVesselSelection()
)
const [legacyCrews, setLegacyCrews] = useState<any[]>([])
const [entries, setEntries] = useState<any[]>([])
const [photos, setPhotos] = useState<any[]>([])
@@ -97,6 +102,31 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
}
}
if (data.logbookVesselSelection) {
const decVessel = await decryptJson(
data.logbookVesselSelection.encryptedData,
data.logbookVesselSelection.iv,
data.logbookVesselSelection.tag,
keyBuffer
)
if (decVessel) {
setLogbookVesselSelection({
activeVesselId: decVessel.activeVesselId ?? null,
vesselSnapshot: decVessel.vesselSnapshot ?? null
})
}
} else if (decYacht) {
const legacy = decYacht as Record<string, unknown>
setLogbookVesselSelection({
activeVesselId: 'legacy-yacht',
vesselSnapshot: {
id: 'legacy-yacht',
name: typeof legacy.name === 'string' ? legacy.name : '',
...legacy
} as import('../types/vessel.js').VesselSnapshot
})
}
const decCrews: Array<{ payloadId: string; data: PersonData }> = []
if (data.crews) {
for (const c of data.crews) {
@@ -257,10 +287,11 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
)}
{activeTab === 'vessel' && (
<VesselForm
<LogbookVesselPicker
logbookId="shared"
readOnly={true}
preloadedData={yacht}
selectionOnly={true}
preloadedSelection={logbookVesselSelection}
/>
)}
+5 -2
View File
@@ -10,7 +10,8 @@ import { apiFetch } from '../services/api.js'
import {
enableCollaboratorChangePush,
isCollaboratorPushActive,
isPushSupported
isPushSupported,
preloadPushService
} from '../services/pushNotifications.js'
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
@@ -55,6 +56,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
loadCollaborators()
loadShareLink()
}
void preloadPushService()
}, [logbookId])
const loadShareLink = async () => {
@@ -191,7 +193,8 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED)
} catch (err: unknown) {
console.error('Failed to enable push after invite:', err)
await showAlert(err instanceof Error ? err.message : t('profile.push_error'))
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
await showAlert(message)
}
}
+47 -4
View File
@@ -15,6 +15,7 @@ import {
Anchor,
Gauge,
Sailboat,
Ship,
Timer,
Share2,
Calendar,
@@ -31,6 +32,9 @@ import {
import AccountDangerZone from './AccountDangerZone.tsx'
import UserProfilePreferences from './UserProfilePreferences.tsx'
import PersonPoolForm from './PersonPoolForm.tsx'
import VesselPoolForm from './VesselPoolForm.tsx'
import ProfileAccordionSection from './ProfileAccordionSection.tsx'
import { useAppTour } from '../context/AppTourContext.tsx'
import BetaBadge from './BetaBadge.tsx'
import { useDialog } from './ModalDialog.tsx'
import {
@@ -137,6 +141,11 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
connStatusClassName
} = useSyncIndicator()
const { isActive: tourActive, currentStepId: tourStepId } = useAppTour()
const fleetSectionTourOpen =
tourActive &&
(tourStepId === 'profile_vessel_pool' || tourStepId === 'profile_crew_pool')
const sharedLogbookCount = useLiveQuery(
() => db.logbooks.filter((lb) => lb.isShared === 1).count(),
[]
@@ -444,8 +453,14 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
</section>
) : profile ? (
<>
<ProfileAccordionSection
id="account"
title={t('profile.sections.account')}
icon={<User size={20} aria-hidden="true" />}
defaultOpen
>
<div data-tour="profile-preferences">
<section className="form-card">
<section className="form-card profile-accordion-inner-card">
<div className="form-header">
<User size={24} className="form-icon" />
<h2>{t('profile.identity_title')}</h2>
@@ -487,10 +502,25 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
<UserProfilePreferences userId={profile.userId} />
</div>
</ProfileAccordionSection>
<ProfileAccordionSection
id="fleet"
title={t('profile.sections.fleet')}
icon={<Ship size={20} aria-hidden="true" />}
defaultOpen
forceOpen={fleetSectionTourOpen ? true : undefined}
>
<VesselPoolForm />
<PersonPoolForm />
</ProfileAccordionSection>
<section className="member-editor-card glass">
<ProfileAccordionSection
id="security"
title={t('profile.sections.security')}
icon={<Shield size={20} aria-hidden="true" />}
>
<section className="member-editor-card glass profile-accordion-inner-card">
<div className="profile-section-header">
<Shield size={20} />
<h3>{t('profile.security_title')}</h3>
@@ -729,7 +759,14 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
</div>
</section>
<section className="form-card profile-stats-section">
</ProfileAccordionSection>
<ProfileAccordionSection
id="stats"
title={t('profile.sections.stats')}
icon={<BarChart2 size={20} aria-hidden="true" />}
>
<section className="form-card profile-stats-section profile-accordion-inner-card">
<div className="form-header">
<BarChart2 size={24} className="form-icon" />
<div>
@@ -791,8 +828,14 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
</div>
)}
</section>
</ProfileAccordionSection>
<AccountDangerZone className="mt-6" />
<ProfileAccordionSection
id="danger"
title={t('profile.sections.danger')}
>
<AccountDangerZone className="profile-accordion-inner-card" />
</ProfileAccordionSection>
</>
) : null}
</main>
+328
View File
@@ -0,0 +1,328 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Ship, Camera, Trash2, Plus, X } from 'lucide-react'
import type { VesselFormInputs } from '../utils/vesselFormUtils.js'
export interface VesselDataFieldsProps {
inputs: VesselFormInputs
onChange: (next: VesselFormInputs) => void
readOnly?: boolean
saving?: boolean
newSailName: string
onNewSailNameChange: (value: string) => void
onAddSail: () => void
onRemoveSail: (index: number) => void
photoError?: string | null
onPhotoChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onRemovePhoto: () => void
fileInputRef: React.RefObject<HTMLInputElement | null>
}
export default function VesselDataFields({
inputs,
onChange,
readOnly = false,
saving = false,
newSailName,
onNewSailNameChange,
onAddSail,
onRemoveSail,
photoError,
onPhotoChange,
onRemovePhoto,
fileInputRef
}: VesselDataFieldsProps) {
const { t } = useTranslation()
const set = (patch: Partial<VesselFormInputs>) => onChange({ ...inputs, ...patch })
const triggerFileInput = () => {
if (!readOnly) fileInputRef.current?.click()
}
return (
<div className="form-grid">
<div className="vessel-photo-wrapper">
<div
className="vessel-photo-preview"
onClick={triggerFileInput}
style={{ cursor: readOnly ? 'default' : 'pointer' }}
>
{inputs.photo ? (
<img src={inputs.photo} alt={inputs.name || 'Vessel'} className="vessel-photo" />
) : (
<div className="vessel-photo-placeholder">
<Ship size={48} className="placeholder-icon" />
</div>
)}
{!readOnly && (
<div className="vessel-photo-overlay">
<Camera size={24} />
<span>{inputs.photo ? t('vessel.photo_change') : t('vessel.photo_add')}</span>
</div>
)}
</div>
{!readOnly && (
<div className="vessel-photo-actions">
<button type="button" className="btn secondary btn-sm" onClick={triggerFileInput} disabled={saving}>
<Camera size={16} />
{inputs.photo ? t('vessel.photo_change') : t('vessel.photo_add')}
</button>
{inputs.photo && (
<button type="button" className="btn danger btn-sm" onClick={onRemovePhoto} disabled={saving}>
<Trash2 size={16} />
{t('vessel.photo_delete')}
</button>
)}
</div>
)}
<input
type="file"
ref={fileInputRef}
onChange={onPhotoChange}
accept="image/*"
style={{ display: 'none' }}
/>
{photoError && <div className="auth-error mt-2">{photoError}</div>}
</div>
<div className="input-group">
<label>{t('vessel.name')}</label>
<input
type="text"
className="input-text"
value={inputs.name}
onChange={(e) => set({ name: e.target.value })}
disabled={saving || readOnly}
required
/>
</div>
<div className="input-group">
<label>{t('vessel.type')}</label>
<select
className="input-text"
value={inputs.vesselType}
onChange={(e) => set({ vesselType: e.target.value })}
disabled={saving || readOnly}
>
<option value="">{t('vessel.type_unset')}</option>
<option value="sailing">{t('vessel.type_sailing')}</option>
<option value="motor">{t('vessel.type_motor')}</option>
</select>
</div>
<div className="input-group">
<label>{t('vessel.length_m')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={inputs.lengthM}
onChange={(e) => set({ lengthM: e.target.value })}
disabled={saving || readOnly}
placeholder="0.00"
/>
</div>
<div className="input-group">
<label>{t('vessel.draft_m')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={inputs.draftM}
onChange={(e) => set({ draftM: e.target.value })}
disabled={saving || readOnly}
placeholder="0.00"
/>
</div>
<div className="input-group">
<label>{t('vessel.air_draft_m')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={inputs.airDraftM}
onChange={(e) => set({ airDraftM: e.target.value })}
disabled={saving || readOnly}
placeholder="0.00"
/>
</div>
<div className="input-group">
<label>{t('vessel.port')}</label>
<input
type="text"
className="input-text"
value={inputs.homePort}
onChange={(e) => set({ homePort: e.target.value })}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('vessel.owner')}</label>
<input
type="text"
className="input-text"
value={inputs.owner}
onChange={(e) => set({ owner: e.target.value })}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('vessel.charter')}</label>
<input
type="text"
className="input-text"
value={inputs.charterCompany}
onChange={(e) => set({ charterCompany: e.target.value })}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('vessel.registration')}</label>
<input
type="text"
className="input-text"
value={inputs.registrationNumber}
onChange={(e) => set({ registrationNumber: e.target.value })}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('vessel.callsign')}</label>
<input
type="text"
className="input-text"
value={inputs.callSign}
onChange={(e) => set({ callSign: e.target.value })}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('vessel.atis')}</label>
<input
type="text"
className="input-text"
value={inputs.atis}
onChange={(e) => set({ atis: e.target.value })}
disabled={saving || readOnly}
/>
</div>
<div className="input-group">
<label>{t('vessel.mmsi')}</label>
<input
type="text"
className="input-text"
value={inputs.mmsi}
onChange={(e) => set({ mmsi: e.target.value })}
disabled={saving || readOnly}
/>
</div>
<div className="vessel-tanks-section">
<h3>{t('vessel.tanks_section')}</h3>
<p className="vessel-tanks-help">{t('vessel.tanks_help')}</p>
<div className="vessel-tanks-grid">
<div className="input-group">
<label>{t('vessel.freshwater_capacity_l')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={inputs.freshwaterCapacityL}
onChange={(e) => set({ freshwaterCapacityL: e.target.value })}
disabled={saving || readOnly}
placeholder="0"
/>
</div>
<div className="input-group">
<label>{t('vessel.fuel_capacity_l')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={inputs.fuelCapacityL}
onChange={(e) => set({ fuelCapacityL: e.target.value })}
disabled={saving || readOnly}
placeholder="0"
/>
</div>
<div className="input-group">
<label>{t('vessel.greywater_capacity_l')}</label>
<input
type="text"
inputMode="decimal"
className="input-text"
value={inputs.greywaterCapacityL}
onChange={(e) => set({ greywaterCapacityL: e.target.value })}
disabled={saving || readOnly}
placeholder="0"
/>
</div>
</div>
</div>
<div className="sails-section">
<h3>{t('vessel.sails_list')}</h3>
<p className="help-text">{t('vessel.sails_help')}</p>
<div className="sails-badges-grid">
{inputs.sails.length === 0 ? (
<span className="no-sails-msg">{t('vessel.no_sails')}</span>
) : (
inputs.sails.map((sail, idx) => (
<span key={idx} className="sail-badge">
{sail}
{!readOnly && (
<button
type="button"
className="remove-btn"
onClick={() => onRemoveSail(idx)}
disabled={saving}
>
<X size={14} />
</button>
)}
</span>
))
)}
</div>
{!readOnly && (
<div className="add-sail-form">
<input
type="text"
className="input-text"
placeholder={t('vessel.sail_name_placeholder')}
value={newSailName}
onChange={(e) => onNewSailNameChange(e.target.value)}
disabled={saving}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
onAddSail()
}
}}
/>
<button
type="button"
className="btn secondary"
onClick={onAddSail}
disabled={saving || !newSailName.trim()}
style={{ width: 'auto' }}
>
<Plus size={16} />
{t('vessel.add_sail')}
</button>
</div>
)}
</div>
</div>
)
}
+244
View File
@@ -0,0 +1,244 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Ship, Plus, Trash2, Edit2, X, Save } from 'lucide-react'
import { useDialog } from './ModalDialog.tsx'
import VesselDataFields from './VesselDataFields.tsx'
import type { VesselFormInputs } from '../utils/vesselFormUtils.js'
import { parseVesselFormInputs, vesselDataToFormInputs } from '../utils/vesselFormUtils.js'
import { emptyVesselData } from '../types/vessel.js'
import { loadVesselPool, saveVessel, deleteVessel, type DecryptedVessel } from '../services/vesselPool.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
export default function VesselPoolForm() {
const { t } = useTranslation()
const { showConfirm } = useDialog()
const [vessels, setVessels] = useState<DecryptedVessel[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [inputs, setInputs] = useState<VesselFormInputs>(vesselDataToFormInputs(emptyVesselData()))
const [newSailName, setNewSailName] = useState('')
const [saving, setSaving] = useState(false)
const [photoError, setPhotoError] = useState<string | null>(null)
const fileRef = useRef<HTMLInputElement>(null)
const reload = useCallback(async () => {
setLoading(true)
setError(null)
try {
setVessels(await loadVesselPool())
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to load')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void reload()
}, [reload])
const openAdd = () => {
setEditingId(null)
setInputs(vesselDataToFormInputs(emptyVesselData()))
setNewSailName('')
setPhotoError(null)
setShowForm(true)
}
const openEdit = (vessel: DecryptedVessel) => {
setEditingId(vessel.payloadId)
setInputs(vesselDataToFormInputs(vessel.data))
setNewSailName('')
setPhotoError(null)
setShowForm(true)
}
const handleAddSail = () => {
const trimmed = newSailName.trim()
if (trimmed && !inputs.sails.includes(trimmed)) {
setInputs((prev) => ({ ...prev, sails: [...prev.sails, trimmed] }))
}
setNewSailName('')
}
const handlePhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setPhotoError(null)
const reader = new FileReader()
reader.onload = (event) => {
const img = new Image()
img.onload = () => {
try {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get canvas context')
let width = img.width
let height = img.height
const MAX_WIDTH = 800
const MAX_HEIGHT = 600
if (width > MAX_WIDTH || height > MAX_HEIGHT) {
const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height)
width = Math.round(width * ratio)
height = Math.round(height * ratio)
}
canvas.width = width
canvas.height = height
ctx.drawImage(img, 0, 0, width, height)
setInputs((prev) => ({ ...prev, photo: canvas.toDataURL('image/jpeg', 0.7) }))
} catch (err: unknown) {
setPhotoError(err instanceof Error ? err.message : 'Failed to process image')
}
}
img.onerror = () => setPhotoError('Invalid image file')
img.src = event.target?.result as string
}
reader.readAsDataURL(file)
}
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
if (!inputs.name.trim()) return
setSaving(true)
setError(null)
try {
const data = parseVesselFormInputs(inputs)
const id = editingId ?? window.crypto.randomUUID()
await saveVessel(id, data, !editingId)
setShowForm(false)
trackPlausibleEvent(PlausibleEvents.VESSEL_SAVED, { context: 'vessel_pool' })
await reload()
} catch (err: unknown) {
if (err instanceof Error && err.message === 'MAX_VESSELS') {
setError(t('vessel_pool.max_vessels'))
} else if (err instanceof Error && err.message === 'invalid_metric') {
setError(t('vessel.invalid_metric'))
} else if (err instanceof Error && err.message === 'invalid_tank_liters') {
setError(t('vessel.invalid_tank_liters'))
} else {
setError(err instanceof Error ? err.message : 'Failed to save')
}
} finally {
setSaving(false)
}
}
const handleDelete = async (id: string) => {
if (
!(await showConfirm(
t('vessel_pool.delete_confirm'),
t('vessel_pool.title'),
t('logs.confirm_yes'),
t('logs.confirm_no')
))
) {
return
}
try {
await deleteVessel(id)
await reload()
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to delete')
}
}
if (loading) {
return (
<div className="tab-placeholder">
<Ship className="header-logo spin" size={48} />
<p>{t('vessel_pool.loading')}</p>
</div>
)
}
return (
<div data-tour="profile-vessel-pool">
<div className="section-title-bar mb-4">
<h3>{t('vessel_pool.section_title')}</h3>
{!showForm && (
<button type="button" className="btn primary" style={{ width: 'auto', padding: '8px 16px' }} onClick={openAdd}>
<Plus size={16} />
{t('vessel_pool.add_vessel')}
</button>
)}
</div>
<p className="help-text mb-4">{t('vessel_pool.subtitle')}</p>
{error && <div className="auth-error mb-4">{error}</div>}
{vessels.length === 0 ? (
<p className="help-text mb-4">{t('vessel_pool.no_vessels')}</p>
) : (
<div className="crew-grid mb-6">
{vessels.map((v) => (
<div key={v.payloadId} className="crew-member-card glass">
<div className="crew-card-header">
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{v.data.photo ? (
<img src={v.data.photo} alt="" className="crew-card-avatar" />
) : (
<div className="crew-card-avatar-placeholder">
<Ship size={18} />
</div>
)}
<div>
<h4>{v.data.name}</h4>
{v.data.homePort && <p className="help-text">{v.data.homePort}</p>}
</div>
</div>
<div className="card-actions">
<button type="button" className="btn-icon" onClick={() => openEdit(v)}>
<Edit2 size={14} />
</button>
<button
type="button"
className="btn-icon logout"
onClick={() => void handleDelete(v.payloadId)}
>
<Trash2 size={14} />
</button>
</div>
</div>
</div>
))}
</div>
)}
{showForm && (
<form onSubmit={(e) => void handleSave(e)} className="member-editor-card glass">
<div className="editor-header mb-4">
<h3>{editingId ? t('vessel_pool.edit_vessel') : t('vessel_pool.add_vessel')}</h3>
<button type="button" className="btn-icon" onClick={() => setShowForm(false)}>
<X size={16} />
</button>
</div>
<VesselDataFields
inputs={inputs}
onChange={setInputs}
saving={saving}
newSailName={newSailName}
onNewSailNameChange={setNewSailName}
onAddSail={handleAddSail}
onRemoveSail={(idx) =>
setInputs((prev) => ({ ...prev, sails: prev.sails.filter((_, i) => i !== idx) }))
}
photoError={photoError}
onPhotoChange={handlePhotoChange}
onRemovePhoto={() => {
setInputs((prev) => ({ ...prev, photo: null }))
if (fileRef.current) fileRef.current.value = ''
}}
fileInputRef={fileRef}
/>
<div className="form-actions mt-4">
<button type="submit" className="btn primary" disabled={saving || !inputs.name.trim()}>
<Save size={18} />
{saving ? t('vessel.saving') : t('vessel.save')}
</button>
</div>
</form>
)}
</div>
)
}
+5 -1
View File
@@ -17,9 +17,13 @@ describe('AppTourContext step order', () => {
expect(profileIndex).toBeGreaterThan(FULL_STEP_ORDER.indexOf('nav_feedback'))
expect(prefsIndex).toBe(profileIndex + 1)
expect(finishIndex).toBe(prefsIndex + 1)
expect(FULL_STEP_ORDER).toContain('profile_vessel_pool')
expect(FULL_STEP_ORDER).toContain('profile_crew_pool')
expect(FULL_STEP_ORDER).toContain('nav_logbook_crew')
expect(FULL_STEP_ORDER).toHaveLength(13)
expect(FULL_STEP_ORDER.indexOf('profile_vessel_pool')).toBeLessThan(
FULL_STEP_ORDER.indexOf('profile_crew_pool')
)
expect(FULL_STEP_ORDER).toHaveLength(14)
})
it('excludes profile, stats and feedback from demo tour', () => {
+10 -2
View File
@@ -26,6 +26,7 @@ export type TourStepId =
| 'entry_open'
| 'entry_track'
| 'nav_vessel'
| 'profile_vessel_pool'
| 'profile_crew_pool'
| 'nav_logbook_crew'
| 'nav_stats'
@@ -72,6 +73,7 @@ export const FULL_STEP_ORDER: TourStepId[] = [
'entry_open',
'entry_track',
'nav_vessel',
'profile_vessel_pool',
'profile_crew_pool',
'nav_logbook_crew',
'nav_stats',
@@ -115,6 +117,7 @@ const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
entry_open: '[data-tour="entry-first"]',
entry_track: '[data-tour="entry-track"]',
nav_vessel: '[data-tour="nav-vessel"]',
profile_vessel_pool: '[data-tour="profile-vessel-pool"]',
profile_crew_pool: '[data-tour="profile-crew-pool"]',
nav_logbook_crew: '[data-tour="nav-logbook-crew"]',
nav_stats: '[data-tour="stats-dashboard"]',
@@ -131,7 +134,12 @@ export function tourStepOpensEntry(stepId: TourStepId): boolean {
export function getTourTargetDelay(stepId: TourStepId): number {
if (stepId === 'entry_track') return 400
if (stepId === 'nav_feedback') return 180
if (stepId === 'nav_profile' || stepId === 'profile_preferences' || stepId === 'profile_crew_pool') {
if (
stepId === 'nav_profile' ||
stepId === 'profile_preferences' ||
stepId === 'profile_vessel_pool' ||
stepId === 'profile_crew_pool'
) {
return 250
}
return 0
@@ -189,7 +197,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
nav.setSelectedEntryId(null)
nav.setActiveTab('vessel')
}
if (stepId === 'profile_crew_pool') {
if (stepId === 'profile_vessel_pool' || stepId === 'profile_crew_pool') {
nav.setSelectedEntryId(null)
nav.setLogbookActive(false)
nav.setProfileOpen(true)
+53 -8
View File
@@ -12,6 +12,7 @@ import { useDialog } from '../components/ModalDialog.tsx'
interface UnsavedChangesContextValue {
setDirty: (source: string, dirty: boolean) => void
registerSaveHandler: (source: string, handler: (() => Promise<void>) | null) => void
confirmLeave: () => Promise<boolean>
}
@@ -19,23 +20,51 @@ const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(n
export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation()
const { showConfirm } = useDialog()
const { showConfirmLeave, showAlert } = useDialog()
const dirtySources = useRef(new Set<string>())
const saveHandlers = useRef(new Map<string, () => Promise<void>>())
const setDirty = useCallback((source: string, dirty: boolean) => {
if (dirty) dirtySources.current.add(source)
else dirtySources.current.delete(source)
}, [])
const registerSaveHandler = useCallback((source: string, handler: (() => Promise<void>) | null) => {
if (handler) saveHandlers.current.set(source, handler)
else saveHandlers.current.delete(source)
}, [])
const confirmLeave = useCallback(async (): Promise<boolean> => {
if (dirtySources.current.size === 0) return true
return showConfirm(
const canSave = [...dirtySources.current].some((source) => saveHandlers.current.has(source))
const choice = await showConfirmLeave(
t('common.unsaved_changes_message'),
t('common.unsaved_changes_title'),
t('common.unsaved_changes_leave'),
t('common.unsaved_changes_stay')
t('common.unsaved_changes_stay'),
t('common.unsaved_changes_save_leave'),
t('common.unsaved_changes_discard'),
{ showSave: canSave }
)
}, [showConfirm, t])
if (choice === 'stay') return false
if (choice === 'discard') return true
const handlers = [...dirtySources.current]
.map((source) => saveHandlers.current.get(source))
.filter((handler): handler is () => Promise<void> => handler != null)
try {
for (const handler of handlers) {
await handler()
}
return true
} catch (err) {
console.error('Failed to save before leaving:', err)
await showAlert(t('errors.save_failed'))
return false
}
}, [showConfirmLeave, showAlert, t])
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
@@ -47,7 +76,10 @@ export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
return () => window.removeEventListener('beforeunload', handler)
}, [])
const value = useMemo(() => ({ setDirty, confirmLeave }), [setDirty, confirmLeave])
const value = useMemo(
() => ({ setDirty, registerSaveHandler, confirmLeave }),
[setDirty, registerSaveHandler, confirmLeave]
)
return (
<UnsavedChangesContext.Provider value={value}>
@@ -65,13 +97,26 @@ export function useUnsavedChangesContext(): UnsavedChangesContextValue {
}
/** Register a form/view as having unsaved changes (cleared automatically on unmount). */
export function useRegisterUnsavedChanges(source: string, isDirty: boolean) {
const { setDirty, confirmLeave } = useUnsavedChangesContext()
export function useRegisterUnsavedChanges(
source: string,
isDirty: boolean,
onSave?: () => Promise<void>
) {
const { setDirty, registerSaveHandler, confirmLeave } = useUnsavedChangesContext()
useEffect(() => {
setDirty(source, isDirty)
return () => setDirty(source, false)
}, [source, isDirty, setDirty])
useEffect(() => {
if (!onSave) {
registerSaveHandler(source, null)
return
}
registerSaveHandler(source, onSave)
return () => registerSaveHandler(source, null)
}, [source, onSave, registerSaveHandler])
return { confirmLeave }
}
+4 -2
View File
@@ -42,12 +42,14 @@ function scheduleUpdateChecks(
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') {
checkForUpdate()
// Delay check on wake-up to allow the mobile network stack to stabilize
setTimeout(checkForUpdate, 2000)
}
}
const onOnline = () => {
checkForUpdate()
// Small delay to ensure connection is fully established
setTimeout(checkForUpdate, 500)
}
document.addEventListener('visibilitychange', onVisibilityChange)
+52 -7
View File
@@ -27,8 +27,10 @@
"common": {
"unsaved_changes_title": "Ikke gemte ændringer",
"unsaved_changes_message": "Du har ændringer, der ikke er gemt. Vil du virkelig forlade siden? Dine ændringer vil gå tabt.",
"unsaved_changes_leave": "Forladelse",
"unsaved_changes_stay": "Bliv her"
"unsaved_changes_stay": "Bliv her",
"unsaved_changes_save_leave": "Gem og forlad",
"unsaved_changes_discard": "Kassér",
"unsaved_changes_leave": "Forladelse"
},
"nav": {
"dashboard": "Dashboard",
@@ -280,6 +282,7 @@
"live_pressure_btn": "Lufttryk",
"live_precip_btn": "Nedbør",
"live_sea_state_btn": "Søgang",
"live_visibility_btn": "Sigtbarhed",
"live_course_btn": "Kurs",
"live_fuel_btn": "Diesel",
"live_water_btn": "Vand",
@@ -288,6 +291,7 @@
"live_pressure_entry": "Lufttryk {{value}} hPa",
"live_precip_entry": "Nedbør {{value}}",
"live_sea_state_entry": "Søgang {{value}}",
"live_visibility_entry": "Sigtbarhed {{value}}",
"live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Vand +{{liters}} L",
@@ -298,6 +302,7 @@
"live_temp_placeholder": "f.eks. 18",
"live_precip_placeholder": "f.eks. let regn",
"live_sea_state_placeholder": "f.eks. 3",
"live_visibility_placeholder": "f.eks. 10 km",
"live_course_placeholder": "f.eks. 245",
"live_fuel_placeholder": "Optankede liter",
"live_water_placeholder": "Optankede liter",
@@ -339,6 +344,12 @@
"event_wind_direction": "Vindretning",
"event_wind_strength": "Vindstyrke",
"event_sea_state": "Havets tilstand",
"event_visibility": "Sigtbarhed",
"event_visibility_placeholder": "f.eks. 10 km",
"weather_slider_unset": "—",
"weather_slider_pressure": "{{value}} hPa",
"weather_slider_sea_state": "Trin {{value}}",
"weather_slider_heel": "{{value}}°",
"event_weather": "Vejret",
"event_log": "Log (sm)",
"event_gps": "GPS-position",
@@ -475,7 +486,7 @@
"edit_success": "Logbog omdøbt med succes",
"edit_btn": "Omdøb",
"filter_label": "Filtrer logbøger",
"filter_placeholder": "Navn, årstal eller dato ...",
"filter_placeholder": "Navn, årstal, dato, crew eller skib …",
"filter_clear": "Nulstil filter",
"filter_results": "{{count}} Hits",
"filter_no_results": "Ingen logbøger matcher din søgning. Prøv med et andet navn eller et andet år.",
@@ -606,7 +617,37 @@
"push_unsupported": "Push-meddelelser understøttes ikke i denne browser.",
"push_denied_hint": "Notifikationer er blokeret. Tillad dem i browserens eller enhedens indstillinger.",
"push_ios_install_hint": "På iPhone/iPad: Føj app til startskærmen (iOS 16.4+) for at bruge push.",
"push_error": "Push-meddelelser kunne ikke aktiveres."
"push_error": "Push-meddelelser kunne ikke aktiveres.",
"sections": {
"account": "Konto og indstillinger",
"fleet": "Flåde og besætning",
"security": "Sikkerhed og enhed",
"stats": "Statistik",
"danger": "Farezone"
}
},
"vessel_pool": {
"title": "Skibsflåde",
"section_title": "Dine skibe",
"subtitle": "Hold alle skibe til dine logbøger her. Vælg aktivt skib per logbog fra listen.",
"loading": "Indlæser skibsflåde…",
"add_vessel": "Tilføj skib",
"edit_vessel": "Rediger skib",
"no_vessels": "Ingen skibe i puljen endnu.",
"delete_confirm": "Fjerne dette skib fra flåden?",
"max_vessels": "Højst 20 skibe i puljen."
},
"logbook_vessel": {
"title": "Skib for denne logbog",
"subtitle": "Vælg skib for denne logbog. Rejsedage bruger sejl- og tankdata fra valgt skib.",
"active_vessel": "Skib for denne logbog",
"no_vessels_in_pool": "Intet skib i flåden tilføj i brugerprofilen først.",
"no_vessel": "Intet skib valgt",
"unnamed": "Uden navn",
"save": "Gem skib",
"saved": "Logbog-skib gemt.",
"selection_only_hint": "Du ser skibet ejeren har valgt (delt logbog).",
"manage_in_profile": "Administrer skibe i brugerprofilen"
},
"person_pool": {
"title": "Stamm-Crew og skippere",
@@ -883,7 +924,7 @@
},
"welcome_public": {
"title": "Velkommen om bord!",
"body": "Udforsk vores demo-logbog med tre dages rejse i Kielerfjorden - uden en konto. Denne korte tur viser dig skibsdata, crew og logbogsposter."
"body": "Udforsk vores demo-logbog med tre dages rejse i Kielerfjorden uden konto. Turen viser logbogsposter samt valg af skib og besætning for denne logbog. Flåde og stamm-besætning vedligeholder du senere i brugerprofilen."
},
"nav_logs": {
"title": "Indlæg i logbogen",
@@ -902,8 +943,12 @@
"body": "Upload GPX-filer, eller se allerede gemte ruter på kortet - inklusive afstand og hastighed."
},
"nav_vessel": {
"title": "Skibsdata",
"body": "Indtast navn, dimensioner og tekniske data for din yacht - udfyld én gang, tilgængelig for alle rejsedage."
"title": "Skib for logbog",
"body": "Vælg skib fra flåden for denne logbog. Administrer skibe i brugerprofilen under Flåde og besætning."
},
"profile_vessel_pool": {
"title": "Skibsflåde",
"body": "I brugerprofilen opretter du alle dine skibe charter, eget skib osv. Vælg derefter det rigtige skib per logbog."
},
"profile_crew_pool": {
"title": "Stamm-Crew og skippere",
+52 -7
View File
@@ -27,8 +27,10 @@
"common": {
"unsaved_changes_title": "Ungespeicherte Änderungen",
"unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.",
"unsaved_changes_leave": "Verlassen",
"unsaved_changes_stay": "Bleiben"
"unsaved_changes_stay": "Bleiben",
"unsaved_changes_save_leave": "Speichern & verlassen",
"unsaved_changes_discard": "Verwerfen",
"unsaved_changes_leave": "Verlassen"
},
"nav": {
"dashboard": "Dashboard",
@@ -280,6 +282,7 @@
"live_pressure_btn": "Luftdruck",
"live_precip_btn": "Niederschlag",
"live_sea_state_btn": "Seegang",
"live_visibility_btn": "Sichtweite",
"live_course_btn": "Kurs",
"live_fuel_btn": "Diesel",
"live_water_btn": "Wasser",
@@ -288,6 +291,7 @@
"live_pressure_entry": "Luftdruck {{value}} hPa",
"live_precip_entry": "Niederschlag {{value}}",
"live_sea_state_entry": "Seegang {{value}}",
"live_visibility_entry": "Sichtweite {{value}}",
"live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Wasser +{{liters}} L",
@@ -298,6 +302,7 @@
"live_temp_placeholder": "z. B. 18",
"live_precip_placeholder": "z. B. leichter Regen",
"live_sea_state_placeholder": "z. B. 3",
"live_visibility_placeholder": "z. B. 10 km",
"live_course_placeholder": "z. B. 245",
"live_fuel_placeholder": "Nachgefüllte Liter",
"live_water_placeholder": "Nachgefüllte Liter",
@@ -339,6 +344,12 @@
"event_wind_direction": "Wind-Richtung",
"event_wind_strength": "Windstärke",
"event_sea_state": "Seegang",
"event_visibility": "Sichtweite",
"event_visibility_placeholder": "z. B. 10 km",
"weather_slider_unset": "—",
"weather_slider_pressure": "{{value}} hPa",
"weather_slider_sea_state": "Stufe {{value}}",
"weather_slider_heel": "{{value}}°",
"event_weather": "Wetter",
"event_log": "Logge (sm)",
"event_gps": "GPS-Position",
@@ -475,7 +486,7 @@
"edit_success": "Logbuch erfolgreich umbenannt",
"edit_btn": "Umbenennen",
"filter_label": "Logbücher filtern",
"filter_placeholder": "Name, Jahr oder Datum …",
"filter_placeholder": "Name, Jahr, Datum, Crew oder Schiff …",
"filter_clear": "Filter zurücksetzen",
"filter_results": "{{count}} Treffer",
"filter_no_results": "Keine Logbücher passen zu deiner Suche. Probiere einen anderen Namen oder ein anderes Jahr.",
@@ -606,7 +617,37 @@
"push_unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.",
"push_denied_hint": "Benachrichtigungen sind blockiert. Erlaube sie in den Browser- oder Geräteeinstellungen.",
"push_ios_install_hint": "Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), um Push zu nutzen.",
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden."
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden.",
"sections": {
"account": "Konto & Einstellungen",
"fleet": "Flotte & Crew",
"security": "Sicherheit & Gerät",
"stats": "Statistik",
"danger": "Gefahrenzone"
}
},
"vessel_pool": {
"title": "Schiffsflotte",
"section_title": "Deine Schiffe",
"subtitle": "Pflege hier alle Schiffe für deine Logbücher. Pro Logbuch wählst du das aktive Schiff aus dieser Liste.",
"loading": "Schiffsflotte wird geladen…",
"add_vessel": "Schiff hinzufügen",
"edit_vessel": "Schiff bearbeiten",
"no_vessels": "Noch keine Schiffe im Pool.",
"delete_confirm": "Dieses Schiff wirklich aus der Flotte entfernen?",
"max_vessels": "Maximale Anzahl von 20 Schiffen im Pool erreicht."
},
"logbook_vessel": {
"title": "Schiff für dieses Logbuch",
"subtitle": "Wähle das Schiff für dieses Logbuch. Reisetage nutzen Segel- und Tankdaten des gewählten Schiffs.",
"active_vessel": "Schiff für dieses Logbuch",
"no_vessels_in_pool": "Kein Schiff in der Flotte zuerst im Benutzerprofil anlegen.",
"no_vessel": "Kein Schiff gewählt",
"unnamed": "Unbenannt",
"save": "Schiff speichern",
"saved": "Schiff für das Logbuch gespeichert.",
"selection_only_hint": "Du siehst das vom Eigner gewählte Schiff (geteiltes Logbuch).",
"manage_in_profile": "Schiffe im Benutzerprofil verwalten"
},
"person_pool": {
"title": "Stammcrew & Skipper",
@@ -883,7 +924,7 @@
},
"welcome_public": {
"title": "Willkommen an Bord!",
"body": "Erkunde unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde ganz ohne Account. Diese Tour zeigt dir Schiffsdaten, Crew-Auswahl und Logbucheinträge. Die Stammcrew pflegst du später im Benutzerprofil."
"body": "Erkunde unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde ganz ohne Account. Die Tour zeigt dir Logbucheinträge, die Schiff- und Crew-Auswahl für dieses Logbuch. Flotte und Stammcrew pflegst du später im Benutzerprofil."
},
"nav_logs": {
"title": "Logbucheinträge",
@@ -902,8 +943,12 @@
"body": "Lade GPX-Dateien hoch oder sieh bereits gespeicherte Routen auf der Karte inklusive Distanz und Geschwindigkeit."
},
"nav_vessel": {
"title": "Schiffsdaten",
"body": "Hinterlege Name, Maße und technische Daten deiner Yacht einmal ausfüllen, für alle Reisetage verfügbar."
"title": "Schiff fürs Logbuch",
"body": "Wähle aus deiner Schiffsflotte das Schiff für dieses Logbuch. Schiffe pflegst du im Benutzerprofil unter Flotte & Crew."
},
"profile_vessel_pool": {
"title": "Schiffsflotte",
"body": "Im Benutzerprofil legst du alle deine Schiffe an Charteryachten, eigenes Boot usw. Pro Logbuch wählst du dann das passende Schiff."
},
"profile_crew_pool": {
"title": "Stammcrew & Skipper",
+52 -7
View File
@@ -27,8 +27,10 @@
"common": {
"unsaved_changes_title": "Unsaved changes",
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
"unsaved_changes_leave": "Leave",
"unsaved_changes_stay": "Stay"
"unsaved_changes_stay": "Stay",
"unsaved_changes_save_leave": "Save & leave",
"unsaved_changes_discard": "Discard",
"unsaved_changes_leave": "Leave"
},
"nav": {
"dashboard": "Dashboard",
@@ -280,6 +282,7 @@
"live_pressure_btn": "Pressure",
"live_precip_btn": "Precipitation",
"live_sea_state_btn": "Sea state",
"live_visibility_btn": "Visibility",
"live_course_btn": "Course",
"live_fuel_btn": "Fuel",
"live_water_btn": "Water",
@@ -288,6 +291,7 @@
"live_pressure_entry": "Pressure {{value}} hPa",
"live_precip_entry": "Precipitation {{value}}",
"live_sea_state_entry": "Sea state {{value}}",
"live_visibility_entry": "Visibility {{value}}",
"live_course_entry": "Course {{course}}",
"live_fuel_entry": "Fuel +{{liters}} L",
"live_water_entry": "Water +{{liters}} L",
@@ -298,6 +302,7 @@
"live_temp_placeholder": "e.g. 18",
"live_precip_placeholder": "e.g. light rain",
"live_sea_state_placeholder": "e.g. 3",
"live_visibility_placeholder": "e.g. 10 km",
"live_course_placeholder": "e.g. 245",
"live_fuel_placeholder": "Liters refilled",
"live_water_placeholder": "Liters refilled",
@@ -339,6 +344,12 @@
"event_wind_direction": "Wind Dir",
"event_wind_strength": "Wind Str",
"event_sea_state": "Sea State",
"event_visibility": "Visibility",
"event_visibility_placeholder": "e.g. 10 km",
"weather_slider_unset": "—",
"weather_slider_pressure": "{{value}} hPa",
"weather_slider_sea_state": "State {{value}}",
"weather_slider_heel": "{{value}}°",
"event_weather": "Weather",
"event_log": "Log (nm)",
"event_gps": "GPS Position",
@@ -475,7 +486,7 @@
"edit_success": "Logbook renamed successfully",
"edit_btn": "Rename",
"filter_label": "Filter logbooks",
"filter_placeholder": "Name, year or date …",
"filter_placeholder": "Name, year, date, crew or vessel …",
"filter_clear": "Clear filter",
"filter_results": "{{count}} matches",
"filter_no_results": "No logbooks match your search. Try a different name or year.",
@@ -606,7 +617,37 @@
"push_unsupported": "Push notifications are not supported in this browser.",
"push_denied_hint": "Notifications are blocked. Allow them in your browser or device settings.",
"push_ios_install_hint": "On iPhone/iPad: add the app to your Home Screen (iOS 16.4+) to use push notifications.",
"push_error": "Could not enable push notifications."
"push_error": "Could not enable push notifications.",
"sections": {
"account": "Account & settings",
"fleet": "Fleet & crew",
"security": "Security & device",
"stats": "Statistics",
"danger": "Danger zone"
}
},
"vessel_pool": {
"title": "Vessel fleet",
"section_title": "Your vessels",
"subtitle": "Maintain all vessels for your logbooks here. Select the active vessel per logbook from this list.",
"loading": "Loading vessel fleet…",
"add_vessel": "Add vessel",
"edit_vessel": "Edit vessel",
"no_vessels": "No vessels in the pool yet.",
"delete_confirm": "Remove this vessel from the fleet?",
"max_vessels": "Maximum of 20 vessels in the pool reached."
},
"logbook_vessel": {
"title": "Vessel for this logbook",
"subtitle": "Choose the vessel for this logbook. Travel days use sails and tank data from the selected vessel.",
"active_vessel": "Vessel for this logbook",
"no_vessels_in_pool": "No vessel in the fleet — add one in your user profile first.",
"no_vessel": "No vessel selected",
"unnamed": "Unnamed",
"save": "Save vessel",
"saved": "Logbook vessel saved.",
"selection_only_hint": "You see the vessel chosen by the owner (shared logbook).",
"manage_in_profile": "Manage vessels in user profile"
},
"person_pool": {
"title": "Core Crew & skippers",
@@ -883,7 +924,7 @@
},
"welcome_public": {
"title": "Welcome aboard!",
"body": "Explore our demo logbook with three travel days in Kiel Bay — no account required. This short tour shows vessel data, crew, and log entries."
"body": "Explore our demo logbook with three travel days in Kiel Bay — no account required. The tour covers log entries and vessel and crew selection for this logbook. Manage your fleet and core crew later in your user profile."
},
"nav_logs": {
"title": "Log entries",
@@ -902,8 +943,12 @@
"body": "Upload GPX files or view saved routes on the map including distance and speed stats."
},
"nav_vessel": {
"title": "Vessel data",
"body": "Enter your yacht's name, dimensions, and technical details fill once, use on every travel day."
"title": "Vessel for logbook",
"body": "Choose a vessel from your fleet for this logbook. Manage vessels in your user profile under Fleet & crew."
},
"profile_vessel_pool": {
"title": "Vessel fleet",
"body": "In your user profile you add all your vessels — charter yachts, your own boat, etc. Then pick the right vessel per logbook."
},
"profile_crew_pool": {
"title": "Core Crew & skippers",
+52 -7
View File
@@ -27,8 +27,10 @@
"common": {
"unsaved_changes_title": "Ikke-lagrede endringer",
"unsaved_changes_message": "Du har endringer som ikke er lagret. Vil du virkelig forlate siden? Endringene dine vil gå tapt.",
"unsaved_changes_leave": "Oppgivelse",
"unsaved_changes_stay": "Bli"
"unsaved_changes_stay": "Bli",
"unsaved_changes_save_leave": "Lagre og forlat",
"unsaved_changes_discard": "Forkast",
"unsaved_changes_leave": "Oppgivelse"
},
"nav": {
"dashboard": "Dashbord",
@@ -280,6 +282,7 @@
"live_pressure_btn": "Lufttrykk",
"live_precip_btn": "Nedbør",
"live_sea_state_btn": "Sjøgang",
"live_visibility_btn": "Sikt",
"live_course_btn": "Kurs",
"live_fuel_btn": "Diesel",
"live_water_btn": "Vann",
@@ -288,6 +291,7 @@
"live_pressure_entry": "Lufttrykk {{value}} hPa",
"live_precip_entry": "Nedbør {{value}}",
"live_sea_state_entry": "Sjøgang {{value}}",
"live_visibility_entry": "Sikt {{value}}",
"live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Vann +{{liters}} L",
@@ -298,6 +302,7 @@
"live_temp_placeholder": "f.eks. 18",
"live_precip_placeholder": "f.eks. lett regn",
"live_sea_state_placeholder": "f.eks. 3",
"live_visibility_placeholder": "f.eks. 10 km",
"live_course_placeholder": "f.eks. 245",
"live_fuel_placeholder": "Påfylte liter",
"live_water_placeholder": "Påfylte liter",
@@ -339,6 +344,12 @@
"event_wind_direction": "Vindretning",
"event_wind_strength": "Vindstyrke",
"event_sea_state": "Havets tilstand",
"event_visibility": "Sikt",
"event_visibility_placeholder": "f.eks. 10 km",
"weather_slider_unset": "—",
"weather_slider_pressure": "{{value}} hPa",
"weather_slider_sea_state": "Grad {{value}}",
"weather_slider_heel": "{{value}}°",
"event_weather": "Været",
"event_log": "Logg (sm)",
"event_gps": "GPS-posisjon",
@@ -475,7 +486,7 @@
"edit_success": "Loggboken har fått nytt navn",
"edit_btn": "Gi nytt navn",
"filter_label": "Filtrer loggbøker",
"filter_placeholder": "Navn, årstall eller dato ...",
"filter_placeholder": "Navn, årstall, dato, crew eller skip …",
"filter_clear": "Tilbakestill filter",
"filter_results": "{{count}} Treff",
"filter_no_results": "Ingen loggbøker samsvarer med søket ditt. Prøv et annet navn eller et annet år.",
@@ -606,7 +617,37 @@
"push_unsupported": "Push-varsler støttes ikke i denne nettleseren.",
"push_denied_hint": "Varsler er blokkert. Tillat dem i innstillingene i nettleseren eller på enheten.",
"push_ios_install_hint": "På iPhone/iPad: Legg til app på startskjermen (iOS 16.4+) for å bruke push.",
"push_error": "Push-varsler kunne ikke aktiveres."
"push_error": "Push-varsler kunne ikke aktiveres.",
"sections": {
"account": "Konto og innstillinger",
"fleet": "Flåte og crew",
"security": "Sikkerhet og enhet",
"stats": "Statistikk",
"danger": "Faresone"
}
},
"vessel_pool": {
"title": "Skipsflåte",
"section_title": "Dine skip",
"subtitle": "Hold alle skip for loggbøkene dine her. Velg aktivt skip per loggbok fra listen.",
"loading": "Laster skipsflåte…",
"add_vessel": "Legg til skip",
"edit_vessel": "Rediger skip",
"no_vessels": "Ingen skip i poolen ennå.",
"delete_confirm": "Fjerne dette skipet fra flåten?",
"max_vessels": "Maksimalt 20 skip i poolen."
},
"logbook_vessel": {
"title": "Skip for denne loggboken",
"subtitle": "Velg skip for denne loggboken. Reisedager bruker seil- og tankdata fra valgt skip.",
"active_vessel": "Skip for denne loggboken",
"no_vessels_in_pool": "Ingen skip i flåten legg til i brukerprofilen først.",
"no_vessel": "Ingen skip valgt",
"unnamed": "Uten navn",
"save": "Lagre skip",
"saved": "Loggbok-skip lagret.",
"selection_only_hint": "Du ser skipet eieren har valgt (delt loggbok).",
"manage_in_profile": "Administrer skip i brukerprofilen"
},
"person_pool": {
"title": "Stamm-Crew og skippere",
@@ -883,7 +924,7 @@
},
"welcome_public": {
"title": "Velkommen om bord!",
"body": "Utforsk vår demologgbok med tre dagers reise i Kielfjorden - uten konto. Denne korte omvisningen viser deg skipsdata, crew og loggbokoppføringer."
"body": "Utforsk vår demologgbok med tre dagers reise i Kielfjorden uten konto. Omvisningen viser loggbokoppføringer og valg av skip og crew for denne loggboken. Flåte og stamm-crew legger du inn senere i brukerprofilen."
},
"nav_logs": {
"title": "Loggbokoppføringer",
@@ -902,8 +943,12 @@
"body": "Last opp GPX-filer eller se allerede lagrede ruter på kartet - inkludert avstand og hastighet."
},
"nav_vessel": {
"title": "Skipsdata",
"body": "Skriv inn navn, dimensjoner og tekniske data for båten din - fyll inn én gang, tilgjengelig for alle reisedager."
"title": "Skip for loggbok",
"body": "Velg skip fra flåten for denne loggboken. Administrer skip i brukerprofilen under Flåte og crew."
},
"profile_vessel_pool": {
"title": "Skipsflåte",
"body": "I brukerprofilen legger du inn alle skip charter, eget båt osv. Velg deretter riktig skip per loggbok."
},
"profile_crew_pool": {
"title": "Stamm-Crew og skippere",
+52 -7
View File
@@ -27,8 +27,10 @@
"common": {
"unsaved_changes_title": "Osparade ändringar",
"unsaved_changes_message": "Du har ändringar som inte sparats. Vill du verkligen lämna sidan? Dina ändringar kommer att gå förlorade.",
"unsaved_changes_leave": "Övergivande",
"unsaved_changes_stay": "Stanna kvar"
"unsaved_changes_stay": "Stanna kvar",
"unsaved_changes_save_leave": "Spara och lämna",
"unsaved_changes_discard": "Kasta",
"unsaved_changes_leave": "Övergivande"
},
"nav": {
"dashboard": "Instrumentpanel",
@@ -280,6 +282,7 @@
"live_pressure_btn": "Lufttryck",
"live_precip_btn": "Nederbörd",
"live_sea_state_btn": "Sjögang",
"live_visibility_btn": "Sikt",
"live_course_btn": "Kurs",
"live_fuel_btn": "Diesel",
"live_water_btn": "Vatten",
@@ -288,6 +291,7 @@
"live_pressure_entry": "Lufttryck {{value}} hPa",
"live_precip_entry": "Nederbörd {{value}}",
"live_sea_state_entry": "Sjögang {{value}}",
"live_visibility_entry": "Sikt {{value}}",
"live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Vatten +{{liters}} L",
@@ -298,6 +302,7 @@
"live_temp_placeholder": "t.ex. 18",
"live_precip_placeholder": "t.ex. lätt regn",
"live_sea_state_placeholder": "t.ex. 3",
"live_visibility_placeholder": "t.ex. 10 km",
"live_course_placeholder": "t.ex. 245",
"live_fuel_placeholder": "Påfyllda liter",
"live_water_placeholder": "Påfyllda liter",
@@ -339,6 +344,12 @@
"event_wind_direction": "Vindriktning",
"event_wind_strength": "Vindstyrka",
"event_sea_state": "Havets tillstånd",
"event_visibility": "Sikt",
"event_visibility_placeholder": "t.ex. 10 km",
"weather_slider_unset": "—",
"weather_slider_pressure": "{{value}} hPa",
"weather_slider_sea_state": "Grad {{value}}",
"weather_slider_heel": "{{value}}°",
"event_weather": "Väder",
"event_log": "Log (sm)",
"event_gps": "GPS-position",
@@ -475,7 +486,7 @@
"edit_success": "Loggboken har framgångsrikt bytt namn",
"edit_btn": "Byt namn på",
"filter_label": "Filtrera loggböcker",
"filter_placeholder": "Namn, årtal eller datum ...",
"filter_placeholder": "Namn, årtal, datum, crew eller fartyg …",
"filter_clear": "Återställ filter",
"filter_results": "{{count}} Träffar",
"filter_no_results": "Inga loggböcker matchar din sökning. Försök med ett annat namn eller ett annat år.",
@@ -606,7 +617,37 @@
"push_unsupported": "Push-meddelanden stöds inte i den här webbläsaren.",
"push_denied_hint": "Meddelanden är blockerade. Tillåt dem i webbläsarens eller enhetens inställningar.",
"push_ios_install_hint": "På iPhone/iPad: Lägg till app på startskärmen (iOS 16.4+) för att använda push.",
"push_error": "Push-meddelanden kunde inte aktiveras."
"push_error": "Push-meddelanden kunde inte aktiveras.",
"sections": {
"account": "Konto och inställningar",
"fleet": "Flotta och besättning",
"security": "Säkerhet och enhet",
"stats": "Statistik",
"danger": "Riskzon"
}
},
"vessel_pool": {
"title": "Skipsflotta",
"section_title": "Dina fartyg",
"subtitle": "Underhåll alla fartyg för dina loggböcker här. Välj aktivt fartyg per loggbok från listan.",
"loading": "Laddar fartygsflotta…",
"add_vessel": "Lägg till fartyg",
"edit_vessel": "Redigera fartyg",
"no_vessels": "Inga fartyg i poolen ännu.",
"delete_confirm": "Ta bort detta fartyg från flottan?",
"max_vessels": "Högst 20 fartyg i poolen."
},
"logbook_vessel": {
"title": "Fartyg för denna loggbok",
"subtitle": "Välj fartyg för denna loggbok. Resdagar använder segel- och tankdata från valt fartyg.",
"active_vessel": "Fartyg för denna loggbok",
"no_vessels_in_pool": "Inget fartyg i flottan lägg till i användarprofilen först.",
"no_vessel": "Inget fartyg valt",
"unnamed": "Namnlös",
"save": "Spara fartyg",
"saved": "Loggbok-fartyg sparat.",
"selection_only_hint": "Du ser fartyget ägaren valt (delad loggbok).",
"manage_in_profile": "Hantera fartyg i användarprofilen"
},
"person_pool": {
"title": "Stamm-Crew och skeppare",
@@ -883,7 +924,7 @@
},
"welcome_public": {
"title": "Välkommen ombord!",
"body": "Utforska vår demologgbok med tre dagars resor i Kielfjorden - utan konto. Den här korta rundturen visar dig fartygsdata, crew och loggboksanteckningar."
"body": "Utforska vår demologgbok med tre dagars resor i Kielfjorden utan konto. Rundturen visar loggboksanteckningar samt val av fartyg och besättning för denna loggbok. Flotta och stamm-besättning hanterar du senare i användarprofilen."
},
"nav_logs": {
"title": "Loggboksanteckningar",
@@ -902,8 +943,12 @@
"body": "Ladda upp GPX-filer eller visa redan sparade rutter på kartan - inklusive avstånd och hastighet."
},
"nav_vessel": {
"title": "Fartygsdata",
"body": "Ange namn, dimensioner och tekniska data för din yacht - fyll i en gång, tillgänglig för alla resdagar."
"title": "Fartyg för loggbok",
"body": "Välj fartyg från flottan för denna loggbok. Hantera fartyg i användarprofilen under Flotta och besättning."
},
"profile_vessel_pool": {
"title": "Fartygsflotta",
"body": "I användarprofilen lägger du in alla fartyg charter, egen båt m.m. Välj sedan rätt fartyg per loggbok."
},
"profile_crew_pool": {
"title": "Stamm-Crew och skeppare",
+16
View File
@@ -7,6 +7,7 @@ import './App.css'
import './i18n'
import App from './App.tsx'
import { applyAppearanceToDocument } from './services/appearance.ts'
import { flushPendingPwaBootEvents } from './services/analytics.ts'
import {
installStaleAssetRecovery,
markReloadAttempt,
@@ -14,6 +15,15 @@ import {
} from './services/pwaStartup.ts'
import { redirectToPasskeyCompatibleHostIfNeeded } from './utils/passkeyHost.ts'
declare global {
interface Window {
__KDB_MAIN_MODULE_LOADED?: boolean
__KDB_APP_BOOTSTRAPPED?: boolean
}
}
window.__KDB_MAIN_MODULE_LOADED = true
/** Stale PWA precache on localhost can shadow Vite dev modules. */
async function clearDevServiceWorkerCaches(): Promise<void> {
if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return
@@ -47,6 +57,10 @@ async function bootstrap(): Promise<void> {
applyAppearanceToDocument()
installStaleAssetRecovery()
flushPendingPwaBootEvents()
window.addEventListener('load', () => {
flushPendingPwaBootEvents()
}, { once: true })
await clearDevServiceWorkerCaches()
const startupResult = await reconcileVersionOnStartup()
@@ -69,6 +83,7 @@ async function bootstrap(): Promise<void> {
<App />
</StrictMode>,
)
window.__KDB_APP_BOOTSTRAPPED = true
}
void bootstrap().catch((err) => {
@@ -76,4 +91,5 @@ void bootstrap().catch((err) => {
renderBootstrapError(
'Die App konnte nicht gestartet werden. Bitte neu laden oder die App vollständig beenden und erneut öffnen.',
)
window.__KDB_APP_BOOTSTRAPPED = false
})
+61 -1
View File
@@ -41,7 +41,11 @@ export const PlausibleEvents = {
LIVE_LOG_OPENED: 'Live Log Opened',
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
LIVE_LOG_PHOTO_UPLOADED: 'Live Log Photo Uploaded',
OWM_WEATHER_FETCHED: 'OWM Weather Fetched'
OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
PWA_BOOT_WATCHDOG_HARD: 'PWA Boot Watchdog Hard',
PWA_BOOT_WATCHDOG_FALLBACK: 'PWA Boot Watchdog Fallback',
PWA_BOOT_WATCHDOG_MANUAL_REPAIR: 'PWA Boot Watchdog Manual Repair'
} as const
/** Where a successful OpenWeatherMap API call originated (no coordinates or place names). */
@@ -50,6 +54,13 @@ export type OwmAnalyticsSource = 'live_log' | 'entry_editor' | 'entry_editor_gps
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
export type PlausibleEventProps = Record<string, string | number | boolean>
type PendingPwaBootEvent = {
name: PlausibleEventName
props?: PlausibleEventProps
ts?: number
}
const PWA_BOOT_PENDING_EVENTS_KEY = 'pwa_boot_pending_events'
export function trackPlausibleEvent(name: PlausibleEventName, props?: PlausibleEventProps): void {
if (typeof window.plausible !== 'function') return
@@ -59,3 +70,52 @@ export function trackPlausibleEvent(name: PlausibleEventName, props?: PlausibleE
}
window.plausible(name)
}
export function flushPendingPwaBootEvents(): number {
if (typeof window.plausible !== 'function') return 0
let raw: string | null = null
try {
raw = sessionStorage.getItem(PWA_BOOT_PENDING_EVENTS_KEY)
} catch {
return 0
}
if (!raw) return 0
let pending: PendingPwaBootEvent[]
try {
pending = JSON.parse(raw) as PendingPwaBootEvent[]
} catch {
try {
sessionStorage.removeItem(PWA_BOOT_PENDING_EVENTS_KEY)
} catch {
/* ignore storage errors */
}
return 0
}
if (!Array.isArray(pending) || pending.length === 0) {
try {
sessionStorage.removeItem(PWA_BOOT_PENDING_EVENTS_KEY)
} catch {
/* ignore storage errors */
}
return 0
}
for (const event of pending) {
if (!event || typeof event.name !== 'string') continue
if (event.props && Object.keys(event.props).length > 0) {
window.plausible(event.name, { props: event.props })
} else {
window.plausible(event.name)
}
}
try {
sessionStorage.removeItem(PWA_BOOT_PENDING_EVENTS_KEY)
} catch {
/* ignore storage errors */
}
return pending.length
}
+29 -8
View File
@@ -10,22 +10,43 @@ export class ApiError extends Error {
export async function apiFetch(
input: string,
init: RequestInit = {}
init: RequestInit = {},
timeoutMs = 15000
): Promise<Response> {
const headers = new Headers(init.headers)
if (init.body !== undefined && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
return fetch(input, {
...init,
headers,
credentials: 'include'
})
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
if (init.signal) {
if (init.signal.aborted) {
controller.abort()
} else {
init.signal.addEventListener('abort', () => controller.abort())
}
}
try {
return await fetch(input, {
...init,
headers,
credentials: 'include',
signal: controller.signal
})
} finally {
clearTimeout(timeoutId)
}
}
export async function apiJson<T>(input: string, init: RequestInit = {}): Promise<T> {
const res = await apiFetch(input, init)
export async function apiJson<T>(
input: string,
init: RequestInit = {},
timeoutMs = 15000
): Promise<T> {
const res = await apiFetch(input, init, timeoutMs)
const data = await res.json().catch(() => ({}))
if (!res.ok) {
const message =
+6 -1
View File
@@ -558,7 +558,12 @@ export async function deleteAccount(): Promise<boolean> {
db.photos.clear(),
db.gpsTracks.clear(),
db.syncQueue.clear(),
db.logbookKeys.clear()
db.logbookKeys.clear(),
db.personPool.clear(),
db.vesselPool.clear(),
db.logbookCrewSelections.clear(),
db.logbookVesselSelections.clear(),
db.userSyncQueue.clear()
])
// Wipe localStorage and session variables
+14 -18
View File
@@ -37,22 +37,17 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
throw new Error('Encryption key not found. User must log in.')
}
// 1. Fetch Yacht details
const yachtRecord = await db.yachts.get(logbookId);
if (yachtRecord) {
try {
const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey);
yachtName = yacht.name || '';
homePort = yacht.port || '';
owner = yacht.owner || '';
charter = yacht.charter || '';
registration = yacht.registration || '';
callsign = yacht.callsign || '';
atis = yacht.atis || '';
mmsi = yacht.mmsi || '';
} catch (e) {
console.error('Failed to decrypt yacht details for CSV:', e);
}
const { resolveVesselForLogbook } = await import('./resolveVessel.js')
const yacht = await resolveVesselForLogbook(logbookId)
if (yacht) {
yachtName = yacht.name || ''
homePort = yacht.homePort || ''
owner = yacht.owner || ''
charter = yacht.charterCompany || ''
registration = yacht.registrationNumber || ''
callsign = yacht.callSign || ''
atis = yacht.atis || ''
mmsi = yacht.mmsi || ''
}
// 2. Fetch logbook entries
@@ -83,7 +78,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
'Skipper Signature', 'Crew Signature',
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
'Event Time', 'MgK Course', 'RwK Course',
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State',
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Visibility',
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
'Latitude', 'Longitude', 'Remarks',
'Freshwater Morning (L)', 'Freshwater Refilled (L)', 'Freshwater Evening (L)', 'Freshwater Consumption (L)',
@@ -134,7 +129,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
signS, signC,
trackDist, trackMax, trackAvg, motorH,
'', '', '',
'', '', '', '',
'', '', '', '', '',
'', '', '', '', '',
'', '', '',
fwM, fwR, fwE, fwCons,
@@ -152,6 +147,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
trackDist, trackMax, trackAvg, motorH,
ev.time || '', ev.mgk || '', ev.rwk || '',
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
ev.visibility || '',
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
ev.gpsLat || '', ev.gpsLng || '', ev.remarks || '',
fwM, fwR, fwE, fwCons,
+57 -2
View File
@@ -35,6 +35,14 @@ export interface LocalDeviation {
updatedAt: string
}
export interface EntryListCache {
date: string
dayOfTravel: string
departure: string
destination: string
skipperSignStatus: 'none' | 'valid' | 'invalid'
}
export interface LocalEntry {
payloadId: string
logbookId: string
@@ -42,6 +50,8 @@ export interface LocalEntry {
iv: string
tag: string
updatedAt: string
/** Plaintext list fields — avoids full decrypt when opening the journal list. */
listCache?: EntryListCache
}
export interface LocalPhoto {
@@ -88,6 +98,14 @@ export interface LocalPerson {
updatedAt: string
}
export interface LocalVessel {
payloadId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface LocalLogbookCrewSelection {
logbookId: string
encryptedData: string
@@ -96,10 +114,27 @@ export interface LocalLogbookCrewSelection {
updatedAt: string
}
export interface LocalLogbookVesselSelection {
logbookId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface SyncQueueItem {
id?: number
action: 'create' | 'update' | 'delete'
type: 'yacht' | 'crew' | 'deviation' | 'entry' | 'logbook' | 'photo' | 'gpsTrack' | 'logbookCrew'
type:
| 'yacht'
| 'crew'
| 'deviation'
| 'entry'
| 'logbook'
| 'photo'
| 'gpsTrack'
| 'logbookCrew'
| 'logbookVessel'
payloadId: string // payloadId or logbookId depending on the type
logbookId: string
data: string // JSON representation of the local record
@@ -109,7 +144,7 @@ export interface SyncQueueItem {
export interface UserSyncQueueItem {
id?: number
action: 'create' | 'update' | 'delete'
type: 'person'
type: 'person' | 'vessel'
payloadId: string
data: string
updatedAt: string
@@ -135,7 +170,9 @@ class DaagboxDatabase extends Dexie {
nmeaArchives!: Table<LocalNmeaArchive>
logbookKeys!: Table<LocalLogbookKey>
personPool!: Table<LocalPerson>
vesselPool!: Table<LocalVessel>
logbookCrewSelections!: Table<LocalLogbookCrewSelection>
logbookVesselSelections!: Table<LocalLogbookVesselSelection>
syncQueue!: Table<SyncQueueItem>
userSyncQueue!: Table<UserSyncQueueItem>
entryDrafts!: Table<EntryDraftRecord, [string, string]>
@@ -234,6 +271,24 @@ class DaagboxDatabase extends Dexie {
userSyncQueue: '++id, action, type, payloadId',
entryDrafts: '[logbookId+entryId], updatedAt'
})
this.version(9).stores({
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
yachts: 'logbookId, updatedAt',
crews: 'payloadId, logbookId, updatedAt',
deviations: 'logbookId, updatedAt',
entries: 'payloadId, logbookId, updatedAt',
syncQueue: '++id, action, type, payloadId, logbookId',
photos: 'payloadId, entryId, logbookId, updatedAt',
gpsTracks: 'entryId, logbookId, updatedAt',
nmeaArchives: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId',
personPool: 'payloadId, updatedAt',
vesselPool: 'payloadId, updatedAt',
logbookCrewSelections: 'logbookId, updatedAt',
logbookVesselSelections: 'logbookId, updatedAt',
userSyncQueue: '++id, action, type, payloadId',
entryDrafts: '[logbookId+entryId], updatedAt'
})
}
}
+12 -8
View File
@@ -4,6 +4,7 @@ import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import { putEntryRecord } from '../utils/entryListCache.js'
import { syncPersonPool } from './personPoolSync.js'
import i18n from '../i18n/index.js'
import type { PersonData } from '../types/person.js'
@@ -35,14 +36,17 @@ async function putEncryptedRecord(
const encrypted = await encryptJson(data, key)
if (type === 'entry') {
await db.entries.put({
payloadId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await putEntryRecord(
{
payloadId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
},
data as Record<string, unknown>
)
} else if (type === 'yacht') {
await db.yachts.put({
logbookId,
+33
View File
@@ -17,6 +17,7 @@ const PUBLIC_DEMO_ENTRY_IDS = [
] as const
export const PUBLIC_DEMO_SKIPPER_ID = 'skipper'
export const PUBLIC_DEMO_VESSEL_ID = 'demo-vessel-1'
const PUBLIC_DEMO_CREW_MEMBER_ID = 'a0000001-0000-4000-8000-000000000010'
export interface DemoDaySpec {
@@ -50,9 +51,19 @@ export interface DemoCrewRecord {
}
}
export interface DemoVesselRecord {
payloadId: string
data: Record<string, unknown> & { name: string }
}
export interface PublicDemoFixture {
title: string
yacht: Record<string, unknown>
vesselPool: DemoVesselRecord[]
logbookVesselSelection: {
activeVesselId: string | null
vesselSnapshot: Record<string, unknown> | null
}
/** @deprecated legacy share payload */
crews: DemoCrewRecord[]
personPool: DemoCrewRecord[]
@@ -238,6 +249,24 @@ export function buildDemoCrewRecords(): DemoCrewRecord[] {
]
}
function buildDemoVesselPool(yacht: Record<string, unknown>): DemoVesselRecord[] {
return [
{
payloadId: PUBLIC_DEMO_VESSEL_ID,
data: { name: String(yacht.name ?? 'Demo'), ...yacht }
}
]
}
function buildDemoLogbookVesselSelection(
yacht: Record<string, unknown>
): PublicDemoFixture['logbookVesselSelection'] {
return {
activeVesselId: PUBLIC_DEMO_VESSEL_ID,
vesselSnapshot: { id: PUBLIC_DEMO_VESSEL_ID, ...yacht }
}
}
function buildDemoLogbookCrewSelection(pool: DemoCrewRecord[]) {
const skipper = pool.find((p) => p.data.role === 'skipper')
const crew = pool.filter((p) => p.data.role === 'crew')
@@ -255,6 +284,8 @@ function buildDemoLogbookCrewSelection(pool: DemoCrewRecord[]) {
export function buildPublicDemoFixture(): PublicDemoFixture {
const title = i18n.t('demo.logbook_title')
const yacht = buildDemoYachtData()
const vesselPool = buildDemoVesselPool(yacht)
const logbookVesselSelection = buildDemoLogbookVesselSelection(yacht)
const personPool = buildDemoPersonPool()
const crews = personPool
const logbookCrewSelection = buildDemoLogbookCrewSelection(personPool)
@@ -310,6 +341,8 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
return {
title,
yacht,
vesselPool,
logbookVesselSelection,
crews,
personPool,
logbookCrewSelection,
+81
View File
@@ -0,0 +1,81 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { decryptJson } from './crypto.js'
import { getLogbookKey } from './logbookKeys.js'
import type { PersonData } from '../types/person.js'
import { loadLogbookCrewSelection } from './logbookCrewSelection.js'
import { loadPersonPoolMap } from './personPool.js'
import { resolveVesselForLogbook } from './resolveVessel.js'
import type { LogbookSearchFields } from '../utils/logbookFilter.js'
async function loadLegacyCrewNames(logbookId: string): Promise<string[]> {
const records = await db.crews.where({ logbookId }).toArray()
if (records.length === 0) return []
const key = (await getLogbookKey(logbookId)) || getActiveMasterKey()
if (!key) return []
const names: string[] = []
for (const record of records) {
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, key)) as PersonData | null
const name = data?.name?.trim()
if (name) names.push(name)
}
return names
}
function collectCrewNamesFromSelection(
selection: Awaited<ReturnType<typeof loadLogbookCrewSelection>>,
pool: Map<string, PersonData>
): string[] {
const names = new Set<string>()
for (const snapshot of Object.values(selection.snapshotsById)) {
const name = snapshot.name?.trim()
if (name) names.add(name)
}
const ids = [
...(selection.activeSkipperId ? [selection.activeSkipperId] : []),
...selection.activeCrewIds
]
for (const id of ids) {
const fromSnapshot = selection.snapshotsById[id]?.name?.trim()
if (fromSnapshot) {
names.add(fromSnapshot)
continue
}
const fromPool = pool.get(id)?.name?.trim()
if (fromPool) names.add(fromPool)
}
return [...names]
}
export async function loadLogbookSearchFields(logbookId: string): Promise<LogbookSearchFields> {
const [vessel, crewSelection, pool] = await Promise.all([
resolveVesselForLogbook(logbookId),
loadLogbookCrewSelection(logbookId),
loadPersonPoolMap()
])
let crewNames = collectCrewNamesFromSelection(crewSelection, pool)
if (crewNames.length === 0) {
crewNames = await loadLegacyCrewNames(logbookId)
}
return {
vesselName: vessel?.name?.trim() ?? '',
crewNames
}
}
export async function loadLogbookSearchFieldsBatch(
logbookIds: string[]
): Promise<Map<string, LogbookSearchFields>> {
const uniqueIds = [...new Set(logbookIds)]
const entries = await Promise.all(
uniqueIds.map(async (id) => [id, await loadLogbookSearchFields(id)] as const)
)
return new Map(entries)
}
@@ -0,0 +1,73 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson, encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import type { LogbookVesselSelectionData } from '../types/vessel.js'
import { emptyLogbookVesselSelection } from '../types/vessel.js'
import { buildLogbookVesselSelection } from '../utils/vesselSnapshot.js'
import type { VesselData } from '../types/vessel.js'
import { loadVesselPoolMap } from './vesselPool.js'
async function resolveLogbookKey(logbookId: string): Promise<ArrayBuffer> {
const key = (await getLogbookKey(logbookId)) || getActiveMasterKey()
if (!key) throw new Error('Encryption key not found. Please log in.')
return key
}
export async function loadLogbookVesselSelection(
logbookId: string
): Promise<LogbookVesselSelectionData> {
const record = await db.logbookVesselSelections.get(logbookId)
if (!record) return emptyLogbookVesselSelection()
const key = await resolveLogbookKey(logbookId)
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, key)) as
| LogbookVesselSelectionData
| null
if (!data) return emptyLogbookVesselSelection()
return {
activeVesselId: data.activeVesselId ?? null,
vesselSnapshot: data.vesselSnapshot ?? null
}
}
export async function saveLogbookVesselSelection(
logbookId: string,
selection: LogbookVesselSelectionData
): Promise<void> {
const key = await resolveLogbookKey(logbookId)
const encrypted = await encryptJson(selection, key)
const now = new Date().toISOString()
await db.logbookVesselSelections.put({
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'update',
type: 'logbookVessel',
payloadId: logbookId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
export async function saveLogbookVesselSelectionFromId(
logbookId: string,
activeVesselId: string | null,
poolOverride?: Map<string, VesselData>
): Promise<LogbookVesselSelectionData> {
const pool = poolOverride ?? (await loadVesselPoolMap())
const selection = buildLogbookVesselSelection(activeVesselId, pool)
await saveLogbookVesselSelection(logbookId, selection)
return selection
}
+9 -14
View File
@@ -31,20 +31,15 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
throw new Error('Encryption key not found. Please log in.')
}
// 1. Fetch Yacht details
const yachtRecord = await db.yachts.get(logbookId);
if (yachtRecord) {
try {
const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey);
yachtName = yacht.name || '';
homePort = yacht.port || '';
registration = yacht.registrationNumber || yacht.registration || '';
callsign = yacht.callSign || '';
atis = yacht.atis || '';
mmsi = yacht.mmsi || '';
} catch (e) {
console.error('Failed to decrypt yacht details for PDF:', e);
}
const { resolveVesselForLogbook } = await import('./resolveVessel.js')
const yacht = await resolveVesselForLogbook(logbookId)
if (yacht) {
yachtName = yacht.name || ''
homePort = yacht.homePort || ''
registration = yacht.registrationNumber || ''
callsign = yacht.callSign || ''
atis = yacht.atis || ''
mmsi = yacht.mmsi || ''
}
// 2. Fetch active Entry
+1 -1
View File
@@ -16,7 +16,7 @@ export async function syncPersonPool(): Promise<void> {
}
async function pushPersonPool(): Promise<void> {
const pending = await db.userSyncQueue.toArray()
const pending = (await db.userSyncQueue.toArray()).filter((item) => item.type === 'person')
if (pending.length === 0) return
try {
+132 -20
View File
@@ -27,17 +27,62 @@ export function getNotificationPermission(): NotificationPermission | 'unsupport
return Notification.permission
}
let cachedVapidKey: string | null = null
let cachedRegistration: ServiceWorkerRegistration | null = null
async function getRegistrationCompat(timeoutMs = 8000): Promise<ServiceWorkerRegistration> {
if (!('serviceWorker' in navigator)) {
throw new Error('Service Worker is not supported by your browser')
}
try {
const reg = await navigator.serviceWorker.getRegistration()
if (reg) return reg
} catch (e) {
console.warn('Failed to get service worker registration directly:', e)
}
// Fallback to waiting for ready state with a timeout
const readyPromise = navigator.serviceWorker.ready
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout waiting for Service Worker ready state')), timeoutMs)
)
return Promise.race([readyPromise, timeoutPromise])
}
export async function preloadPushService(): Promise<void> {
if (!isPushSupported()) return
try {
if (!cachedVapidKey) {
await fetchVapidPublicKey()
}
if (!cachedRegistration) {
cachedRegistration = await getRegistrationCompat()
}
} catch (err) {
console.warn('Failed to preload push service:', err)
}
}
async function fetchVapidPublicKey(): Promise<string | null> {
if (cachedVapidKey) return cachedVapidKey
const envKey = import.meta.env.VITE_VAPID_PUBLIC_KEY
if (typeof envKey === 'string' && envKey.trim()) {
return envKey.trim()
cachedVapidKey = envKey.trim()
return cachedVapidKey
}
try {
const res = await fetch(`${API_BASE}/vapid-public-key`)
if (!res.ok) return null
const data = await res.json()
return typeof data.publicKey === 'string' ? data.publicKey : null
if (typeof data.publicKey === 'string') {
cachedVapidKey = data.publicKey.trim()
return cachedVapidKey
}
return null
} catch {
return null
}
@@ -72,11 +117,61 @@ export async function savePushPrefs(collaboratorChangesEnabled: boolean): Promis
})
}
async function requestNotificationPermission(): Promise<NotificationPermission> {
if (typeof Notification === 'undefined') return 'denied'
// Try promise-based signature first
try {
const result = Notification.requestPermission()
if (result !== undefined) {
return await result
}
} catch {
// Ignore and fall back to callback
}
// Callback-based fallback
return new Promise<NotificationPermission>((resolve) => {
Notification.requestPermission((permission) => {
resolve(permission)
})
})
}
async function saveSubscriptionToServer(subscription: PushSubscription): Promise<void> {
if (!localStorage.getItem('active_userid')) throw new Error('Not authenticated')
const endpoint = subscription.endpoint
const json = subscription.toJSON()
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) {
let p256dh = json.keys?.p256dh
let auth = json.keys?.auth
// Fallback for browsers (like Safari) that might not serialize keys in toJSON()
if (!p256dh && typeof subscription.getKey === 'function') {
try {
const rawKey = subscription.getKey('p256dh')
if (rawKey) {
p256dh = btoa(String.fromCharCode(...new Uint8Array(rawKey)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
} catch (e) {
console.warn('Failed to extract p256dh key manually:', e)
}
}
if (!auth && typeof subscription.getKey === 'function') {
try {
const rawAuth = subscription.getKey('auth')
if (rawAuth) {
auth = btoa(String.fromCharCode(...new Uint8Array(rawAuth)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
} catch (e) {
console.warn('Failed to extract auth key manually:', e)
}
}
if (!endpoint || !p256dh || !auth) {
throw new Error('Invalid push subscription')
}
@@ -85,8 +180,8 @@ async function saveSubscriptionToServer(subscription: PushSubscription): Promise
await apiJson(`${API_BASE}/subscription`, {
method: 'PUT',
body: JSON.stringify({
endpoint: json.endpoint,
keys: json.keys,
endpoint,
keys: { p256dh, auth },
locale,
userAgent: navigator.userAgent
})
@@ -98,35 +193,48 @@ export async function subscribeToPush(): Promise<void> {
throw new Error('Push notifications are not supported on this device')
}
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
throw new Error('Notification permission denied')
// Pre-resolve registration using getRegistrationCompat to prevent ready state hangs
let registration = cachedRegistration
if (!registration) {
registration = await getRegistrationCompat()
cachedRegistration = registration
}
const publicKey = await fetchVapidPublicKey()
const publicKey = cachedVapidKey || await fetchVapidPublicKey()
if (!publicKey) {
throw new Error('Push notifications are not configured on this server')
}
const registration = await navigator.serviceWorker.ready
let subscription = await registration.pushManager.getSubscription()
if (!subscription) {
const keyBytes = urlBase64ToUint8Array(publicKey)
const applicationServerKey = new Uint8Array(keyBytes)
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey
})
const permission = await requestNotificationPermission()
if (permission !== 'granted') {
throw new Error('Notification permission denied')
}
const keyBytes = urlBase64ToUint8Array(publicKey)
const applicationServerKey = new Uint8Array(keyBytes)
// Always call subscribe with timeout to prevent silent hangs on push network errors
const subscribePromise = registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey
})
const subscribeTimeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout establishing subscription with push service (FCM/APNs)')), 12000)
)
const subscription = await Promise.race([subscribePromise, subscribeTimeout])
await saveSubscriptionToServer(subscription)
}
export async function unsubscribeFromPush(): Promise<void> {
if (!isPushSupported()) return
const registration = await navigator.serviceWorker.ready
let registration = cachedRegistration
if (!registration) {
registration = await getRegistrationCompat()
cachedRegistration = registration
}
const subscription = await registration.pushManager.getSubscription()
if (!subscription) return
@@ -164,3 +272,7 @@ export async function disableCollaboratorChangePush(): Promise<void> {
await savePushPrefs(false)
await unsubscribeFromPush()
}
if (isPushSupported()) {
void preloadPushService()
}
+23 -16
View File
@@ -3,6 +3,7 @@ import { getActiveMasterKey } from './auth.js'
import { ensureLogbookKey, getLogbookKey } from './logbookKeys.js'
import { decryptJson, encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import { putEntryRecord } from '../utils/entryListCache.js'
import {
buildLogEntryPayload,
normalizeLogEvent,
@@ -190,14 +191,17 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
const encrypted = await encryptJson(initialPayload, masterKey)
await db.entries.put({
payloadId: localId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: nowStr
})
await putEntryRecord(
{
payloadId: localId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: nowStr
},
initialPayload
)
await db.syncQueue.put({
action: 'create',
@@ -305,14 +309,17 @@ async function persistEntry(
const encrypted = await encryptJson(entryData, masterKey)
const now = new Date().toISOString()
await db.entries.put({
payloadId: entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await putEntryRecord(
{
payloadId: entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
},
entryData
)
await db.syncQueue.put({
action: 'update',
+45
View File
@@ -0,0 +1,45 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.js'
import type { VesselData } from '../types/vessel.js'
import { vesselDataFromSnapshot } from '../utils/vesselSnapshot.js'
import { loadLogbookVesselSelection } from './logbookVesselSelection.js'
import { loadVesselPoolMap } from './vesselPool.js'
/** Resolved vessel for a logbook: selection snapshot, pool, or legacy per-logbook yacht. */
export async function resolveVesselForLogbook(
logbookId: string,
options?: {
preloadedYacht?: VesselData | Record<string, unknown> | null
preloadedSelection?: import('../types/vessel.js').LogbookVesselSelectionData
}
): Promise<VesselData | null> {
if (options?.preloadedYacht) {
return options.preloadedYacht as VesselData
}
const selection =
options?.preloadedSelection ?? (logbookId === 'demo' ? null : await loadLogbookVesselSelection(logbookId))
if (selection?.vesselSnapshot) {
return vesselDataFromSnapshot(selection.vesselSnapshot)
}
if (selection?.activeVesselId && logbookId !== 'demo') {
const pool = await loadVesselPoolMap()
const fromPool = pool.get(selection.activeVesselId)
if (fromPool) return fromPool
}
const legacy = await db.yachts.get(logbookId)
if (!legacy) return null
const key = (await getLogbookKey(logbookId)) || getActiveMasterKey()
if (!key) return null
const decrypted = (await decryptJson(legacy.encryptedData, legacy.iv, legacy.tag, key)) as
| VesselData
| null
return decrypted
}
+44 -15
View File
@@ -8,6 +8,7 @@ import {
type SyncConflict
} from './syncConflicts.js'
import { syncPersonPool } from './personPoolSync.js'
import { forEachInBatches, yieldToMain } from '../utils/yieldToMain.js'
const API_BASE = '/api/sync'
const syncingLogbooks = new Set<string>()
@@ -64,6 +65,8 @@ async function entityExistsLocally(item: SyncQueueItem): Promise<boolean> {
return !!(await db.gpsTracks.get(item.payloadId))
case 'logbookCrew':
return !!(await db.logbookCrewSelections.get(item.logbookId))
case 'logbookVessel':
return !!(await db.logbookVesselSelections.get(item.logbookId))
default:
return false
}
@@ -128,12 +131,7 @@ async function coalesceSyncQueue(logbookId: string): Promise<SyncQueueItem[]> {
}
function scheduleResync(logbookId: string) {
if (pendingResync.has(logbookId)) return
pendingResync.add(logbookId)
queueMicrotask(() => {
pendingResync.delete(logbookId)
syncLogbook(logbookId).catch((err) => console.warn('Deferred sync failed:', err))
})
}
type LogbookPushAccess = 'OWNER' | 'WRITE' | 'READ' | 'UNKNOWN'
@@ -228,6 +226,7 @@ type PulledServerPayload = {
yacht?: { updatedAt: string } | null
deviation?: { updatedAt: string } | null
logbookCrewSelection?: { updatedAt: string } | null
logbookVesselSelection?: { updatedAt: string } | null
crews?: Array<{ payloadId: string; updatedAt: string }>
entries?: Array<{ payloadId: string; updatedAt: string }>
photos?: Array<{ payloadId: string; updatedAt: string }>
@@ -248,6 +247,9 @@ async function pruneAcknowledgedQueueItems(
if (server.logbookCrewSelection) {
serverTimes.set('logbookCrew:' + logbookId, server.logbookCrewSelection.updatedAt)
}
if (server.logbookVesselSelection) {
serverTimes.set('logbookVessel:' + logbookId, server.logbookVesselSelection.updatedAt)
}
for (const c of server.crews ?? []) serverTimes.set('crew:' + c.payloadId, c.updatedAt)
for (const e of server.entries ?? []) serverTimes.set('entry:' + e.payloadId, e.updatedAt)
for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt)
@@ -269,7 +271,9 @@ async function pruneAcknowledgedQueueItems(
? 'yacht:' + logbookId
: item.type === 'logbookCrew'
? 'logbookCrew:' + logbookId
: `${item.type}:${item.payloadId}`
: item.type === 'logbookVessel'
? 'logbookVessel:' + logbookId
: `${item.type}:${item.payloadId}`
const serverUpdatedAt = serverTimes.get(key)
if (serverUpdatedAt && !isNewer(item.updatedAt, serverUpdatedAt)) {
if (item.id !== undefined) staleIds.push(item.id)
@@ -295,12 +299,17 @@ async function pullChanges(logbookId: string): Promise<boolean> {
return false
}
const { yacht, deviation, crews, logbookCrewSelection, entries, photos, gpsTracks } =
const { yacht, deviation, crews, logbookCrewSelection, logbookVesselSelection, entries, photos, gpsTracks } =
await response.json()
// Large pull payloads block on JSON.parse — yield before applying to IndexedDB.
await yieldToMain()
const serverSnapshot: PulledServerPayload = {
yacht,
deviation,
logbookCrewSelection,
logbookVesselSelection,
crews,
entries,
photos,
@@ -349,10 +358,24 @@ async function pullChanges(logbookId: string): Promise<boolean> {
}
}
// 2c. Sync Logbook Vessel Selection
if (logbookVesselSelection) {
const local = await db.logbookVesselSelections.get(logbookId)
if (!local || isNewer(logbookVesselSelection.updatedAt, local.updatedAt)) {
await db.logbookVesselSelections.put({
logbookId,
encryptedData: logbookVesselSelection.encryptedData,
iv: logbookVesselSelection.iv,
tag: logbookVesselSelection.tag,
updatedAt: logbookVesselSelection.updatedAt
})
}
}
// 3. Sync Crew List Payloads (legacy)
const serverCrewMap = new Map<string, any>()
if (crews && Array.isArray(crews)) {
for (const c of crews) {
await forEachInBatches(crews, 20, async (c) => {
serverCrewMap.set(c.payloadId, c)
const local = await db.crews.get(c.payloadId)
if (!local || isNewer(c.updatedAt, local.updatedAt)) {
@@ -365,7 +388,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
updatedAt: c.updatedAt
})
}
}
})
}
// Deletions for Crew: If present locally but not on server, and not pending creation locally
@@ -385,7 +408,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
// 4. Sync Journal Entry Payloads
const serverEntryMap = new Map<string, any>()
if (entries && Array.isArray(entries)) {
for (const e of entries) {
await forEachInBatches(entries, 15, async (e) => {
serverEntryMap.set(e.payloadId, e)
const local = await db.entries.get(e.payloadId)
if (!local || isNewer(e.updatedAt, local.updatedAt)) {
@@ -398,7 +421,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
updatedAt: e.updatedAt
})
}
}
})
}
// Deletions for Entries
@@ -417,7 +440,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
// 5. Sync Photos
const serverPhotoMap = new Map<string, any>()
if (photos && Array.isArray(photos)) {
for (const p of photos) {
await forEachInBatches(photos, 20, async (p) => {
serverPhotoMap.set(p.payloadId, p)
const local = await db.photos.get(p.payloadId)
if (!local || isNewer(p.updatedAt, local.updatedAt)) {
@@ -432,7 +455,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
updatedAt: p.updatedAt
})
}
}
})
}
// Deletions for Photos
@@ -451,7 +474,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
// 6. Sync GPS Tracks
const serverGpsTrackMap = new Map<string, any>()
if (gpsTracks && Array.isArray(gpsTracks)) {
for (const gt of gpsTracks) {
await forEachInBatches(gpsTracks, 10, async (gt) => {
serverGpsTrackMap.set(gt.entryId, gt)
const local = await db.gpsTracks.get(gt.entryId)
if (!local || isNewer(gt.updatedAt, local.updatedAt)) {
@@ -464,7 +487,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
updatedAt: gt.updatedAt
})
}
}
})
}
// Deletions for GPS Tracks
@@ -512,6 +535,12 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
} finally {
syncingLogbooks.delete(logbookId)
recomputeSyncingState()
if (pendingResync.has(logbookId)) {
pendingResync.delete(logbookId)
setTimeout(() => {
syncLogbook(logbookId).catch((err) => console.warn('Deferred sync failed:', err))
}, 1000)
}
}
}
+80
View File
@@ -0,0 +1,80 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { decryptJson, encryptJson } from './crypto.js'
import { getLogbookKey } from './logbookKeys.js'
import type { VesselData } from '../types/vessel.js'
import { buildLogbookVesselSelection } from '../utils/vesselSnapshot.js'
import { saveLogbookVesselSelection } from './logbookVesselSelection.js'
const MIGRATION_FLAG = 'vessel_pool_migration_v1_done'
function dedupeKey(data: VesselData): string {
const reg = (data.registrationNumber || '').trim().toLowerCase()
const name = (data.name || '').trim().toLowerCase()
return `${reg}|${name}`
}
export async function migrateLegacyYachtsToPoolIfNeeded(): Promise<void> {
const userId = localStorage.getItem('active_userid')
if (!userId || localStorage.getItem(MIGRATION_FLAG) === userId) return
const masterKey = getActiveMasterKey()
if (!masterKey) return
try {
const ownedLogbooks = await db.logbooks.filter((lb) => lb.isShared !== 1).toArray()
const poolByKey = new Map<string, string>()
const poolData = new Map<string, VesselData>()
for (const logbook of ownedLogbooks) {
const logbookKey = (await getLogbookKey(logbook.id)) || masterKey
const legacyYacht = await db.yachts.get(logbook.id)
if (!legacyYacht) continue
const data = (await decryptJson(
legacyYacht.encryptedData,
legacyYacht.iv,
legacyYacht.tag,
logbookKey
)) as VesselData | null
if (!data?.name?.trim()) continue
const key = dedupeKey(data)
let poolId = poolByKey.get(key)
if (!poolId) {
poolId = crypto.randomUUID()
const existing = await db.vesselPool.get(poolId)
if (!existing) {
const encrypted = await encryptJson(data, masterKey)
const now = new Date().toISOString()
await db.vesselPool.put({
payloadId: poolId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.userSyncQueue.put({
action: 'create',
type: 'vessel',
payloadId: poolId,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
poolByKey.set(key, poolId)
poolData.set(poolId, data)
}
const existingSelection = await db.logbookVesselSelections.get(logbook.id)
if (!existingSelection) {
const selection = buildLogbookVesselSelection(poolId, poolData)
await saveLogbookVesselSelection(logbook.id, selection)
}
}
localStorage.setItem(MIGRATION_FLAG, userId)
} catch (err) {
console.warn('Vessel pool migration failed:', err)
}
}
+90
View File
@@ -0,0 +1,90 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { decryptJson, encryptJson } from './crypto.js'
import type { VesselData } from '../types/vessel.js'
import { MAX_POOL_VESSELS } from '../types/vessel.js'
import { syncVesselPool } from './vesselPoolSync.js'
export interface DecryptedVessel {
payloadId: string
data: VesselData
}
function requireMasterKey(): ArrayBuffer {
const key = getActiveMasterKey()
if (!key) throw new Error('Encryption key not found. Please log in.')
return key
}
export async function loadVesselPool(): Promise<DecryptedVessel[]> {
const masterKey = requireMasterKey()
const records = await db.vesselPool.toArray()
const result: DecryptedVessel[] = []
for (const record of records) {
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)) as
| VesselData
| null
if (data?.name) {
result.push({ payloadId: record.payloadId, data })
}
}
result.sort((a, b) =>
a.data.name.localeCompare(b.data.name, undefined, { sensitivity: 'base' })
)
return result
}
export async function loadVesselPoolMap(): Promise<Map<string, VesselData>> {
const vessels = await loadVesselPool()
return new Map(vessels.map((v) => [v.payloadId, v.data]))
}
export async function saveVessel(
payloadId: string,
data: VesselData,
isNew: boolean
): Promise<void> {
if (isNew) {
const count = await db.vesselPool.count()
if (count >= MAX_POOL_VESSELS) {
throw new Error('MAX_VESSELS')
}
}
const masterKey = requireMasterKey()
const encrypted = await encryptJson(data, masterKey)
const now = new Date().toISOString()
await db.vesselPool.put({
payloadId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.userSyncQueue.put({
action: isNew ? 'create' : 'update',
type: 'vessel',
payloadId,
data: JSON.stringify(encrypted),
updatedAt: now
})
syncVesselPool().catch((err) => console.warn('Vessel pool sync failed:', err))
}
export async function deleteVessel(payloadId: string): Promise<void> {
const now = new Date().toISOString()
await db.vesselPool.delete(payloadId)
await db.userSyncQueue.put({
action: 'delete',
type: 'vessel',
payloadId,
data: '',
updatedAt: now
})
syncVesselPool().catch((err) => console.warn('Vessel pool sync failed:', err))
}
+83
View File
@@ -0,0 +1,83 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { apiFetch } from './api.js'
const API_BASE = '/api/auth/vessel-pool'
function isNewer(timeA: string | Date, timeB: string | Date): boolean {
return new Date(timeA).getTime() > new Date(timeB).getTime()
}
export async function syncVesselPool(): Promise<void> {
if (!navigator.onLine || !getActiveMasterKey() || !localStorage.getItem('active_userid')) return
await pushVesselPool()
await pullVesselPool()
}
async function pushVesselPool(): Promise<void> {
const pending = (await db.userSyncQueue.toArray()).filter((item) => item.type === 'vessel')
if (pending.length === 0) return
try {
const response = await apiFetch(`${API_BASE}/push`, {
method: 'POST',
body: JSON.stringify({ items: pending })
})
if (!response.ok) {
console.warn('Vessel pool push rejected')
return
}
const { results } = await response.json()
for (let i = 0; i < results.length; i++) {
const res = results[i]
const item = pending[i]
if (!item) continue
if (res.status === 'success' && item.id !== undefined) {
await db.userSyncQueue.delete(item.id)
}
}
} catch (err) {
console.warn('Vessel pool push failed:', err)
}
}
async function pullVesselPool(): Promise<void> {
try {
const response = await apiFetch(API_BASE, { method: 'GET' })
if (!response.ok) return
const { vessels } = await response.json()
if (!Array.isArray(vessels)) return
const serverMap = new Map<string, (typeof vessels)[0]>()
for (const v of vessels) {
serverMap.set(v.payloadId, v)
const local = await db.vesselPool.get(v.payloadId)
if (!local || isNewer(v.updatedAt, local.updatedAt)) {
await db.vesselPool.put({
payloadId: v.payloadId,
encryptedData: v.encryptedData,
iv: v.iv,
tag: v.tag,
updatedAt: v.updatedAt
})
}
}
const localAll = await db.vesselPool.toArray()
for (const local of localAll) {
if (!serverMap.has(local.payloadId)) {
const pendingCreate = await db.userSyncQueue
.where({ payloadId: local.payloadId, action: 'create' })
.first()
if (!pendingCreate) {
await db.vesselPool.delete(local.payloadId)
}
}
}
} catch (err) {
console.warn('Vessel pool pull failed:', err)
}
}
+54
View File
@@ -0,0 +1,54 @@
export interface VesselData {
name: string
vesselType?: string
lengthM?: number
draftM?: number
airDraftM?: number
homePort?: string
charterCompany?: string
owner?: string
registrationNumber?: string
callSign?: string
atis?: string
mmsi?: string
sails?: string[]
photo?: string | null
freshwaterCapacityL?: number
fuelCapacityL?: number
greywaterCapacityL?: number
}
export interface VesselSnapshot extends VesselData {
id: string
}
export interface LogbookVesselSelectionData {
activeVesselId: string | null
/** Denormalized for collaborators / offline without account pool */
vesselSnapshot: VesselSnapshot | null
}
export const MAX_POOL_VESSELS = 20
export function emptyLogbookVesselSelection(): LogbookVesselSelectionData {
return {
activeVesselId: null,
vesselSnapshot: null
}
}
export function emptyVesselData(): VesselData {
return {
name: '',
vesselType: '',
homePort: '',
charterCompany: '',
owner: '',
registrationNumber: '',
callSign: '',
atis: '',
mmsi: '',
sails: [],
photo: null
}
}
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { buildEntryListCache, entryListItemFromLocal } from './entryListCache.js'
import type { LocalEntry } from '../services/db.js'
describe('entryListCache', () => {
it('builds cache fields from decrypted entry', async () => {
const cache = await buildEntryListCache({
date: '2026-06-02',
dayOfTravel: '3',
departure: 'Kiel',
destination: 'Laboe',
signSkipper: 'Max'
})
expect(cache).toEqual({
date: '2026-06-02',
dayOfTravel: '3',
departure: 'Kiel',
destination: 'Laboe',
skipperSignStatus: 'valid'
})
})
it('maps cached local entry to list item', () => {
const entry: LocalEntry = {
payloadId: 'e1',
logbookId: 'lb1',
encryptedData: 'x',
iv: 'i',
tag: 't',
updatedAt: '2026-06-02T12:00:00.000Z',
listCache: {
date: '2026-06-02',
dayOfTravel: '1',
departure: 'A',
destination: 'B',
skipperSignStatus: 'none'
}
}
expect(entryListItemFromLocal(entry)).toEqual({
id: 'e1',
date: '2026-06-02',
dayOfTravel: '1',
departure: 'A',
destination: 'B',
updatedAt: '2026-06-02T12:00:00.000Z',
skipperSignStatus: 'none'
})
})
it('returns null when cache is missing', () => {
const entry: LocalEntry = {
payloadId: 'e1',
logbookId: 'lb1',
encryptedData: 'x',
iv: 'i',
tag: 't',
updatedAt: '2026-06-02T12:00:00.000Z'
}
expect(entryListItemFromLocal(entry)).toBeNull()
})
})
+64
View File
@@ -0,0 +1,64 @@
import { db, type EntryListCache, type LocalEntry } from '../services/db.js'
import { getSkipperSignStatus, type SkipperSignStatus } from './signatures.js'
export type { EntryListCache }
export interface EntryListItem {
id: string
date: string
dayOfTravel: string
departure: string
destination: string
updatedAt: string
skipperSignStatus: SkipperSignStatus
}
export async function buildEntryListCache(decrypted: Record<string, unknown>): Promise<EntryListCache> {
return {
date: String(decrypted.date || ''),
dayOfTravel: String(decrypted.dayOfTravel || ''),
departure: String(decrypted.departure || ''),
destination: String(decrypted.destination || ''),
skipperSignStatus: await getSkipperSignStatus(decrypted)
}
}
export function entryListItemFromLocal(entry: LocalEntry): EntryListItem | null {
if (!entry.listCache) return null
return {
id: entry.payloadId,
date: entry.listCache.date,
dayOfTravel: entry.listCache.dayOfTravel,
departure: entry.listCache.departure,
destination: entry.listCache.destination,
updatedAt: entry.updatedAt,
skipperSignStatus: entry.listCache.skipperSignStatus
}
}
export type LocalEntryPut = Omit<LocalEntry, 'listCache'> & { listCache?: EntryListCache }
/** Persist entry ciphertext and optional plaintext list cache for fast journal list loads. */
export async function putEntryRecord(
record: LocalEntryPut,
decryptedForCache?: Record<string, unknown>
): Promise<void> {
const listCache =
record.listCache ??
(decryptedForCache ? await buildEntryListCache(decryptedForCache) : undefined)
await db.entries.put({
...record,
...(listCache ? { listCache } : {})
})
}
/** Backfill list cache after a legacy decrypt — fire-and-forget is fine. */
export function persistEntryListCache(
payloadId: string,
decrypted: Record<string, unknown>
): void {
void buildEntryListCache(decrypted)
.then((listCache) => db.entries.update(payloadId, { listCache }))
.catch((err) => console.warn('Failed to persist entry list cache:', err))
}
@@ -24,6 +24,7 @@ const t = (key: string, opts?: Record<string, unknown>) => {
'logs.live_event_generic': 'Event',
'logs.live_temp_entry': `Temperature ${opts?.temp} °C`,
'logs.live_pressure_entry': `Pressure ${opts?.value} hPa`,
'logs.live_visibility_entry': `Visibility ${opts?.value}`,
'logs.live_wind_entry': `Wind ${opts?.value}`,
'logs.live_photo_entry': `Photo: ${opts?.caption}`,
'logs.live_photo_entry_plain': 'Photo captured',
@@ -94,6 +95,15 @@ describe('formatEventSummary', () => {
expect(formatEventSummary(event, t)).toBe('Pressure 1013 hPa')
})
it('formats visibility entry', () => {
const event = normalizeLogEvent({
time: '09:00',
remarks: LIVE_EVENT_CODES.VISIBILITY,
visibility: '10 km'
})
expect(formatEventSummary(event, t)).toBe('Visibility 10 km')
})
it('formats SOG entry', () => {
const event = normalizeLogEvent({
time: '10:15',
+5
View File
@@ -81,6 +81,10 @@ export function formatEventSummary(event: LogEventPayload, t: TFunction): string
return t('logs.live_sea_state_entry', { value: event.seaState })
}
if (code === LIVE_EVENT_CODES.VISIBILITY && event.visibility) {
return t('logs.live_visibility_entry', { value: event.visibility })
}
if (code && !code.startsWith('__live:')) {
return code
}
@@ -92,6 +96,7 @@ export function formatEventSummary(event: LogEventPayload, t: TFunction): string
parts.push([event.windDirection, event.windStrength].filter(Boolean).join(' '))
}
if (event.windPressure) parts.push(`${t('logs.event_wind_pressure')}: ${event.windPressure}`)
if (event.visibility) parts.push(`${t('logs.event_visibility')}: ${event.visibility}`)
if (event.gpsLat && event.gpsLng) {
parts.push(`${event.gpsLat}, ${event.gpsLng}`)
}
+2 -1
View File
@@ -9,7 +9,8 @@ export const LIVE_EVENT_CODES = {
COURSE: '__live:course',
WIND: '__live:wind',
PRESSURE: '__live:pressure',
SEA_STATE: '__live:sea_state'
SEA_STATE: '__live:sea_state',
VISIBILITY: '__live:visibility'
} as const
export type LiveEventCode = (typeof LIVE_EVENT_CODES)[keyof typeof LIVE_EVENT_CODES]
+3 -1
View File
@@ -12,6 +12,7 @@ export interface LogEventPayload {
windDirection: string
windStrength: string
seaState: string
visibility: string
weatherIcon: string
current: string
heel: string
@@ -75,7 +76,7 @@ export function joinTimeHHMM(hours: string, minutes: string): string {
const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [
'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState',
'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
'visibility', 'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
'gpsLat', 'gpsLng', 'remarks'
]
@@ -91,6 +92,7 @@ export function normalizeLogEvent(event: Partial<LogEventPayload> | Record<strin
windDirection: normalizeWindDirectionString(String(e.windDirection ?? '')),
windStrength: '',
seaState: '',
visibility: '',
weatherIcon: '',
current: '',
heel: '',
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { logbookMatchesFilter, nameMatchesQuery } from './logbookFilter.js'
describe('nameMatchesQuery', () => {
it('matches full name', () => {
expect(nameMatchesQuery('Anna Müller', 'müller')).toBe(true)
})
it('matches first name part only', () => {
expect(nameMatchesQuery('Anna Müller', 'anna')).toBe(true)
})
it('matches last name part only', () => {
expect(nameMatchesQuery('Anna Müller', 'mül')).toBe(true)
})
it('returns false for unrelated query', () => {
expect(nameMatchesQuery('Anna Müller', 'peter')).toBe(false)
})
})
describe('logbookMatchesFilter', () => {
const lb = { title: 'Sommer 2024', updatedAt: '2024-06-15T12:00:00.000Z' }
it('matches logbook title', () => {
expect(logbookMatchesFilter(lb, 'sommer', 'de')).toBe(true)
})
it('matches vessel name from search fields', () => {
expect(
logbookMatchesFilter(lb, 'wind', 'de', { vesselName: 'Windrose', crewNames: [] })
).toBe(true)
})
it('matches crew first name from search fields', () => {
expect(
logbookMatchesFilter(lb, 'klaus', 'de', {
vesselName: '',
crewNames: ['Klaus Hansen']
})
).toBe(true)
})
it('matches crew last name from search fields', () => {
expect(
logbookMatchesFilter(lb, 'hansen', 'de', {
vesselName: '',
crewNames: ['Klaus Hansen']
})
).toBe(true)
})
})
+45
View File
@@ -0,0 +1,45 @@
export interface LogbookSearchFields {
vesselName: string
crewNames: string[]
}
/** Match full name or any whitespace-separated part (e.g. first or last name). */
export function nameMatchesQuery(name: string, query: string): boolean {
const q = query.trim().toLowerCase()
if (!q) return true
const normalized = name.trim().toLowerCase()
if (!normalized) return false
if (normalized.includes(q)) return true
return normalized.split(/\s+/).some((part) => part.includes(q))
}
export function logbookMatchesFilter(
lb: { title: string; updatedAt: string },
query: string,
locale: string,
fields?: LogbookSearchFields
): boolean {
const q = query.trim().toLowerCase()
if (!q) return true
if (lb.title.toLowerCase().includes(q)) return true
const updated = new Date(lb.updatedAt)
const year = updated.getFullYear().toString()
if (year.includes(q)) return true
const dateLabel = updated.toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric'
}).toLowerCase()
if (dateLabel.includes(q)) return true
if (fields?.vesselName && nameMatchesQuery(fields.vesselName, q)) return true
if (fields?.crewNames?.some((name) => nameMatchesQuery(name, q))) return true
return false
}
+9
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
formatOwmVisibilityMeters,
formatWindStrengthBeaufort,
mpsToBeaufort,
parseOwmCurrentWeather
@@ -13,15 +14,23 @@ describe('openWeatherMap', () => {
expect(formatWindStrengthBeaufort(5)).toBe('3 Bft (5.0 m/s)')
})
it('formats visibility in metres', () => {
expect(formatOwmVisibilityMeters(500)).toBe('500 m')
expect(formatOwmVisibilityMeters(10000)).toBe('10 km')
expect(formatOwmVisibilityMeters(2500)).toBe('2.5 km')
})
it('parses OWM current weather payload', () => {
const parsed = parseOwmCurrentWeather({
wind: { speed: 8.5, deg: 225 },
main: { pressure: 1018, temp: 17.4 },
visibility: 10000,
weather: [{ icon: '04d', description: 'Bedeckt' }]
})
expect(parsed.windDirection).toBe('SW')
expect(parsed.windStrength).toBe('5 Bft (8.5 m/s)')
expect(parsed.windPressure).toBe('1018')
expect(parsed.visibility).toBe('10 km')
expect(parsed.tempC).toBe('17.4')
expect(parsed.precipText).toBe('Bedeckt')
expect(parsed.weatherIcon).toBe('04d')
+12
View File
@@ -1,9 +1,14 @@
import { degreesToCardinal } from './courseAngle.js'
import { formatVisibilityMeters } from './weatherMetrics.js'
/** @deprecated Use formatVisibilityMeters */
export const formatOwmVisibilityMeters = formatVisibilityMeters
export interface ParsedOwmCurrent {
windDirection: string
windStrength: string
windPressure: string
visibility: string
tempC: string | null
precipText: string | null
weatherIcon: string | null
@@ -57,10 +62,17 @@ export function parseOwmCurrentWeather(data: Record<string, unknown>): ParsedOwm
const weatherIcon = firstWeather?.icon?.trim() ? firstWeather.icon.trim() : null
const visibilityRaw = data.visibility
const visibility =
typeof visibilityRaw === 'number'
? formatVisibilityMeters(visibilityRaw)
: ''
return {
windDirection,
windStrength,
windPressure,
visibility,
tempC,
precipText,
weatherIcon
+90
View File
@@ -0,0 +1,90 @@
import { parseOptionalTankLiters, tankCapacityInputFromStored } from './tankCapacity.js'
import type { VesselData } from '../types/vessel.js'
export function metricInputFromStored(value: unknown): string {
if (value == null || value === '') return ''
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
if (typeof value === 'string') return value.trim()
return ''
}
export function parseOptionalMetricMeters(input: string): number | undefined {
const trimmed = input.trim().replace(',', '.')
if (!trimmed) return undefined
const parsed = Number(trimmed)
if (!Number.isFinite(parsed) || parsed < 0) {
throw new Error('invalid_metric')
}
return parsed
}
export interface VesselFormInputs {
name: string
vesselType: string
lengthM: string
draftM: string
airDraftM: string
homePort: string
charterCompany: string
owner: string
registrationNumber: string
callSign: string
atis: string
mmsi: string
sails: string[]
photo: string | null
freshwaterCapacityL: string
fuelCapacityL: string
greywaterCapacityL: string
}
export function vesselDataToFormInputs(data: Partial<VesselData>): VesselFormInputs {
return {
name: data.name || '',
vesselType: data.vesselType || '',
lengthM: metricInputFromStored(data.lengthM),
draftM: metricInputFromStored(data.draftM),
airDraftM: metricInputFromStored(data.airDraftM),
homePort: data.homePort || '',
charterCompany: data.charterCompany || '',
owner: data.owner || '',
registrationNumber: data.registrationNumber || '',
callSign: data.callSign || '',
atis: data.atis || '',
mmsi: data.mmsi || '',
sails: data.sails || [],
photo: data.photo ?? null,
freshwaterCapacityL: tankCapacityInputFromStored(data.freshwaterCapacityL),
fuelCapacityL: tankCapacityInputFromStored(data.fuelCapacityL),
greywaterCapacityL: tankCapacityInputFromStored(data.greywaterCapacityL)
}
}
export function parseVesselFormInputs(inputs: VesselFormInputs): VesselData {
const parsedLengthM = parseOptionalMetricMeters(inputs.lengthM)
const parsedDraftM = parseOptionalMetricMeters(inputs.draftM)
const parsedAirDraftM = parseOptionalMetricMeters(inputs.airDraftM)
const parsedFreshwaterCapacityL = parseOptionalTankLiters(inputs.freshwaterCapacityL)
const parsedFuelCapacityL = parseOptionalTankLiters(inputs.fuelCapacityL)
const parsedGreywaterCapacityL = parseOptionalTankLiters(inputs.greywaterCapacityL)
return {
name: inputs.name.trim(),
vesselType: inputs.vesselType || undefined,
lengthM: parsedLengthM,
draftM: parsedDraftM,
airDraftM: parsedAirDraftM,
freshwaterCapacityL: parsedFreshwaterCapacityL,
fuelCapacityL: parsedFuelCapacityL,
greywaterCapacityL: parsedGreywaterCapacityL,
homePort: inputs.homePort.trim(),
charterCompany: inputs.charterCompany.trim(),
owner: inputs.owner.trim(),
registrationNumber: inputs.registrationNumber.trim(),
callSign: inputs.callSign.trim(),
atis: inputs.atis.trim(),
mmsi: inputs.mmsi.trim(),
sails: inputs.sails,
photo: inputs.photo
}
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { buildLogbookVesselSelection, vesselDataFromSnapshot, vesselToSnapshot } from './vesselSnapshot.js'
import type { VesselData } from '../types/vessel.js'
const sampleVessel: VesselData = {
name: 'Sea Breeze',
homePort: 'Kiel',
sails: ['Genoa'],
registrationNumber: 'DE-123'
}
describe('vesselSnapshot', () => {
it('builds selection with snapshot from pool', () => {
const pool = new Map<string, VesselData>([['v1', sampleVessel]])
const sel = buildLogbookVesselSelection('v1', pool)
expect(sel.activeVesselId).toBe('v1')
expect(sel.vesselSnapshot?.name).toBe('Sea Breeze')
expect(sel.vesselSnapshot?.id).toBe('v1')
})
it('returns empty selection when no vessel id', () => {
const sel = buildLogbookVesselSelection(null, new Map())
expect(sel.activeVesselId).toBeNull()
expect(sel.vesselSnapshot).toBeNull()
})
it('round-trips snapshot to vessel data', () => {
const snap = vesselToSnapshot('v1', sampleVessel)
const data = vesselDataFromSnapshot(snap)
expect(data?.name).toBe('Sea Breeze')
expect(data?.homePort).toBe('Kiel')
})
})
+47
View File
@@ -0,0 +1,47 @@
import type { LogbookVesselSelectionData, VesselData, VesselSnapshot } from '../types/vessel.js'
export function vesselToSnapshot(id: string, data: VesselData): VesselSnapshot {
return {
id,
name: data.name,
vesselType: data.vesselType,
lengthM: data.lengthM,
draftM: data.draftM,
airDraftM: data.airDraftM,
homePort: data.homePort,
charterCompany: data.charterCompany,
owner: data.owner,
registrationNumber: data.registrationNumber,
callSign: data.callSign,
atis: data.atis,
mmsi: data.mmsi,
sails: data.sails ? [...data.sails] : [],
photo: data.photo ?? null,
freshwaterCapacityL: data.freshwaterCapacityL,
fuelCapacityL: data.fuelCapacityL,
greywaterCapacityL: data.greywaterCapacityL
}
}
export function buildLogbookVesselSelection(
activeVesselId: string | null,
pool: Map<string, VesselData>
): LogbookVesselSelectionData {
if (!activeVesselId) {
return { activeVesselId: null, vesselSnapshot: null }
}
const data = pool.get(activeVesselId)
if (!data) {
return { activeVesselId, vesselSnapshot: null }
}
return {
activeVesselId,
vesselSnapshot: vesselToSnapshot(activeVesselId, data)
}
}
export function vesselDataFromSnapshot(snapshot: VesselSnapshot | null): VesselData | null {
if (!snapshot) return null
const { id: _id, ...data } = snapshot
return data
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import {
formatPressureHpa,
formatSeaState,
formatVisibilityMeters,
parseHeelDeg,
parsePressureHpa,
parseSeaState,
parseVisibilityMeters,
visibilityMetersFromStepIndex,
visibilityStepIndex
} from './weatherMetrics.js'
describe('weatherMetrics', () => {
it('parses and formats pressure', () => {
expect(parsePressureHpa('1014')).toBe(1014)
expect(parsePressureHpa('1014 hPa')).toBe(1014)
expect(parsePressureHpa('')).toBeNull()
expect(formatPressureHpa(1014)).toBe('1014')
})
it('parses and formats sea state', () => {
expect(parseSeaState('3')).toBe(3)
expect(parseSeaState('leicht')).toBeNull()
expect(formatSeaState(3)).toBe('3')
})
it('parses and formats heel', () => {
expect(parseHeelDeg('12')).toBe(12)
expect(parseHeelDeg('12°')).toBe(12)
})
it('parses visibility with units', () => {
expect(parseVisibilityMeters('10 km')).toBe(10000)
expect(parseVisibilityMeters('500 m')).toBe(500)
expect(formatVisibilityMeters(10000)).toBe('10 km')
expect(formatVisibilityMeters(500)).toBe('500 m')
})
it('maps visibility to log steps', () => {
expect(visibilityStepIndex(10000)).toBe(8)
expect(visibilityMetersFromStepIndex(8)).toBe(10000)
expect(visibilityMetersFromStepIndex(0)).toBe(0)
})
})
+118
View File
@@ -0,0 +1,118 @@
/** Barometric pressure (hPa), typical marine range. */
export const PRESSURE_MIN_HPA = 960
export const PRESSURE_MAX_HPA = 1050
export const PRESSURE_DEFAULT_HPA = 1013
/** Douglas sea state 09. */
export const SEA_STATE_MIN = 0
export const SEA_STATE_MAX = 9
/** Heel angle in degrees. */
export const HEEL_MIN_DEG = 0
export const HEEL_MAX_DEG = 45
/** Log-spaced visibility steps in metres; index 0 = not set. */
export const VISIBILITY_STEPS_M = [
0, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 50000
] as const
function parseDecimal(value: string): number | null {
const trimmed = value.trim().replace(',', '.')
if (!trimmed) return null
const n = Number(trimmed)
return Number.isFinite(n) ? n : null
}
export function clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n))
}
export function parsePressureHpa(value: string): number | null {
const raw = value.trim().replace(/\s*hPa\s*$/i, '')
if (!raw) return null
const n = parseDecimal(raw)
if (n == null) return null
return clamp(Math.round(n), PRESSURE_MIN_HPA, PRESSURE_MAX_HPA)
}
export function formatPressureHpa(hpa: number): string {
return String(clamp(Math.round(hpa), PRESSURE_MIN_HPA, PRESSURE_MAX_HPA))
}
export function parseSeaState(value: string): number | null {
const raw = value.trim()
if (!raw) return null
const n = parseDecimal(raw)
if (n == null) return null
if (!Number.isInteger(n) || n < SEA_STATE_MIN || n > SEA_STATE_MAX) return null
return n
}
export function formatSeaState(level: number): string {
return String(clamp(Math.round(level), SEA_STATE_MIN, SEA_STATE_MAX))
}
export function parseHeelDeg(value: string): number | null {
const raw = value.trim().replace(/°\s*$/, '')
if (!raw) return null
const n = parseDecimal(raw)
if (n == null) return null
return clamp(Math.round(n), HEEL_MIN_DEG, HEEL_MAX_DEG)
}
export function formatHeelDeg(deg: number): string {
return String(clamp(Math.round(deg), HEEL_MIN_DEG, HEEL_MAX_DEG))
}
export function parseVisibilityMeters(value: string): number | null {
const raw = value.trim()
if (!raw) return null
const kmMatch = raw.match(/^([\d.,]+)\s*km$/i)
if (kmMatch) {
const km = parseDecimal(kmMatch[1])
return km == null ? null : Math.round(km * 1000)
}
const mMatch = raw.match(/^([\d.,]+)\s*m$/i)
if (mMatch) {
const m = parseDecimal(mMatch[1])
return m == null ? null : Math.round(m)
}
const bare = parseDecimal(raw)
if (bare == null) return null
return Math.round(bare >= 100 ? bare : bare)
}
export function formatVisibilityMeters(meters: number): string {
if (meters <= 0) return ''
if (meters >= 1000) {
const km = meters / 1000
const rounded = Math.round(km * 10) / 10
return Number.isInteger(rounded) ? `${rounded} km` : `${rounded.toFixed(1)} km`
}
return `${Math.round(meters)} m`
}
export function visibilityStepIndex(meters: number): number {
if (meters <= 0) return 0
let bestIdx = 1
let bestDiff = Math.abs(VISIBILITY_STEPS_M[1] - meters)
for (let i = 2; i < VISIBILITY_STEPS_M.length; i++) {
const diff = Math.abs(VISIBILITY_STEPS_M[i] - meters)
if (diff < bestDiff) {
bestDiff = diff
bestIdx = i
}
}
return bestIdx
}
export function visibilityMetersFromStepIndex(index: number): number {
const i = clamp(Math.round(index), 0, VISIBILITY_STEPS_M.length - 1)
return VISIBILITY_STEPS_M[i]
}
/** Re-export for OWM formatting consistency. */
export { formatOwmVisibilityMeters } from './openWeatherMap.js'
+24
View File
@@ -0,0 +1,24 @@
/** Yield so long tasks can interleave with paint and input handling. */
export function yieldToMain(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0)
})
}
/** Run an async handler over items in batches, yielding between batches. */
export async function forEachInBatches<T>(
items: T[],
batchSize: number,
handler: (item: T) => Promise<void>
): Promise<void> {
if (items.length === 0) return
const size = Math.max(1, batchSize)
for (let i = 0; i < items.length; i += size) {
if (i > 0) await yieldToMain()
const batch = items.slice(i, i + size)
for (const item of batch) {
await handler(item)
}
}
}
+15
View File
@@ -56,6 +56,10 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| Language Changed | Sprache über UI-Wechsler gewählt (`i18nLanguages.ts` via Sprach-Button in App, Dashboard, Auth, Demo, Einladung, Share-Viewer) | `from`, `to`: ISO 639-1 (`de`, `en`, `da`, `sv`, `nb`) |
| Live Log Opened | Live-Journal-Ansicht geladen (`LiveLogView.tsx`, einmal pro Mount nach erfolgreichem Init) | — |
| Live Log Event Logged | Quick-Action erfolgreich ins heutige Journal geschrieben (`LiveLogView.tsx`) | `action`: siehe [Live-Log-Aktionen](#live-log-aktionen) |
| PWA Boot Watchdog Soft | Watchdog erkennt Start-Haenger und versucht sanfte Selbstheilung (`bootstrap-watchdog.js`) | `attempt` (Anzahl Wiederherstellungsversuche), `online` (`true`\|`false`), `reason`: `soft-reload` \| `offline-retry` |
| PWA Boot Watchdog Hard | Watchdog startet harte PWA-Reparatur (SW/Cache) nach erneutem Start-Haenger (`bootstrap-watchdog.js`) | `attempt`, `online`, `reason`: `hard-recovery` |
| PWA Boot Watchdog Fallback | Watchdog zeigt Recovery-UI nach ausgeschoepften Auto-Recovery-Versuchen (`bootstrap-watchdog.js`) | `attempt`, `online`, `reason`: `retries-exhausted` \| `offline-retries-exhausted` |
| PWA Boot Watchdog Manual Repair | Nutzer startet manuelle App-Reparatur im Fallback-Dialog (`bootstrap-watchdog.js`) | `attempt`, `online` |
### Live-Log-Aktionen
@@ -94,6 +98,16 @@ Property `source` bei **OWM Weather Fetched** — ein Event pro erfolgreichem AP
Fehlgeschlagene Abrufe (kein API-Key, Timeout, leere Antwort) lösen **kein** Event aus.
### PWA-Boot-Watchdog-Properties
Properties bei **PWA Boot Watchdog Soft / Hard / Fallback / Manual Repair**:
| Property | Typ | Bedeutung | Erlaubte Werte |
|----------|-----|-----------|----------------|
| `attempt` | number | Laufende Nummer des Recovery-Versuchs im aktuellen Startfenster | `1`, `2`, `3`, ... |
| `online` | boolean | Netzwerkstatus beim Trigger | `true` \| `false` |
| `reason` | string | Grund/Recovery-Pfad (nur bei Soft/Hard/Fallback) | `soft-reload`, `offline-retry`, `hard-recovery`, `retries-exhausted`, `offline-retries-exhausted` |
## Bewusst nicht getrackt
- **Demo-Logbuch:** Beim automatischen Seed (`demoLogbook.ts`) werden keine Events ausgelöst — nur echte Nutzeraktionen zählen.
@@ -122,6 +136,7 @@ Empfohlene Goal-Ketten für Auswertung (nur Business!):
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. `fix`, `course`, `motor_start`) → Live Log Photo Uploaded
11. **OpenWeatherMap:** OWM Weather Fetched (Verteilung `source`; Live-Journal vs. Reisetag-Editor)
12. **PWA-Stabilitaet:** PWA Boot Watchdog Soft → PWA Boot Watchdog Hard → PWA Boot Watchdog Fallback → PWA Boot Watchdog Manual Repair
## Entwicklung
+23
View File
@@ -159,6 +159,29 @@ else
echo "Warning: Docker command not found. Skipping PostgreSQL container management."
fi
# Sync Prisma client and database schema (dev)
sync_prisma_schema() {
local server_dir="$REPO_ROOT/server"
if [ ! -f "$server_dir/prisma/schema.prisma" ]; then
return 0
fi
if [ ! -d "$server_dir/node_modules" ]; then
echo "Warning: server/node_modules missing — skipping Prisma sync. Run: cd server && npm ci"
return 0
fi
echo "Syncing Prisma client and database schema..."
(
cd "$server_dir" || exit 1
npx prisma generate && npx prisma db push
) || {
echo "Error: Prisma generate/db push failed. Check DATABASE_URL in .env and PostgreSQL."
exit 1
}
echo "Prisma schema is in sync."
}
sync_prisma_schema
# Start backend server
echo "Starting backend API server..."
cd "$REPO_ROOT/server" || exit 1
+26
View File
@@ -24,6 +24,7 @@ model User {
notificationPrefs UserNotificationPrefs?
appearancePrefs UserAppearancePrefs?
personPool PersonPayload[]
vesselPool VesselPayload[]
}
model PushSubscription {
@@ -88,6 +89,7 @@ model Logbook {
yachts YachtPayload[]
crews CrewPayload[]
logbookCrewSelection LogbookCrewSelectionPayload?
logbookVesselSelection LogbookVesselSelectionPayload?
deviations DeviationPayload[]
entries EntryPayload[]
photos PhotoPayload[]
@@ -174,6 +176,30 @@ model LogbookCrewSelectionPayload {
logbook Logbook @relation(fields: [logbookId], references: [id], onDelete: Cascade)
}
model VesselPayload {
id String @id @default(uuid())
userId String
payloadId String
encryptedData String
iv String
tag String
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, payloadId])
@@index([userId])
}
model LogbookVesselSelectionPayload {
id String @id @default(uuid())
logbookId String @unique
encryptedData String
iv String
tag String
updatedAt DateTime @updatedAt
logbook Logbook @relation(fields: [logbookId], references: [id], onDelete: Cascade)
}
model DeviationPayload {
id String @id @default(uuid())
logbookId String @unique
+89
View File
@@ -593,6 +593,95 @@ router.post('/person-pool/push', requireUser, async (req: any, res) => {
}
})
router.get('/vessel-pool', requireUser, async (req: any, res) => {
try {
const { hasVesselPoolPrismaModels, isMissingPrismaTable, VESSEL_POOL_MIGRATION_HINT } =
await import('../utils/crewPoolSchema.js')
if (!hasVesselPoolPrismaModels()) {
console.warn('Vessel pool Prisma models missing — run prisma generate')
return res.status(503).json({ error: VESSEL_POOL_MIGRATION_HINT, vessels: [] })
}
const vessels = await prisma.vesselPayload.findMany({
where: { userId: req.userId }
})
return res.json({ vessels })
} catch (error: unknown) {
const { isMissingPrismaTable, VESSEL_POOL_MIGRATION_HINT } = await import('../utils/crewPoolSchema.js')
if (isMissingPrismaTable(error)) {
return res.status(503).json({ error: VESSEL_POOL_MIGRATION_HINT, vessels: [] })
}
return sendInternalError(res, error, 'auth/vessel-pool-get')
}
})
router.post('/vessel-pool/push', requireUser, async (req: any, res) => {
try {
const { hasVesselPoolPrismaModels, isMissingPrismaTable, VESSEL_POOL_MIGRATION_HINT } =
await import('../utils/crewPoolSchema.js')
if (!hasVesselPoolPrismaModels()) {
return res.status(503).json({ error: VESSEL_POOL_MIGRATION_HINT })
}
const { items } = req.body
if (!items || !Array.isArray(items)) {
return res.status(400).json({ error: 'items array is required' })
}
const results: Array<{ payloadId: string; status: string; error?: string; reason?: string }> = []
for (const item of items) {
const { action, payloadId, data, updatedAt } = item
const itemUpdatedAt = new Date(updatedAt)
try {
if (action === 'delete') {
await prisma.vesselPayload.deleteMany({
where: { userId: req.userId, payloadId }
})
results.push({ payloadId, status: 'success' })
continue
}
const parsed = JSON.parse(data)
const encryptedData = parsed.encryptedData || parsed.ciphertext
const { iv, tag } = parsed
const existing = await prisma.vesselPayload.findUnique({
where: { userId_payloadId: { userId: req.userId, payloadId } }
})
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
continue
}
await prisma.vesselPayload.upsert({
where: { userId_payloadId: { userId: req.userId, payloadId } },
create: {
userId: req.userId,
payloadId,
encryptedData,
iv,
tag,
updatedAt: itemUpdatedAt
},
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
})
results.push({ payloadId, status: 'success' })
} catch (err: any) {
results.push({ payloadId, status: 'error', error: err.message || 'Operation failed' })
}
}
return res.json({ results })
} catch (error: unknown) {
const { isMissingPrismaTable, VESSEL_POOL_MIGRATION_HINT } = await import('../utils/crewPoolSchema.js')
if (isMissingPrismaTable(error)) {
return res.status(503).json({ error: VESSEL_POOL_MIGRATION_HINT })
}
return sendInternalError(res, error, 'auth/vessel-pool-push')
}
})
router.get('/profile', requireUser, async (req: any, res) => {
try {
const user = await prisma.user.findUnique({
+5 -3
View File
@@ -77,9 +77,10 @@ router.get('/share-pull', async (req: any, res) => {
const yacht = await prisma.yachtPayload.findUnique({ where: { logbookId } })
const deviation = await prisma.deviationPayload.findUnique({ where: { logbookId } })
const crews = await prisma.crewPayload.findMany({ where: { logbookId } })
const logbookCrewSelection = await prisma.logbookCrewSelectionPayload.findUnique({
where: { logbookId }
})
const { findLogbookCrewSelectionSafe, findLogbookVesselSelectionSafe } =
await import('../utils/crewPoolSchema.js')
const logbookCrewSelection = await findLogbookCrewSelectionSafe(logbookId)
const logbookVesselSelection = await findLogbookVesselSelectionSafe(logbookId)
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
@@ -90,6 +91,7 @@ router.get('/share-pull', async (req: any, res) => {
deviation,
crews,
logbookCrewSelection,
logbookVesselSelection,
entries,
photos,
gpsTracks
+36 -7
View File
@@ -147,6 +147,8 @@ router.post('/push', async (req: any, res) => {
await prisma.gpsTrackPayload.deleteMany({ where: { logbookId, entryId: payloadId } })
} else if (type === 'logbookCrew') {
await prisma.logbookCrewSelectionPayload.deleteMany({ where: { logbookId } })
} else if (type === 'logbookVessel') {
await prisma.logbookVesselSelectionPayload.deleteMany({ where: { logbookId } })
} else {
results.push({ payloadId, status: 'error', error: `Unsupported delete type: ${type}` })
continue
@@ -270,6 +272,35 @@ router.post('/push', async (req: any, res) => {
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
})
}
} else if (type === 'logbookVessel') {
const { hasVesselPoolPrismaModels, isMissingPrismaTable, VESSEL_POOL_MIGRATION_HINT } =
await import('../utils/crewPoolSchema.js')
if (!hasVesselPoolPrismaModels()) {
results.push({
payloadId,
status: 'error',
error: VESSEL_POOL_MIGRATION_HINT
})
continue
}
try {
const existing = await prisma.logbookVesselSelectionPayload.findUnique({ where: { logbookId } })
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
continue
}
await prisma.logbookVesselSelectionPayload.upsert({
where: { logbookId },
create: { logbookId, encryptedData, iv, tag, updatedAt: itemUpdatedAt },
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
})
} catch (err: unknown) {
if (isMissingPrismaTable(err)) {
results.push({ payloadId, status: 'error', error: VESSEL_POOL_MIGRATION_HINT })
continue
}
throw err
}
}
recordCollaboratorChange(
@@ -335,19 +366,17 @@ router.get('/pull', async (req: any, res) => {
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
let logbookCrewSelection = null
const { hasCrewPoolPrismaModels } = await import('../utils/crewPoolSchema.js')
if (hasCrewPoolPrismaModels()) {
logbookCrewSelection = await prisma.logbookCrewSelectionPayload.findUnique({
where: { logbookId }
})
}
const { findLogbookCrewSelectionSafe, findLogbookVesselSelectionSafe } =
await import('../utils/crewPoolSchema.js')
const logbookCrewSelection = await findLogbookCrewSelectionSafe(logbookId)
const logbookVesselSelection = await findLogbookVesselSelectionSafe(logbookId)
return res.json({
yacht,
deviation,
crews,
logbookCrewSelection,
logbookVesselSelection,
entries,
photos,
gpsTracks
+36
View File
@@ -12,9 +12,23 @@ export function hasCrewPoolPrismaModels(): boolean {
)
}
export function hasVesselPoolPrismaModels(): boolean {
const client = prisma as unknown as {
vesselPayload?: { findMany: unknown }
logbookVesselSelectionPayload?: { findUnique: unknown }
}
return (
typeof client.vesselPayload?.findMany === 'function' &&
typeof client.logbookVesselSelectionPayload?.findUnique === 'function'
)
}
export const CREW_POOL_MIGRATION_HINT =
'Crew-Pool-Datenbank fehlt. Im Ordner server ausführen: npx prisma generate && npx prisma db push — danach Server neu starten.'
export const VESSEL_POOL_MIGRATION_HINT =
'Schiffs-Pool-Datenbank fehlt. Im Ordner server ausführen: npx prisma generate && npx prisma db push — danach Server neu starten.'
export function isMissingPrismaTable(error: unknown): boolean {
return (
typeof error === 'object' &&
@@ -23,3 +37,25 @@ export function isMissingPrismaTable(error: unknown): boolean {
(error as { code: string }).code === 'P2021'
)
}
/** Pull-safe: returns null when models or DB tables are missing (P2021). */
export async function findLogbookCrewSelectionSafe(logbookId: string) {
if (!hasCrewPoolPrismaModels()) return null
try {
return await prisma.logbookCrewSelectionPayload.findUnique({ where: { logbookId } })
} catch (error) {
if (isMissingPrismaTable(error)) return null
throw error
}
}
/** Pull-safe: returns null when models or DB tables are missing (P2021). */
export async function findLogbookVesselSelectionSafe(logbookId: string) {
if (!hasVesselPoolPrismaModels()) return null
try {
return await prisma.logbookVesselSelectionPayload.findUnique({ where: { logbookId } })
} catch (error) {
if (isMissingPrismaTable(error)) return null
throw error
}
}