Compare commits

...

83 Commits

Author SHA1 Message Date
elpatron 9e7c6f4397 chore: release v0.1.1.6 2026-06-03 18:51:14 +02:00
elpatron 6600ceafce debug: add verbose console logging and on-screen logs area to LiveVoiceCapture 2026-06-03 18:51:08 +02:00
elpatron d7a497a4a2 chore: release v0.1.1.5 2026-06-03 18:44:56 +02:00
elpatron 4c04086d63 fix: solve audio recording on iOS/Safari and fix Dockerfile health check 2026-06-03 18:44:51 +02:00
elpatron 79ce42bec6 chore: release v0.1.1.4 2026-06-03 18:33:39 +02:00
elpatron 72c956162c fix: resolve 0-second duration issue on WebM voice recordings in Chrome/Android 2026-06-03 18:33:35 +02:00
elpatron 3080b59dc8 chore: release v0.1.1.3 2026-06-03 18:27:00 +02:00
elpatron d054e42cc0 style: add sunset background image to login screen 2026-06-03 18:26:52 +02:00
elpatron d299fc1d93 chore: release v0.1.1.2 2026-06-03 18:23:23 +02:00
elpatron 6447e95d7d fix: defer stopping media stream tracks until media recorder finishes stopping 2026-06-03 18:22:30 +02:00
elpatron 7ec5a1eccc chore: release v0.1.1.1 2026-06-03 18:14:13 +02:00
elpatron 4cf70a3431 style: increase footer and Ko-Fi badge font-size 2026-06-03 18:14:07 +02:00
elpatron 6ed8b2a8e7 chore: release v0.1.1.0 2026-06-03 18:10:37 +02:00
elpatron bff00cf0a3 fix: camera error modal rendering and voice memo player box-sizing 2026-06-03 18:10:20 +02:00
elpatron 3cab735754 refactor: replace parseFloat with parseAppDecimal and formatAppDecimal for improved number handling
Updated various components to utilize parseAppDecimal and formatAppDecimal for consistent decimal parsing and formatting. This change enhances the handling of numeric inputs across the application, ensuring better accuracy and user experience in forms and displays.
2026-06-03 18:07:22 +02:00
elpatron 79762a0baf fix(gps): Genauigkeitsanzeige unter und ab 100 m unterscheiden
Der tote Ternär lieferte in beiden Zweigen dieselbe Rundung; ab 100 m
wird jetzt auf 10 m gerundet, damit schwache Fixes nicht falsch präzise wirken.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 17:57:11 +02:00
elpatron 24160b6c5d feat(gps): klare Fehlerhinweise, Empfangsqualität und Live-Log-Freigabe
Nutzer sehen spezifische Meldungen bei GPS-Problemen, eine Schätzung des
Empfangs aus der Browser-Genauigkeit und beim ersten Live-Log-Besuch nur
dann einen Freigabe-Hinweis, wenn die Standortberechtigung noch offen ist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 17:53:58 +02:00
elpatron 1326045b25 fix(live-log): Fehlermeldung wenn keine Systemkamera vorhanden ist
Prüft videoinput-Geräte beim Öffnen des Foto-Modals und zeigt eine
klare Meldung statt leerem Kamera-UI; getUserMedia-Fehler differenziert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 17:44:11 +02:00
elpatron e014e997de refactor(live-log): Position-Terminologie und Modal-UX vereinheitlichen
Fix/Standort heißen überall Position (__live:position, Legacy __live:fix).
Nachfüll-Buttons + Diesel/+ Wasser, Abbruch statt Nein in Live-Modals.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 17:42:08 +02:00
elpatron 1bc449687d style: add spacing around voice memo player controls
Widen native audio controls and separate remarks column from event actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 17:31:33 +02:00
elpatron 35ee705510 fix: Klammern bei Signatur-Scoring in scoreTodayEntry
Stellt sicher, dass signSkipper und signCrew gemeinsam für den
Punktewert bei der Auswahl des Tageseintrags ausgewertet werden.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 17:30:46 +02:00
elpatron 9f76c200b0 style: highlight delete actions and align event row buttons
Add btn-icon danger styling, strengthen btn-delete and photo delete, and fix events table action layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 17:27:46 +02:00
elpatron ac627a022f fix: ensure only one travel day per calendar date
Serialize Live-log day creation, prune empty duplicates, and use local dates for "today".

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 17:24:51 +02:00
elpatron 9ae24aa6fb fix: allow microphone access for voice memos in PWA
Permissions-Policy blocked getUserMedia; allow microphone on same origin like camera.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 17:18:49 +02:00
elpatron 91cf2674f7 chore: release v0.1.0.111 2026-06-03 15:51:52 +02:00
elpatron b7a9df6ae0 refactor: drop redundant Live Log photo/voice Plausible events
Live-journal uploads are tracked only via Photo Uploaded and Voice Memo Uploaded with context live_log.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 15:40:22 +02:00
elpatron 7bc3c25ba4 feat: add discreet Ko-fi support badge in app footer
Let users support project development and running costs via ko-fi.com/kapteinsdaagbok, with i18n tooltips and Plausible tracking.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 15:33:56 +02:00
elpatron e2fa036b9c chore: release v0.1.0.110 2026-06-03 15:19:07 +02:00
elpatron 89f0f52841 Disable backup export until passphrases match.
The download button stays inactive until both fields agree and meet
the minimum length, so users cannot start export with a mismatch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 15:18:03 +02:00
elpatron 6f28ea0b16 Replace logbook backup v1 JSON with v2 ZIP archives.
ZIP .daagbok files use a compact manifest and binary KDAB blobs so large
photo, voice, and GPS payloads no longer inflate in a single JSON file.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 15:13:51 +02:00
elpatron 975c7a2e40 Add voice memos to live journal and event log.
Record short E2E-encrypted audio attachments from the live log, link them to events via __live:voice markers, and play them back in the stream and chronological event table.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 14:52:12 +02:00
elpatron f83d67b527 Fix TypeScript null check when preserving AI summary on crew save.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 12:51:40 +02:00
elpatron 6c48085904 chore: release v0.1.0.109 2026-06-03 12:50:09 +02:00
elpatron 07de51be22 chore: release v0.1.0.108 2026-06-03 12:42:26 +02:00
elpatron d654aad937 Allow crew to read AI summaries without losing them on save.
Preserve existing summaries when crew edits entries and show a read-only hint in the editor.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 12:42:17 +02:00
elpatron dd111ce01f chore: release v0.1.0.107 2026-06-03 11:50:37 +02:00
elpatron 978e132c70 Remove unused decryptJson import after crew decrypt fix.
Fixes client TypeScript build blocked by pre-deploy checks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:50:01 +02:00
elpatron 1ecebc5dbb chore: release v0.1.0.106 2026-06-03 11:49:23 +02:00
elpatron caf85ad9eb Fix shared logbook access for crew after AI summary sync.
Correct owner detection while the logbook loads, preserve AI summaries on
live-log saves, skip corrupt entry decrypts, and never regenerate keys for
shared logbooks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:48:45 +02:00
elpatron d637fbea16 Show clear offline messages for OWM weather and AI summaries.
Users see localized feedback when OpenWeatherMap or travel-day summary
features are used without connectivity, instead of generic API errors.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:43:10 +02:00
elpatron 8e03563f65 chore: release v0.1.0.105 2026-06-03 11:27:16 +02:00
elpatron 3ac4201734 Add AI travel day summaries via OpenRouter for skippers.
Skipper-only proxy with per-entry rate limiting, encrypted payload storage, CSV export, and Plausible tracking.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:26:19 +02:00
elpatron 85e641ed39 chore: release v0.1.0.104 2026-06-02 22:53:17 +02:00
elpatron 9bf59280b2 Apply strict rate limits to sensitive auth endpoints.
Account deletion, key enrollment, and credential management use a separate 30/15min limiter so they are not left at 300/min while login and sync routes stay independent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 22:52:52 +02:00
elpatron aee8f4f3db chore: release v0.1.0.103 2026-06-02 22:48:22 +02:00
elpatron 2b029a26f0 Fix passkey login 429 by forwarding client IPs correctly.
Forward X-Forwarded-For through frontend nginx, use TRUST_PROXY=1 for the Docker hop, and limit auth rate limiting to login flows only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 22:48:15 +02:00
elpatron 2156aa4bbd chore: release v0.1.0.102 2026-06-02 22:32:16 +02:00
elpatron 5eb4543255 Use native OS time picker on mobile for event times.
EventTimeInput24h switches to input type=time on touch devices while keeping dual selects on desktop for reliable 24h entry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 22:32:03 +02:00
elpatron fb9bb6754c chore: release v0.1.0.101 2026-06-02 22:29:37 +02:00
elpatron 959afd5a63 Make scrollbars wider and more visible on touch devices.
Global theme-aware scrollbar styling replaces the thin 6px event-table bar so long forms are easier to scroll on mobile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 22:29:16 +02:00
elpatron e3ea45f717 chore: release v0.1.0.100 2026-06-02 20:55:03 +02:00
elpatron 8f57b6ff22 Remove diagnostic debug code and backend endpoint 2026-06-02 20:54:58 +02:00
elpatron 60e1b714b7 chore: release v0.1.0.99 2026-06-02 20:45:44 +02:00
elpatron 1e203bfec1 Fix Service Worker evaluation order of precacheAndRoute 2026-06-02 20:45:40 +02:00
elpatron 11420685cf chore: release v0.1.0.98 2026-06-02 20:40:46 +02:00
elpatron c674aac344 Add debug logging for push and Service Worker registration 2026-06-02 20:40:41 +02:00
elpatron 9c91a0f1fc chore: release v0.1.0.97 2026-06-02 20:35:50 +02:00
elpatron 2bcbbba626 Register Service Worker manually on startup 2026-06-02 20:35:46 +02:00
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
133 changed files with 9118 additions and 1171 deletions
+8 -2
View File
@@ -1,5 +1,11 @@
OpenWeatherMapAPIKey=<owm_api_key> OpenWeatherMapAPIKey=<owm_api_key>
# OpenRouter API (AI travel day summaries — server-side proxy)
OpenRouterAPIKey=
# Optional model override (default: anthropic/claude-3.5-haiku)
# Valid examples: anthropic/claude-3.5-haiku, anthropic/claude-3-haiku, anthropic/claude-haiku-4.5
# OpenRouterModel=anthropic/claude-3.5-haiku
# DeepL API (for scripts/translate-locales.mjs and scripts/translate-flyer.mjs) # DeepL API (for scripts/translate-locales.mjs and scripts/translate-flyer.mjs)
# Free plan keys use api-free.deepl.com automatically (suffix :fx) # Free plan keys use api-free.deepl.com automatically (suffix :fx)
DeepLAPIKey= DeepLAPIKey=
@@ -13,8 +19,8 @@ RP_ID=localhost
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost) # Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
ORIGIN=http://localhost:5173 ORIGIN=http://localhost:5173
# Behind Nginx Proxy Manager — see docs/deployment/npm-security.md # Behind reverse proxy — see docs/deployment/npm-security.md
# TRUST_PROXY=172.16.10.10 # Docker Compose (NPM → frontend nginx → backend): TRUST_PROXY=1
# TRUST_PROXY=1 # TRUST_PROXY=1
# Docker Compose database (required for production deploy) # Docker Compose database (required for production deploy)
+1 -1
View File
@@ -1 +1 @@
0.1.0.86 0.1.1.7
+1 -1
View File
@@ -29,4 +29,4 @@ EXPOSE 80
# Health check to verify Nginx is actively running # Health check to verify Nginx is actively running
HEALTHCHECK --interval=30s --timeout=5s --start-period=3s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=3s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1 CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:80/ || exit 1
+1
View File
@@ -22,6 +22,7 @@
<meta name="apple-mobile-web-app-title" content="Daagbok" /> <meta name="apple-mobile-web-app-title" content="Daagbok" />
<meta name="theme-color" content="#0b0c10" /> <meta name="theme-color" content="#0b0c10" />
<script src="/appearance-bootstrap.js"></script> <script src="/appearance-bootstrap.js"></script>
<script src="/bootstrap-watchdog.js"></script>
<link rel="apple-touch-icon" href="/logo.png" /> <link rel="apple-touch-icon" href="/logo.png" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content="Kapteins Daagbok" /> <meta property="og:site_name" content="Kapteins Daagbok" />
+6 -3
View File
@@ -7,7 +7,7 @@ server {
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always; add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
# Service worker and app shell must revalidate so PWA updates are detected # Service worker and app shell must revalidate so PWA updates are detected
@@ -17,7 +17,7 @@ server {
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always; add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
} }
@@ -27,7 +27,7 @@ server {
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always; add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
} }
@@ -43,6 +43,9 @@ server {
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade'; proxy_set_header Connection 'upgrade';
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade; proxy_cache_bypass $http_upgrade;
} }
} }
+1
View File
@@ -12,6 +12,7 @@
"bip39": "^3.1.0", "bip39": "^3.1.0",
"dexie": "^4.4.2", "dexie": "^4.4.2",
"dexie-react-hooks": "^4.4.0", "dexie-react-hooks": "^4.4.0",
"fflate": "^0.8.3",
"i18next": "^26.3.0", "i18next": "^26.3.0",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
+3 -2
View File
@@ -22,15 +22,16 @@
"bip39": "^3.1.0", "bip39": "^3.1.0",
"dexie": "^4.4.2", "dexie": "^4.4.2",
"dexie-react-hooks": "^4.4.0", "dexie-react-hooks": "^4.4.0",
"fflate": "^0.8.3",
"i18next": "^26.3.0", "i18next": "^26.3.0",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"qrcode": "^1.5.4",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-i18next": "^17.0.8", "react-i18next": "^17.0.8"
"qrcode": "^1.5.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
+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()
})()
Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

+422 -54
View File
@@ -36,6 +36,10 @@ code {
min-height: 100svh; min-height: 100svh;
padding: 24px 16px calc(48px + env(safe-area-inset-bottom, 0px)); padding: 24px 16px calc(48px + env(safe-area-inset-bottom, 0px));
box-sizing: border-box; box-sizing: border-box;
background-image: linear-gradient(rgba(15, 23, 42, 0.3), rgba(15, 23, 42, 0.5)), url('/login-bg.jpg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
} }
/* Glassmorphism Auth Card */ /* Glassmorphism Auth Card */
@@ -148,7 +152,8 @@ select.input-text {
width: 100%; width: 100%;
} }
.time-input-24h__select { .time-input-24h__select,
.time-input-24h__native {
flex: 1 1 0; flex: 1 1 0;
min-width: 0; min-width: 0;
padding-left: 12px; padding-left: 12px;
@@ -157,6 +162,11 @@ select.input-text {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
input[type='time'].time-input-24h__native {
color-scheme: inherit;
cursor: pointer;
}
.time-input-24h__sep { .time-input-24h__sep {
flex-shrink: 0; flex-shrink: 0;
font-size: 18px; font-size: 18px;
@@ -1174,12 +1184,29 @@ html.scheme-dark .themed-select-option.is-selected {
color: var(--app-accent-light); color: var(--app-accent-light);
} }
.btn-icon.logout:hover { .btn-icon.danger {
background: rgba(239, 68, 68, 0.1); background: rgba(239, 68, 68, 0.18);
border-color: #ef4444; border-color: rgba(239, 68, 68, 0.35);
color: #f87171; color: #f87171;
} }
.btn-icon.danger:hover {
background: rgba(239, 68, 68, 0.3);
border-color: #ef4444;
color: #fca5a5;
}
.btn-icon.danger:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn-icon.logout:hover {
background: var(--app-accent-bg);
border-color: var(--app-accent);
color: var(--app-accent-light);
}
.dashboard-main { .dashboard-main {
display: grid; display: grid;
grid-template-columns: 350px 1fr; grid-template-columns: 350px 1fr;
@@ -1270,6 +1297,70 @@ html.scheme-dark .themed-select-option.is-selected {
flex-shrink: 0; 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 { .profile-section-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2111,12 +2202,12 @@ html.scheme-dark .themed-select-option.is-selected {
} }
.btn-delete { .btn-delete {
background: none; background: rgba(239, 68, 68, 0.18);
border: none; border: 1px solid rgba(239, 68, 68, 0.35);
color: #475569; color: #f87171;
cursor: pointer; cursor: pointer;
padding: 6px; padding: 6px;
border-radius: 6px; border-radius: 8px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -2132,8 +2223,9 @@ html.scheme-dark .themed-select-option.is-selected {
} }
.btn-delete:hover { .btn-delete:hover {
color: #f43f5e; color: #fca5a5;
background: rgba(244, 63, 94, 0.1); background: rgba(239, 68, 68, 0.3);
border-color: #ef4444;
} }
.btn-pdf { .btn-pdf {
@@ -2583,27 +2675,13 @@ html.scheme-dark .themed-select-option.is-selected {
.events-scroll-container { .events-scroll-container {
width: 100%; width: 100%;
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch;
background: rgba(11, 12, 16, 0.4); background: rgba(11, 12, 16, 0.4);
border: 1px solid rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px; border-radius: 12px;
box-sizing: border-box; box-sizing: border-box;
} }
/* Custom Scrollbar for events container */
.events-scroll-container::-webkit-scrollbar {
height: 6px;
}
.events-scroll-container::-webkit-scrollbar-track {
background: rgba(11, 12, 16, 0.2);
}
.events-scroll-container::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
.events-scroll-container::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
.events-table { .events-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -2628,15 +2706,18 @@ html.scheme-dark .themed-select-option.is-selected {
} }
.events-actions-td { .events-actions-td {
width: 1%;
min-width: 88px;
white-space: nowrap; white-space: nowrap;
vertical-align: middle;
} }
.events-actions-td .btn-icon { .events-actions-cell {
margin-left: 4px; display: inline-flex;
} align-items: center;
justify-content: flex-end;
.events-actions-td .btn-icon:first-child { gap: 8px;
margin-left: 0; flex-wrap: nowrap;
} }
.events-table tbody tr:hover { .events-table tbody tr:hover {
@@ -3116,9 +3197,9 @@ html.theme-cupertino .events-scroll-container {
position: absolute; position: absolute;
top: 8px; top: 8px;
right: 8px; right: 8px;
background: rgba(15, 23, 42, 0.7); background: rgba(239, 68, 68, 0.22);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(239, 68, 68, 0.4);
color: #f43f5e; color: #fca5a5;
border-radius: 50%; border-radius: 50%;
width: 32px; width: 32px;
height: 32px; height: 32px;
@@ -3130,9 +3211,10 @@ html.theme-cupertino .events-scroll-container {
} }
.photo-btn-delete:hover { .photo-btn-delete:hover {
background: #f43f5e; background: rgba(239, 68, 68, 0.45);
border-color: #ef4444;
color: #ffffff; color: #ffffff;
transform: scale(1.1); transform: scale(1.08);
} }
.photo-caption-bar { .photo-caption-bar {
@@ -3414,6 +3496,84 @@ html.theme-cupertino .events-scroll-container {
color: var(--app-text, inherit); color: var(--app-text, inherit);
} }
.live-log-gps-error-modal {
color: var(--app-warning-text, #b45309);
background: rgba(245, 158, 11, 0.12);
border: 1px solid rgba(245, 158, 11, 0.35);
border-radius: 8px;
padding: 8px 10px;
}
.gps-signal-hint {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.45;
}
.gps-signal-hint-label {
display: inline-flex;
align-items: flex-start;
gap: 8px;
}
.gps-signal-icon {
flex-shrink: 0;
margin-top: 2px;
}
.gps-signal-bars {
display: inline-flex;
align-items: flex-end;
gap: 2px;
height: 14px;
flex-shrink: 0;
}
.gps-signal-bar {
width: 4px;
border-radius: 1px;
background: var(--app-border, rgba(148, 163, 184, 0.45));
}
.gps-signal-bar:nth-child(1) { height: 4px; }
.gps-signal-bar:nth-child(2) { height: 7px; }
.gps-signal-bar:nth-child(3) { height: 10px; }
.gps-signal-bar:nth-child(4) { height: 14px; }
.gps-signal-bar.is-active {
background: var(--app-accent-light, #22c55e);
}
.gps-signal-excellent .gps-signal-bar.is-active {
background: #22c55e;
}
.gps-signal-good .gps-signal-bar.is-active {
background: #84cc16;
}
.gps-signal-fair .gps-signal-bar.is-active,
.gps-signal-unknown .gps-signal-bar.is-active {
background: #eab308;
}
.gps-signal-poor .gps-signal-bar.is-active {
background: #f97316;
}
.gps-signal-poor,
.gps-signal-fair {
color: var(--app-warning-text, #b45309);
}
.gps-signal-hint-editor {
margin-top: 6px;
}
.gps-signal-hint-modal {
margin: 0 0 10px;
}
.live-log-layout { .live-log-layout {
display: grid; display: grid;
grid-template-columns: minmax(148px, 200px) 1fr; grid-template-columns: minmax(148px, 200px) 1fr;
@@ -3586,6 +3746,113 @@ html.theme-cupertino .events-scroll-container {
margin-top: 16px; margin-top: 16px;
} }
.live-log-summary-block {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.live-log-summary-block .voice-memo-player-shell {
margin-top: 2px;
}
.live-voice-modal .live-voice-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.live-voice-modal .live-voice-modal-header h3 {
margin: 0;
}
.live-voice-record-btn,
.live-voice-stop-btn {
width: 100%;
justify-content: center;
}
.live-voice-recording-indicator {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 12px;
font-weight: 600;
color: #f87171;
}
.live-voice-recording-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #ef4444;
animation: live-voice-pulse 1s ease-in-out infinite;
}
@keyframes live-voice-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.85); }
}
.live-voice-caption-field {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
font-size: 13px;
}
.voice-memo-player-shell {
display: block;
max-width: 100%;
padding: 2px 4px 2px 0;
box-sizing: border-box;
}
.voice-memo-player {
display: block;
width: 100%;
max-width: 300px;
min-width: 260px;
height: 36px;
box-sizing: border-box;
padding-inline: 2px 12px;
}
.voice-memo-player--compact {
max-width: 280px;
min-width: 240px;
padding-inline-end: 14px;
}
.voice-memo-player-unavailable {
font-size: 12px;
color: #94a3b8;
font-style: italic;
}
.event-remarks-cell {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-start;
}
.event-remarks-cell--voice {
gap: 8px;
}
.events-table .remarks-td {
padding-right: 20px;
}
.events-table .remarks-td + .events-actions-td {
padding-left: 12px;
}
@media (max-width: 720px) { @media (max-width: 720px) {
.live-log-layout { .live-log-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -3680,14 +3947,14 @@ html.theme-cupertino .events-scroll-container {
max-width: min(100%, 420px); max-width: min(100%, 420px);
} }
.live-log-fix-coords { .live-log-position-coords {
margin: 0; margin: 0;
padding: 0; padding: 0;
border: none; border: none;
min-width: 0; min-width: 0;
} }
.live-log-fix-label { .live-log-position-label {
display: block; display: block;
margin: 0 0 10px; margin: 0 0 10px;
padding: 0; padding: 0;
@@ -3696,35 +3963,35 @@ html.theme-cupertino .events-scroll-container {
color: var(--app-text-muted); color: var(--app-text-muted);
} }
.live-log-fix-coords-row { .live-log-position-coords-row {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 10px; gap: 10px;
min-width: 0; min-width: 0;
} }
.live-log-fix-field { .live-log-position-field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
min-width: 0; min-width: 0;
} }
.live-log-fix-field-label { .live-log-position-field-label {
font-size: 12px; font-size: 12px;
color: var(--app-text-muted); color: var(--app-text-muted);
} }
.live-log-fix-field .input-text { .live-log-position-field .input-text {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
.live-log-fix-gps-row { .live-log-position-gps-row {
margin-top: 10px; margin-top: 10px;
} }
.live-log-fix-gps-btn { .live-log-position-gps-btn {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
@@ -3949,15 +4216,23 @@ html.theme-cupertino .events-scroll-container {
min-height: auto !important; min-height: auto !important;
} }
.btn-inline-icon {
width: auto !important;
display: inline-flex !important;
align-items: center;
gap: 6px;
}
.btn.danger { .btn.danger {
background: rgba(239, 68, 68, 0.15) !important; background: rgba(239, 68, 68, 0.2) !important;
color: #fca5a5 !important; color: #fca5a5 !important;
border: 1px solid rgba(239, 68, 68, 0.3) !important; border: 1px solid rgba(239, 68, 68, 0.38) !important;
} }
.btn.danger:hover { .btn.danger:hover {
background: rgba(239, 68, 68, 0.25) !important; background: rgba(239, 68, 68, 0.32) !important;
border-color: #ef4444 !important; border-color: #ef4444 !important;
color: #fecaca !important;
} }
/* Crew Avatar Card Styling */ /* Crew Avatar Card Styling */
@@ -4110,7 +4385,7 @@ html.theme-cupertino .events-scroll-container {
} }
} }
.tank-liter-input .tank-liter-slider { .tank-liter-slider {
--tank-slider-track-h: 10px; --tank-slider-track-h: 10px;
--tank-slider-thumb: 26px; --tank-slider-thumb: 26px;
width: 100%; width: 100%;
@@ -4125,13 +4400,13 @@ html.theme-cupertino .events-scroll-container {
touch-action: none; 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); height: var(--tank-slider-track-h);
border-radius: 999px; border-radius: 999px;
background: rgba(148, 163, 184, 0.35); 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; -webkit-appearance: none;
width: var(--tank-slider-thumb); width: var(--tank-slider-thumb);
height: var(--tank-slider-thumb); height: var(--tank-slider-thumb);
@@ -4142,13 +4417,13 @@ html.theme-cupertino .events-scroll-container {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35); 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); height: var(--tank-slider-track-h);
border-radius: 999px; border-radius: 999px;
background: rgba(148, 163, 184, 0.35); 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); width: var(--tank-slider-thumb);
height: var(--tank-slider-thumb); height: var(--tank-slider-thumb);
border-radius: 50%; border-radius: 50%;
@@ -4157,7 +4432,7 @@ html.theme-cupertino .events-scroll-container {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35); 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; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
@@ -4169,12 +4444,84 @@ html.theme-cupertino .events-scroll-container {
text-align: center; 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) { @media (max-width: 480px) {
.tank-liter-input .tank-liter-slider { .tank-liter-input .tank-liter-slider {
--tank-slider-track-h: 12px; --tank-slider-track-h: 12px;
--tank-slider-thumb: 32px; --tank-slider-thumb: 32px;
margin: 12px 0 8px; 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 { .vessel-tanks-section {
@@ -5230,7 +5577,7 @@ html.theme-cupertino .events-scroll-container {
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; gap: 6px;
padding: 6px 12px calc(6px + env(safe-area-inset-bottom, 0px)); padding: 6px 12px calc(6px + env(safe-area-inset-bottom, 0px));
font-size: 11px; font-size: 13px;
line-height: 1.4; line-height: 1.4;
color: #64748b; color: #64748b;
background: rgba(11, 12, 16, 0.72); background: rgba(11, 12, 16, 0.72);
@@ -5266,6 +5613,27 @@ html.theme-cupertino .events-scroll-container {
text-decoration: underline; text-decoration: underline;
} }
.kofi-footer-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
color: #94a3b8;
text-decoration: none;
background: rgba(255, 94, 91, 0.08);
border: 1px solid rgba(255, 94, 91, 0.18);
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
}
.kofi-footer-badge:hover {
color: #fecaca;
background: rgba(255, 94, 91, 0.14);
border-color: rgba(255, 94, 91, 0.32);
}
.demo-badge { .demo-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+13 -4
View File
@@ -3,9 +3,11 @@ import { DialogProvider } from './components/ModalDialog.tsx'
import AuthOnboarding from './components/AuthOnboarding.tsx' import AuthOnboarding from './components/AuthOnboarding.tsx'
import UserProfilePage from './components/UserProfilePage.tsx' import UserProfilePage from './components/UserProfilePage.tsx'
import LogbookDashboard from './components/LogbookDashboard.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 LogbookCrewPicker from './components/LogbookCrewPicker.tsx'
import { migrateLegacyCrewToPoolIfNeeded } from './services/crewMigration.js' import { migrateLegacyCrewToPoolIfNeeded } from './services/crewMigration.js'
import { migrateLegacyYachtsToPoolIfNeeded } from './services/vesselMigration.js'
import { syncVesselPool } from './services/vesselPoolSync.js'
import { syncPersonPool } from './services/personPoolSync.js' import { syncPersonPool } from './services/personPoolSync.js'
// Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert (Komponente bleibt erhalten) // Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert (Komponente bleibt erhalten)
// import DeviationForm from './components/DeviationForm.tsx' // import DeviationForm from './components/DeviationForm.tsx'
@@ -101,7 +103,7 @@ function App() {
[activeLogbookId] [activeLogbookId]
) )
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>('OWNER') const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>(null)
useEffect(() => { useEffect(() => {
if (!activeLogbookId) { if (!activeLogbookId) {
@@ -164,6 +166,7 @@ function App() {
if (!userId) return if (!userId) return
void syncAppearancePrefs(userId) void syncAppearancePrefs(userId)
void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool()) void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool())
void migrateLegacyYachtsToPoolIfNeeded().then(() => syncVesselPool())
}, [isAuthenticated]) }, [isAuthenticated])
useEffect(() => { useEffect(() => {
@@ -571,7 +574,8 @@ function App() {
const logbookReadOnly = const logbookReadOnly =
activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ' activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ'
const isLogbookOwner = const isLogbookOwner =
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1 activeAccessRole === 'OWNER' ||
(activeLogbookRecord != null && activeLogbookRecord.isShared !== 1)
if (showUserProfile) { if (showUserProfile) {
return ( return (
@@ -751,7 +755,12 @@ function App() {
)} )}
{activeTab === 'vessel' && ( {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' && ( {activeTab === 'crew' && (
+20
View File
@@ -1,8 +1,13 @@
import { Coffee } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev' const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'
const KOFI_URL = 'https://ko-fi.com/kapteinsdaagbok'
export default function AppFooter() { export default function AppFooter() {
const { t } = useTranslation()
return ( return (
<footer className="app-version-footer"> <footer className="app-version-footer">
<span className="app-version-footer__version">v{APP_VERSION}</span> <span className="app-version-footer__version">v{APP_VERSION}</span>
@@ -18,6 +23,21 @@ export default function AppFooter() {
Markus F.J. Busche Markus F.J. Busche
</a> </a>
</span> </span>
<span className="app-version-footer__sep" aria-hidden="true">
·
</span>
<a
className="kofi-footer-badge"
href={KOFI_URL}
target="_blank"
rel="noopener noreferrer"
title={t('footer.kofi_title')}
aria-label={t('footer.kofi_title')}
onClick={() => trackPlausibleEvent(PlausibleEvents.KOFI_LINK_CLICKED)}
>
<Coffee size={14} aria-hidden="true" />
<span>{t('footer.kofi_label')}</span>
</a>
</footer> </footer>
) )
} }
+1 -1
View File
@@ -818,7 +818,7 @@ export default function CrewForm({
<button className="btn-icon" onClick={() => openEditMember(m)} title="Edit"> <button className="btn-icon" onClick={() => openEditMember(m)} title="Edit">
<Edit2 size={14} /> <Edit2 size={14} />
</button> </button>
<button className="btn-icon logout" onClick={() => handleDeleteMember(m.payloadId)} title="Delete"> <button className="btn-icon danger" onClick={() => handleDeleteMember(m.payloadId)} title="Delete">
<Trash2 size={14} /> <Trash2 size={14} />
</button> </button>
</div> </div>
+25 -4
View File
@@ -1,13 +1,15 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js' import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import VesselForm from './VesselForm.tsx' import LogbookVesselPicker from './LogbookVesselPicker.tsx'
import LogbookCrewPicker from './LogbookCrewPicker.tsx' import LogbookCrewPicker from './LogbookCrewPicker.tsx'
import type { LogbookCrewSelectionData } from '../types/person.js' import type { LogbookCrewSelectionData } from '../types/person.js'
import { personToSnapshot } from '../utils/personSnapshots.js' import { personToSnapshot } from '../utils/personSnapshots.js'
import LogEntriesList from './LogEntriesList.tsx' import LogEntriesList from './LogEntriesList.tsx'
import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react' import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react'
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js' 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 { useAppTour, type AppTab } from '../context/AppTourContext.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
@@ -54,8 +56,18 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
cycleAppLanguage(i18n) cycleAppLanguage(i18n)
} }
const { title, yacht, personPool, logbookCrewSelection, entries, gpsTracks, photos, firstEntryId } = const {
fixture title,
yacht,
vesselPool,
logbookVesselSelection,
personPool,
logbookCrewSelection,
entries,
gpsTracks,
photos,
firstEntryId
} = fixture
const demoSelection: LogbookCrewSelectionData = { const demoSelection: LogbookCrewSelectionData = {
activeSkipperId: logbookCrewSelection.activeSkipperId, activeSkipperId: logbookCrewSelection.activeSkipperId,
@@ -144,6 +156,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
preloadedYacht={yacht} preloadedYacht={yacht}
preloadedEntries={entries} preloadedEntries={entries}
preloadedPhotos={photos} preloadedPhotos={photos}
preloadedVoiceMemos={[]}
preloadedGpsTracks={gpsTracks} preloadedGpsTracks={gpsTracks}
controlledSelectedEntryId={tourSelectedEntryId} controlledSelectedEntryId={tourSelectedEntryId}
onSelectedEntryIdChange={setTourSelectedEntryId} onSelectedEntryIdChange={setTourSelectedEntryId}
@@ -152,7 +165,15 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
)} )}
{activeTab === 'vessel' && ( {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' && ( {activeTab === 'crew' && (
+3 -2
View File
@@ -6,6 +6,7 @@ import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js' import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js' import { syncLogbook } from '../services/sync.js'
import { Compass, Save, Check } from 'lucide-react' import { Compass, Save, Check } from 'lucide-react'
import { parseAppDecimalOrZero } from '../utils/numberFormat.js'
interface DeviationFormProps { interface DeviationFormProps {
logbookId: string logbookId: string
@@ -97,8 +98,8 @@ export default function DeviationForm({ logbookId, readOnly = false, preloadedDa
const sanitizedDeviations: Record<number, number> = {} const sanitizedDeviations: Record<number, number> = {}
headings.forEach((h) => { headings.forEach((h) => {
const val = deviations[h] || '' const val = deviations[h] || ''
const parsed = parseFloat(val.replace('+', '').trim()) const parsed = parseAppDecimalOrZero(val.replace('+', '').trim())
sanitizedDeviations[h] = isNaN(parsed) ? 0 : parsed sanitizedDeviations[h] = parsed
}) })
const dataToSave = { const dataToSave = {
@@ -0,0 +1,40 @@
import { useTranslation } from 'react-i18next'
import type { LogEventPayload } from '../utils/logEntryPayload.js'
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
import { formatEventSummary } from '../utils/formatEventSummary.js'
import VoiceMemoPlayer, { type PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx'
interface EventRemarksCellProps {
event: LogEventPayload
logbookId: string
voiceMemoLookup?: Map<string, PreloadedVoiceMemo>
}
export default function EventRemarksCell({
event,
logbookId,
voiceMemoLookup
}: EventRemarksCellProps) {
const { t } = useTranslation()
const voiceId = parseLiveVoiceRemark(event.remarks.trim())
const preloaded = voiceId ? voiceMemoLookup?.get(voiceId) : undefined
let summary = formatEventSummary(event, t)
if (voiceId && preloaded?.caption) {
summary = t('logs.live_voice_entry', { caption: preloaded.caption })
}
return (
<div className={`event-remarks-cell${voiceId ? ' event-remarks-cell--voice' : ''}`}>
<span>{summary}</span>
{voiceId && (
<VoiceMemoPlayer
audioId={voiceId}
logbookId={logbookId}
preloaded={preloaded}
compact
/>
)}
</div>
)
}
@@ -1,5 +1,6 @@
import { useId, useMemo } from 'react' import { useId, useMemo } from 'react'
import { joinTimeHHMM, splitTimeHHMM } from '../utils/logEntryPayload.js' import { joinTimeHHMM, splitTimeHHMM } from '../utils/logEntryPayload.js'
import { preferNativeCameraPicker } from '../utils/captureVideoFrame.js'
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0')) const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'))
const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0')) const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'))
@@ -18,7 +19,29 @@ export default function EventTimeInput24h({
'aria-label': ariaLabel 'aria-label': ariaLabel
}: EventTimeInput24hProps) { }: EventTimeInput24hProps) {
const baseId = useId() const baseId = useId()
const useNativePicker = preferNativeCameraPicker()
const { hours, minutes } = useMemo(() => splitTimeHHMM(value), [value]) const { hours, minutes } = useMemo(() => splitTimeHHMM(value), [value])
const timeValue = useMemo(() => joinTimeHHMM(hours, minutes), [hours, minutes])
if (useNativePicker) {
return (
<div className="time-input-24h">
<input
id={baseId}
type="time"
step={60}
className="input-text time-input-24h__native"
value={timeValue}
onChange={(e) => {
const next = e.target.value
if (next) onChange(next.slice(0, 5))
}}
disabled={disabled}
aria-label={ariaLabel}
/>
</div>
)
}
return ( return (
<div className="time-input-24h"> <div className="time-input-24h">
+47
View File
@@ -0,0 +1,47 @@
import { useTranslation } from 'react-i18next'
import { Signal } from 'lucide-react'
import {
formatGpsAccuracyMeters,
gpsQualityI18nKey,
type GpsSignalQuality
} from '../utils/geolocation.js'
const SIGNAL_BARS: Record<GpsSignalQuality, number> = {
excellent: 4,
good: 3,
fair: 2,
poor: 1,
unknown: 0
}
interface GpsSignalHintProps {
quality: GpsSignalQuality
accuracyM: number | null
className?: string
}
export default function GpsSignalHint({ quality, accuracyM, className = '' }: GpsSignalHintProps) {
const { t } = useTranslation()
const bars = SIGNAL_BARS[quality]
const i18nParams = accuracyM != null ? { accuracy: formatGpsAccuracyMeters(accuracyM) } : undefined
return (
<p
className={`gps-signal-hint gps-signal-${quality} ${className}`.trim()}
role="status"
>
<span className="gps-signal-hint-label">
<Signal size={14} aria-hidden className="gps-signal-icon" />
<span className="gps-signal-bars" aria-hidden>
{[1, 2, 3, 4].map((level) => (
<span
key={level}
className={`gps-signal-bar ${level <= bars ? 'is-active' : ''}`}
/>
))}
</span>
<span>{t(gpsQualityI18nKey(quality), i18nParams)}</span>
</span>
</p>
)
}
+45 -15
View File
@@ -1,6 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Camera, X } from 'lucide-react' import { Camera, X } from 'lucide-react'
import {
cameraErrorKeyFromDomException,
probeCameraAvailability
} from '../utils/cameraAvailability.js'
import { import {
captureVideoFrame, captureVideoFrame,
preferNativeCameraPicker preferNativeCameraPicker
@@ -15,7 +19,7 @@ interface LiveCameraCaptureProps {
onCapture: (blob: Blob) => void onCapture: (blob: Blob) => void
} }
type Phase = 'live' | 'preview' | 'native' type Phase = 'checking' | 'live' | 'preview' | 'native'
export default function LiveCameraCapture({ export default function LiveCameraCapture({
open, open,
@@ -34,7 +38,7 @@ export default function LiveCameraCapture({
const [cameraError, setCameraError] = useState<string | null>(null) const [cameraError, setCameraError] = useState<string | null>(null)
const [ready, setReady] = useState(false) const [ready, setReady] = useState(false)
const [capturing, setCapturing] = useState(false) const [capturing, setCapturing] = useState(false)
const [phase, setPhase] = useState<Phase>(() => (preferNativeCameraPicker() ? 'native' : 'live')) const [phase, setPhase] = useState<Phase>('checking')
const [previewUrl, setPreviewUrl] = useState<string | null>(null) const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null) const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
const [streamGeneration, setStreamGeneration] = useState(0) const [streamGeneration, setStreamGeneration] = useState(0)
@@ -87,12 +91,37 @@ export default function LiveCameraCapture({
clearPreview() clearPreview()
setCameraError(null) setCameraError(null)
setCapturing(false) setCapturing(false)
setPhase(preferNativeCameraPicker() ? 'native' : 'live') setPhase('checking')
return return
} }
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
let cancelled = false
clearPreview() clearPreview()
}, [open, stopStream, clearPreview]) setCameraError(null)
setCapturing(false)
setPhase('checking')
const probe = async () => {
const availability = await probeCameraAvailability()
if (cancelled) return
if (availability === 'unsupported') {
setCameraError(t('logs.live_photo_camera_unavailable'))
return
}
if (availability === 'none') {
setCameraError(t('logs.live_photo_no_camera'))
return
}
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
}
void probe()
return () => {
cancelled = true
}
}, [open, clearPreview, stopStream, t])
useEffect(() => { useEffect(() => {
if (!open || phase !== 'live') { if (!open || phase !== 'live') {
@@ -105,11 +134,6 @@ export default function LiveCameraCapture({
const start = async () => { const start = async () => {
setCameraError(null) setCameraError(null)
setReady(false) setReady(false)
if (!navigator.mediaDevices?.getUserMedia) {
setCameraError(t('logs.live_photo_camera_unavailable'))
return
}
try { try {
const stream = await navigator.mediaDevices.getUserMedia({ const stream = await navigator.mediaDevices.getUserMedia({
video: { video: {
@@ -141,7 +165,7 @@ export default function LiveCameraCapture({
} catch (err) { } catch (err) {
console.error('Camera access failed:', err) console.error('Camera access failed:', err)
if (!cancelled) { if (!cancelled) {
setCameraError(t('logs.live_photo_camera_denied')) setCameraError(t(cameraErrorKeyFromDomException(err)))
} }
} }
} }
@@ -216,7 +240,7 @@ export default function LiveCameraCapture({
className="btn secondary live-camera-close" className="btn secondary live-camera-close"
onClick={onClose} onClick={onClose}
disabled={busy} disabled={busy}
aria-label={t('logs.confirm_no')} aria-label={t('logs.live_cancel')}
> >
<X size={18} /> <X size={18} />
</button> </button>
@@ -243,6 +267,12 @@ export default function LiveCameraCapture({
className="live-camera-preview live-camera-preview-still" className="live-camera-preview live-camera-preview-still"
/> />
</div> </div>
) : cameraError ? (
<div className="live-camera-preview-wrap">
<p className="live-camera-loading">{cameraError}</p>
</div>
) : phase === 'checking' ? (
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
) : phase === 'native' ? ( ) : phase === 'native' ? (
<div className="live-camera-native-prompt"> <div className="live-camera-native-prompt">
<p className="live-log-modal-hint">{t('logs.live_photo_native_hint')}</p> <p className="live-log-modal-hint">{t('logs.live_photo_native_hint')}</p>
@@ -256,7 +286,7 @@ export default function LiveCameraCapture({
{t('logs.live_photo_open_camera_btn')} {t('logs.live_photo_open_camera_btn')}
</button> </button>
</div> </div>
) : cameraError && !ready ? null : ( ) : phase === 'live' ? (
<div className="live-camera-preview-wrap"> <div className="live-camera-preview-wrap">
<video <video
ref={videoRef} ref={videoRef}
@@ -269,7 +299,7 @@ export default function LiveCameraCapture({
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p> <p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
)} )}
</div> </div>
)} ) : null}
{onCaptionChange && ( {onCaptionChange && (
<div className="input-group live-camera-caption"> <div className="input-group live-camera-caption">
@@ -287,7 +317,7 @@ export default function LiveCameraCapture({
<div className="live-log-modal-actions live-camera-actions"> <div className="live-log-modal-actions live-camera-actions">
<button type="button" className="btn secondary" onClick={onClose} disabled={busy}> <button type="button" className="btn secondary" onClick={onClose} disabled={busy}>
{t('logs.confirm_no')} {t('logs.live_cancel')}
</button> </button>
{showPreview ? ( {showPreview ? (
+353 -127
View File
@@ -15,14 +15,12 @@ import {
MapPin, MapPin,
MessageSquare, MessageSquare,
Camera, Camera,
Mic,
Radio, Radio,
Sailboat, Sailboat,
Undo2, Undo2,
Zap Zap
} from 'lucide-react' } 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 { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { import {
appendQuickEvent, appendQuickEvent,
@@ -35,14 +33,16 @@ import {
import { formatEventSummary } from '../utils/formatEventSummary.js' import { formatEventSummary } from '../utils/formatEventSummary.js'
import { import {
getLastAutoPositionMs, getLastAutoPositionMs,
getLastPositionFixWithin, getLastLoggedPositionWithin,
getLatestPositionFix, getLatestLoggedPosition,
isMotorRunningFromEvents, isMotorRunningFromEvents,
LIVE_EVENT_CODES, LIVE_EVENT_CODES,
LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS,
liveCommentRemark, liveCommentRemark,
liveFuelRemark, liveFuelRemark,
livePhotoRemark, livePhotoRemark,
liveVoiceRemark,
parseLiveVoiceRemark,
livePrecipRemark, livePrecipRemark,
liveSailsRemark, liveSailsRemark,
liveSogRemark, liveSogRemark,
@@ -50,12 +50,22 @@ import {
liveTempRemark, liveTempRemark,
liveWaterRemark liveWaterRemark
} from '../utils/liveEventCodes.js' } from '../utils/liveEventCodes.js'
import { formatAppDecimal, formatTankLiters, parseAppDecimal } from '../utils/numberFormat.js'
const formatSpeedKn = (speedKn: number) =>
formatAppDecimal(speedKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js' import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js' import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
import { import {
geolocationErrorI18nKey,
getCurrentPosition, getCurrentPosition,
getGeolocationErrorReason,
hasSeenGeolocationLiveIntro,
markGeolocationLiveIntroSeen,
normalizeGpsCoordinates, normalizeGpsCoordinates,
queryGeolocationPermission queryGeolocationPermission,
type GeolocationErrorReason,
type GpsSignalQuality
} from '../utils/geolocation.js' } from '../utils/geolocation.js'
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js' import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
import { import {
@@ -66,9 +76,15 @@ import {
} from '../utils/sailSelection.js' } from '../utils/sailSelection.js'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import CourseDialInput from './CourseDialInput.tsx' import CourseDialInput from './CourseDialInput.tsx'
import GpsSignalHint from './GpsSignalHint.tsx'
import LiveCameraCapture from './LiveCameraCapture.tsx' import LiveCameraCapture from './LiveCameraCapture.tsx'
import LiveVoiceCapture from './LiveVoiceCapture.tsx'
import VoiceMemoPlayer from './VoiceMemoPlayer.tsx'
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js' import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
import { saveEntryVoiceMemo, deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js' import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
import { blobToAudioDataUrl } from '../utils/audioBlob.js'
import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js'
interface LiveLogViewProps { interface LiveLogViewProps {
logbookId: string logbookId: string
@@ -85,13 +101,15 @@ type LiveModal =
| 'temp' | 'temp'
| 'precip' | 'precip'
| 'sea_state' | 'sea_state'
| 'visibility'
| 'course' | 'course'
| 'fuel' | 'fuel'
| 'water' | 'water'
| 'sog' | 'sog'
| 'stw' | 'stw'
| 'fix' | 'position'
| 'photo' | 'photo'
| 'voice'
const AUTO_POSITION_INTERVAL_MS = 3 * 60 * 60 * 1000 const AUTO_POSITION_INTERVAL_MS = 3 * 60 * 60 * 1000
const AUTO_POSITION_CHECK_MS = 60_000 const AUTO_POSITION_CHECK_MS = 60_000
@@ -135,13 +153,21 @@ function lastWindDirectionFromEvents(events: LogEventPayload[]): string {
return '' return ''
} }
function gpsFailureAlertBody(
t: (key: string) => string,
reason: GeolocationErrorReason
): string {
return `${t(geolocationErrorI18nKey(reason))}\n\n${t('logs.live_position_manual_hint')}`
}
export default function LiveLogView({ export default function LiveLogView({
logbookId, logbookId,
onOpenEditor, onOpenEditor,
onSwitchToList onSwitchToList
}: LiveLogViewProps) { }: LiveLogViewProps) {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const { showAlert } = useDialog() const { showAlert, showConfirm } = useDialog()
const [geolocationAccessEpoch, setGeolocationAccessEpoch] = useState(0)
const [entryId, setEntryId] = useState<string | null>(null) const [entryId, setEntryId] = useState<string | null>(null)
const [dayOfTravel, setDayOfTravel] = useState('') const [dayOfTravel, setDayOfTravel] = useState('')
@@ -154,21 +180,30 @@ export default function LiveLogView({
const [modal, setModal] = useState<LiveModal>('none') const [modal, setModal] = useState<LiveModal>('none')
const [weatherExpanded, setWeatherExpanded] = useState(false) const [weatherExpanded, setWeatherExpanded] = useState(false)
const [weatherOwmLoading, setWeatherOwmLoading] = useState(false) const [weatherOwmLoading, setWeatherOwmLoading] = useState(false)
const [isOnline, setIsOnline] = useState(navigator.onLine)
const [commentText, setCommentText] = useState('') const [commentText, setCommentText] = useState('')
const [valueInput, setValueInput] = useState('') const [valueInput, setValueInput] = useState('')
const [valueInputSecondary, setValueInputSecondary] = useState('') const [valueInputSecondary, setValueInputSecondary] = useState('')
const [selectedSails, setSelectedSails] = useState<string[]>([]) const [selectedSails, setSelectedSails] = useState<string[]>([])
const [undoVisible, setUndoVisible] = useState(false) const [undoVisible, setUndoVisible] = useState(false)
const [fixLat, setFixLat] = useState('') const [positionLat, setPositionLat] = useState('')
const [fixLng, setFixLng] = useState('') const [positionLng, setPositionLng] = useState('')
const [fixGpsLoading, setFixGpsLoading] = useState(false) const [positionGpsLoading, setPositionGpsLoading] = useState(false)
const [fixGpsUnavailable, setFixGpsUnavailable] = useState(false) const [positionGpsUnavailable, setPositionGpsUnavailable] = useState(false)
const [positionGpsErrorReason, setPositionGpsErrorReason] = useState<GeolocationErrorReason | null>(null)
const [positionGpsSignal, setPositionGpsSignal] = useState<{
quality: GpsSignalQuality
accuracyM: number | null
} | null>(null)
const [photoCaption, setPhotoCaption] = useState('') const [photoCaption, setPhotoCaption] = useState('')
const [photoSaving, setPhotoSaving] = useState(false) const [photoSaving, setPhotoSaving] = useState(false)
const [undoHint, setUndoHint] = useState<'event' | 'photo'>('event') const [voiceCaption, setVoiceCaption] = useState('')
const [voiceSaving, setVoiceSaving] = useState(false)
const [undoHint, setUndoHint] = useState<'event' | 'photo' | 'voice'>('event')
const streamEndRef = useRef<HTMLDivElement | null>(null) const streamEndRef = useRef<HTMLDivElement | null>(null)
const undoPhotoIdRef = useRef<string | null>(null) const undoPhotoIdRef = useRef<string | null>(null)
const undoVoiceIdRef = useRef<string | null>(null)
const undoTimerRef = useRef<number | null>(null) const undoTimerRef = useRef<number | null>(null)
const autoPositionBusyRef = useRef(false) const autoPositionBusyRef = useRef(false)
const busyRef = useRef(busy) const busyRef = useRef(busy)
@@ -191,10 +226,11 @@ export default function LiveLogView({
) )
const motorRunning = isMotorRunningFromEvents(events) const motorRunning = isMotorRunningFromEvents(events)
const motorLabel = t('logs.motor_propulsion') const motorLabel = t('logs.motor_propulsion')
const hasPositionFix = useMemo( const hasLoggedPosition = useMemo(
() => (date ? getLatestPositionFix(events, date) != null : false), () => (date ? getLatestLoggedPosition(events, date) != null : false),
[events, date] [events, date]
) )
const voiceMemoLookup = useEntryVoiceMemos(logbookId, entryId)
const applyLoadedEntry = useCallback((loaded: NonNullable<Awaited<ReturnType<typeof loadEntry>>>) => { const applyLoadedEntry = useCallback((loaded: NonNullable<Awaited<ReturnType<typeof loadEntry>>>) => {
const entryEvents = (loaded.data.events as LogEventPayload[]) || [] const entryEvents = (loaded.data.events as LogEventPayload[]) || []
@@ -209,7 +245,7 @@ export default function LiveLogView({
applyLoadedEntry(loaded) applyLoadedEntry(loaded)
}, [logbookId, applyLoadedEntry]) }, [logbookId, applyLoadedEntry])
const showUndo = useCallback((hint: 'event' | 'photo' = 'event') => { const showUndo = useCallback((hint: 'event' | 'photo' | 'voice' = 'event') => {
setUndoHint(hint) setUndoHint(hint)
setUndoVisible(true) setUndoVisible(true)
if (undoTimerRef.current) window.clearTimeout(undoTimerRef.current) if (undoTimerRef.current) window.clearTimeout(undoTimerRef.current)
@@ -217,6 +253,7 @@ export default function LiveLogView({
setUndoVisible(false) setUndoVisible(false)
undoTimerRef.current = null undoTimerRef.current = null
undoPhotoIdRef.current = null undoPhotoIdRef.current = null
undoVoiceIdRef.current = null
}, UNDO_TIMEOUT_MS) }, UNDO_TIMEOUT_MS)
}, []) }, [])
@@ -243,24 +280,14 @@ export default function LiveLogView({
if (seq !== initSeqRef.current) return if (seq !== initSeqRef.current) return
setEntryId(id) setEntryId(id)
const logbookKey = await getLogbookKey(logbookId) try {
if (logbookKey) { const { resolveVesselForLogbook } = await import('../services/resolveVessel.js')
const yacht = await db.yachts.get(logbookId) const vessel = await resolveVesselForLogbook(logbookId)
if (yacht) { if (vessel?.sails && Array.isArray(vessel.sails)) {
try { setYachtSails(vessel.sails)
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
}
} }
} catch {
// Vessel profile optional for live log
} }
const loaded = await loadEntry(logbookId, id) const loaded = await loadEntry(logbookId, id)
@@ -281,6 +308,17 @@ export default function LiveLogView({
} }
}, [logbookId, applyLoadedEntry, t]) }, [logbookId, applyLoadedEntry, t])
useEffect(() => {
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
useEffect(() => { useEffect(() => {
void runInit() void runInit()
return () => { return () => {
@@ -296,6 +334,56 @@ export default function LiveLogView({
} }
}, [loading, entryId]) }, [loading, entryId])
useEffect(() => {
if (loading || !entryId || !navigator.geolocation) return
let cancelled = false
void (async () => {
const permission = await queryGeolocationPermission()
if (cancelled) return
if (permission === 'granted') {
markGeolocationLiveIntroSeen()
setGeolocationAccessEpoch((n) => n + 1)
return
}
// Only ask when the browser has not granted location yet (state "prompt").
if (permission !== 'prompt' || hasSeenGeolocationLiveIntro()) return
const allow = await showConfirm(
t('logs.gps_live_intro_body'),
t('logs.gps_live_intro_title'),
t('logs.gps_live_intro_allow'),
t('logs.gps_live_intro_later')
)
markGeolocationLiveIntroSeen()
if (cancelled || !allow) return
try {
await getCurrentPosition({
timeoutMs: 15_000,
enableHighAccuracy: false,
maximumAge: 0
})
if (!cancelled) setGeolocationAccessEpoch((n) => n + 1)
} catch (err) {
const reason = getGeolocationErrorReason(err)
if (reason === 'permission_denied') {
await showAlert(
`${t('logs.gps_permission_denied')}\n\n${t('logs.gps_enable_in_settings_hint')}`,
t('logs.live_title')
)
}
}
})()
return () => {
cancelled = true
}
}, [loading, entryId, showAlert, showConfirm, t])
useEffect(() => { useEffect(() => {
streamEndRef.current?.scrollIntoView({ behavior: 'smooth' }) streamEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [events.length]) }, [events.length])
@@ -344,7 +432,7 @@ export default function LiveLogView({
}) })
await refreshEntry(entryId) await refreshEntry(entryId)
} catch { } catch {
// Best-effort; hint banner shows when no position fix exists yet. // Best-effort; hint banner shows when no position has been logged yet.
} finally { } finally {
autoPositionBusyRef.current = false autoPositionBusyRef.current = false
} }
@@ -363,7 +451,7 @@ export default function LiveLogView({
if (startTimer !== undefined) window.clearTimeout(startTimer) if (startTimer !== undefined) window.clearTimeout(startTimer)
if (intervalRef !== undefined) window.clearInterval(intervalRef) if (intervalRef !== undefined) window.clearInterval(intervalRef)
} }
}, [entryId, loading, logbookId, refreshEntry]) }, [entryId, loading, logbookId, refreshEntry, geolocationAccessEpoch])
const runQuickAction = async ( const runQuickAction = async (
action: () => Promise<boolean | void>, action: () => Promise<boolean | void>,
@@ -439,16 +527,26 @@ export default function LiveLogView({
}, 'moor') }, 'moor')
} }
const openFixModal = async () => { const reportPositionGpsFailure = async (reason: GeolocationErrorReason) => {
setFixLat('') setPositionGpsUnavailable(true)
setFixLng('') setPositionGpsErrorReason(reason)
setFixGpsUnavailable(false) setPositionGpsSignal(null)
setFixGpsLoading(true) await showAlert(gpsFailureAlertBody(t, reason), t('logs.live_position'))
setModal('fix') }
const openPositionModal = async () => {
setPositionLat('')
setPositionLng('')
setPositionGpsUnavailable(false)
setPositionGpsErrorReason(null)
setPositionGpsSignal(null)
setPositionGpsLoading(true)
setModal('position')
try { try {
const permission = await queryGeolocationPermission() const permission = await queryGeolocationPermission()
if (permission !== 'granted') { if (permission !== 'granted') {
setFixGpsUnavailable(true) const reason = permission === 'denied' ? 'permission_denied' : 'unavailable'
await reportPositionGpsFailure(reason)
return return
} }
const coords = await getCurrentPosition({ const coords = await getCurrentPosition({
@@ -456,26 +554,26 @@ export default function LiveLogView({
enableHighAccuracy: false, enableHighAccuracy: false,
maximumAge: 60_000 maximumAge: 60_000
}) })
setFixLat(coords.lat) setPositionLat(coords.lat)
setFixLng(coords.lng) setPositionLng(coords.lng)
} catch { setPositionGpsSignal({ quality: coords.signalQuality, accuracyM: coords.accuracyM })
setFixGpsUnavailable(true) } catch (err) {
await reportPositionGpsFailure(getGeolocationErrorReason(err))
} finally { } finally {
setFixGpsLoading(false) setPositionGpsLoading(false)
} }
} }
const retryFixGps = async () => { const retryPositionGps = async () => {
setFixGpsLoading(true) setPositionGpsLoading(true)
setFixGpsUnavailable(false) setPositionGpsUnavailable(false)
setPositionGpsErrorReason(null)
setPositionGpsSignal(null)
try { try {
const permission = await queryGeolocationPermission() const permission = await queryGeolocationPermission()
if (permission !== 'granted') { if (permission !== 'granted') {
setFixGpsUnavailable(true) const reason = permission === 'denied' ? 'permission_denied' : 'unavailable'
await showAlert( await reportPositionGpsFailure(reason)
`${t('logs.live_gps_error')}\n\n${t('logs.live_gps_start_hint')}`,
t('logs.live_fix')
)
return return
} }
const coords = await getCurrentPosition({ const coords = await getCurrentPosition({
@@ -483,23 +581,21 @@ export default function LiveLogView({
enableHighAccuracy: false, enableHighAccuracy: false,
maximumAge: 60_000 maximumAge: 60_000
}) })
setFixLat(coords.lat) setPositionLat(coords.lat)
setFixLng(coords.lng) setPositionLng(coords.lng)
} catch { setPositionGpsUnavailable(false)
setFixGpsUnavailable(true) setPositionGpsSignal({ quality: coords.signalQuality, accuracyM: coords.accuracyM })
await showAlert( } catch (err) {
`${t('logs.live_gps_error')}\n\n${t('logs.live_gps_start_hint')}`, await reportPositionGpsFailure(getGeolocationErrorReason(err))
t('logs.live_fix')
)
} finally { } finally {
setFixGpsLoading(false) setPositionGpsLoading(false)
} }
} }
const confirmFix = () => { const confirmPosition = () => {
const coords = normalizeGpsCoordinates(fixLat, fixLng) const coords = normalizeGpsCoordinates(positionLat, positionLng)
if (!coords) { if (!coords) {
void showAlert(t('logs.live_fix_invalid'), t('logs.live_fix')) void showAlert(t('logs.live_position_invalid'), t('logs.live_position'))
return return
} }
setModal('none') setModal('none')
@@ -508,25 +604,29 @@ export default function LiveLogView({
await appendQuickEvent(logbookId, entryId, { await appendQuickEvent(logbookId, entryId, {
gpsLat: coords.lat, gpsLat: coords.lat,
gpsLng: coords.lng, gpsLng: coords.lng,
remarks: LIVE_EVENT_CODES.FIX remarks: LIVE_EVENT_CODES.POSITION
}) })
}, 'fix') }, 'position')
} }
const handleFetchOwmWeather = () => { const handleFetchOwmWeather = () => {
if (!entryId || busy || weatherOwmLoading) return if (!entryId || busy || weatherOwmLoading) return
if (!isOnline) {
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
return
}
const position = getLastPositionFixWithin( const position = getLastLoggedPositionWithin(
events, events,
date, date,
LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS
) )
if (!position) { if (!position) {
const latest = getLatestPositionFix(events, date) const latest = getLatestLoggedPosition(events, date)
void showAlert( void showAlert(
latest latest
? t('logs.live_weather_fix_stale') ? t('logs.live_weather_position_stale')
: t('logs.live_weather_fix_required'), : t('logs.live_weather_position_required'),
t('logs.live_weather_owm_btn') t('logs.live_weather_owm_btn')
) )
return return
@@ -545,6 +645,10 @@ export default function LiveLogView({
{ analyticsSource: 'live_log' } { analyticsSource: 'live_log' }
) )
} catch (err) { } catch (err) {
if (err instanceof WeatherApiError && err.code === 'OFFLINE') {
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
return
}
if (err instanceof WeatherApiError && err.code === 'NO_KEY') { if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn')) void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
return return
@@ -570,6 +674,12 @@ export default function LiveLogView({
remarks: LIVE_EVENT_CODES.PRESSURE remarks: LIVE_EVENT_CODES.PRESSURE
}) })
} }
if (parsed.visibility) {
partials.push({
visibility: parsed.visibility,
remarks: LIVE_EVENT_CODES.VISIBILITY
})
}
if (parsed.tempC) { if (parsed.tempC) {
partials.push({ remarks: liveTempRemark(parsed.tempC) }) partials.push({ remarks: liveTempRemark(parsed.tempC) })
} }
@@ -596,8 +706,10 @@ export default function LiveLogView({
const handleUndo = () => { const handleUndo = () => {
if (!entryId || busy) return if (!entryId || busy) return
const photoId = undoPhotoIdRef.current const photoId = undoPhotoIdRef.current
const voiceId = undoVoiceIdRef.current
setUndoVisible(false) setUndoVisible(false)
undoPhotoIdRef.current = null undoPhotoIdRef.current = null
undoVoiceIdRef.current = null
if (undoTimerRef.current) { if (undoTimerRef.current) {
window.clearTimeout(undoTimerRef.current) window.clearTimeout(undoTimerRef.current)
undoTimerRef.current = null undoTimerRef.current = null
@@ -606,6 +718,9 @@ export default function LiveLogView({
if (photoId) { if (photoId) {
await deleteEntryPhoto(logbookId, photoId) await deleteEntryPhoto(logbookId, photoId)
} }
if (voiceId) {
await deleteEntryVoiceMemo(logbookId, voiceId)
}
await removeLastEvent(logbookId, entryId) await removeLastEvent(logbookId, entryId)
}, 'undo', false) }, 'undo', false)
} }
@@ -621,6 +736,56 @@ export default function LiveLogView({
setPhotoCaption('') setPhotoCaption('')
} }
const openVoiceModal = () => {
setVoiceCaption('')
setModal('voice')
}
const closeVoiceModal = () => {
if (voiceSaving) return
setModal('none')
setVoiceCaption('')
}
const handleVoiceSave = (blob: Blob, mimeType: string, durationSec: number) => {
if (!entryId || voiceSaving) return
const caption = voiceCaption.trim()
setVoiceSaving(true)
void (async () => {
try {
const audioDataUrl = await blobToAudioDataUrl(blob)
const voiceId = await saveEntryVoiceMemo({
logbookId,
entryId,
audioDataUrl,
mimeType,
durationSec,
caption,
analyticsContext: 'live_log'
})
await appendQuickEvent(logbookId, entryId, {
remarks: liveVoiceRemark(voiceId)
})
await refreshEntry(entryId)
undoVoiceIdRef.current = voiceId
setModal('none')
setVoiceCaption('')
showUndo('voice')
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'voice' })
} catch (err: unknown) {
console.error('Live log voice save failed:', err)
const msg = err instanceof Error && err.message === 'VOICE_MEMO_TOO_LARGE'
? t('logs.live_voice_too_large')
: err instanceof Error
? err.message
: t('logs.live_voice_error')
void showAlert(msg, t('logs.live_voice_btn'))
} finally {
setVoiceSaving(false)
}
})()
}
const handlePhotoCapture = (blob: Blob) => { const handlePhotoCapture = (blob: Blob) => {
if (!entryId || photoSaving) return if (!entryId || photoSaving) return
const caption = photoCaption.trim() const caption = photoCaption.trim()
@@ -737,6 +902,16 @@ export default function LiveLogView({
}) })
}, 'sea_state') }, 'sea_state')
break 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': { case 'course': {
const course = primary || lastCourseFromEvents(events) const course = primary || lastCourseFromEvents(events)
if (!course) return if (!course) return
@@ -750,45 +925,45 @@ export default function LiveLogView({
break break
} }
case 'fuel': { case 'fuel': {
const liters = parseFloat(primary) const liters = parseAppDecimal(primary)
if (!Number.isFinite(liters) || liters <= 0) return if (liters == null || liters <= 0) return
setModal('none') setModal('none')
void runQuickAction(async () => { void runQuickAction(async () => {
await appendTankRefill(logbookId, entryId, 'fuel', liters, { await appendTankRefill(logbookId, entryId, 'fuel', liters, {
remarks: liveFuelRemark(String(liters)) remarks: liveFuelRemark(formatTankLiters(liters))
}) })
}, 'fuel') }, 'fuel')
break break
} }
case 'water': { case 'water': {
const liters = parseFloat(primary) const liters = parseAppDecimal(primary)
if (!Number.isFinite(liters) || liters <= 0) return if (liters == null || liters <= 0) return
setModal('none') setModal('none')
void runQuickAction(async () => { void runQuickAction(async () => {
await appendTankRefill(logbookId, entryId, 'freshwater', liters, { await appendTankRefill(logbookId, entryId, 'freshwater', liters, {
remarks: liveWaterRemark(String(liters)) remarks: liveWaterRemark(formatTankLiters(liters))
}) })
}, 'water') }, 'water')
break break
} }
case 'sog': { case 'sog': {
const speedKn = parseFloat(primary.replace(',', '.')) const speedKn = parseAppDecimal(primary)
if (!Number.isFinite(speedKn) || speedKn < 0) return if (speedKn == null || speedKn < 0) return
setModal('none') setModal('none')
void runQuickAction(async () => { void runQuickAction(async () => {
await appendQuickEvent(logbookId, entryId, { await appendQuickEvent(logbookId, entryId, {
remarks: liveSogRemark(String(speedKn)) remarks: liveSogRemark(formatSpeedKn(speedKn))
}) })
}, 'sog') }, 'sog')
break break
} }
case 'stw': { case 'stw': {
const speedKn = parseFloat(primary.replace(',', '.')) const speedKn = parseAppDecimal(primary)
if (!Number.isFinite(speedKn) || speedKn < 0) return if (speedKn == null || speedKn < 0) return
setModal('none') setModal('none')
void runQuickAction(async () => { void runQuickAction(async () => {
await appendQuickEvent(logbookId, entryId, { await appendQuickEvent(logbookId, entryId, {
remarks: liveStwRemark(String(speedKn)) remarks: liveStwRemark(formatSpeedKn(speedKn))
}) })
}, 'stw') }, 'stw')
break break
@@ -852,7 +1027,7 @@ export default function LiveLogView({
{error && <div className="auth-error mb-4">{error}</div>} {error && <div className="auth-error mb-4">{error}</div>}
{!hasPositionFix && ( {!hasLoggedPosition && (
<p className="live-log-gps-hint" role="status"> <p className="live-log-gps-hint" role="status">
<MapPin size={16} aria-hidden /> <MapPin size={16} aria-hidden />
{t('logs.live_gps_start_hint')} {t('logs.live_gps_start_hint')}
@@ -936,13 +1111,16 @@ export default function LiveLogView({
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('sea_state')} disabled={busy}> <button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('sea_state')} disabled={busy}>
{t('logs.live_sea_state_btn')} {t('logs.live_sea_state_btn')}
</button> </button>
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('visibility')} disabled={busy}>
{t('logs.live_visibility_btn')}
</button>
</div> </div>
)} )}
</div> </div>
<button type="button" className="live-log-action-btn" onClick={() => void openFixModal()} disabled={busy}> <button type="button" className="live-log-action-btn" onClick={() => void openPositionModal()} disabled={busy}>
<MapPin size={18} /> <MapPin size={18} />
{t('logs.live_fix')} {t('logs.live_position')}
</button> </button>
<button type="button" className="live-log-action-btn" onClick={() => { setCommentText(''); setModal('comment') }} disabled={busy}> <button type="button" className="live-log-action-btn" onClick={() => { setCommentText(''); setModal('comment') }} disabled={busy}>
<MessageSquare size={18} /> <MessageSquare size={18} />
@@ -952,6 +1130,10 @@ export default function LiveLogView({
<Camera size={18} /> <Camera size={18} />
{t('logs.live_photo_btn')} {t('logs.live_photo_btn')}
</button> </button>
<button type="button" className="live-log-action-btn" onClick={openVoiceModal} disabled={busy || voiceSaving}>
<Mic size={18} />
{t('logs.live_voice_btn')}
</button>
</aside> </aside>
<section className="live-log-stream-panel" aria-label={t('logs.live_stream_label')}> <section className="live-log-stream-panel" aria-label={t('logs.live_stream_label')}>
@@ -960,12 +1142,30 @@ export default function LiveLogView({
<p className="live-log-empty">{t('logs.live_no_events')}</p> <p className="live-log-empty">{t('logs.live_no_events')}</p>
) : ( ) : (
<ol className="live-log-stream"> <ol className="live-log-stream">
{events.map((event, index) => ( {events.map((event, index) => {
<li key={`${event.time}-${index}`} className="live-log-entry"> const voiceId = parseLiveVoiceRemark(event.remarks.trim())
<time className="live-log-time">{event.time}</time> const voicePreloaded = voiceId ? voiceMemoLookup.get(voiceId) : undefined
<span className="live-log-summary">{formatEventSummary(event, t)}</span> let summary = formatEventSummary(event, t)
</li> if (voiceId && voicePreloaded?.caption) {
))} summary = t('logs.live_voice_entry', { caption: voicePreloaded.caption })
}
return (
<li key={`${event.time}-${index}`} className="live-log-entry">
<time className="live-log-time">{event.time}</time>
<div className="live-log-summary-block">
<span className="live-log-summary">{summary}</span>
{voiceId && (
<VoiceMemoPlayer
audioId={voiceId}
logbookId={logbookId}
preloaded={voicePreloaded}
compact
/>
)}
</div>
</li>
)
})}
<div ref={streamEndRef} /> <div ref={streamEndRef} />
</ol> </ol>
)} )}
@@ -979,7 +1179,11 @@ export default function LiveLogView({
<div className="live-log-undo-bar" role="status"> <div className="live-log-undo-bar" role="status">
<div className="live-log-undo-bar-inner"> <div className="live-log-undo-bar-inner">
<span> <span>
{undoHint === 'photo' ? t('logs.live_undo_photo_hint') : t('logs.live_undo_hint')} {undoHint === 'photo'
? t('logs.live_undo_photo_hint')
: undoHint === 'voice'
? t('logs.live_undo_voice_hint')
: t('logs.live_undo_hint')}
</span> </span>
<button type="button" className="btn secondary" onClick={handleUndo} disabled={busy}> <button type="button" className="btn secondary" onClick={handleUndo} disabled={busy}>
<Undo2 size={16} /> <Undo2 size={16} />
@@ -1023,7 +1227,7 @@ export default function LiveLogView({
</p> </p>
)} )}
<div className="live-log-modal-actions"> <div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={closeModal}>{t('logs.confirm_no')}</button> <button type="button" className="btn secondary" onClick={closeModal}>{t('logs.live_cancel')}</button>
<button <button
type="button" type="button"
className="btn primary" className="btn primary"
@@ -1039,68 +1243,79 @@ export default function LiveLogView({
</div> </div>
)} )}
{modal === 'fix' && ( {modal === 'position' && (
<div <div
className="live-log-modal-backdrop" className="live-log-modal-backdrop"
onClick={(e) => { if (e.target === e.currentTarget) closeModal() }} onClick={(e) => { if (e.target === e.currentTarget) closeModal() }}
> >
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}> <div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
<h3>{t('logs.live_fix')}</h3> <h3>{t('logs.live_position')}</h3>
{fixGpsUnavailable && ( {positionGpsUnavailable && (
<> <>
<p className="live-log-modal-hint live-log-gps-hint-modal">{t('logs.live_gps_start_hint')}</p> {positionGpsErrorReason && (
<p className="live-log-modal-hint">{t('logs.live_fix_manual_hint')}</p> <p className="live-log-modal-hint live-log-gps-error-modal" role="alert">
{t(geolocationErrorI18nKey(positionGpsErrorReason))}
</p>
)}
<p className="live-log-modal-hint">{t('logs.live_position_manual_hint')}</p>
</> </>
)} )}
<fieldset className="live-log-fix-coords" disabled={busy}> <fieldset className="live-log-position-coords" disabled={busy}>
<legend className="live-log-fix-label">{t('logs.event_gps')}</legend> <legend className="live-log-position-label">{t('logs.event_gps')}</legend>
<div className="live-log-fix-coords-row"> <div className="live-log-position-coords-row">
<label className="live-log-fix-field"> <label className="live-log-position-field">
<span className="live-log-fix-field-label">{t('logs.live_fix_lat_placeholder')}</span> <span className="live-log-position-field-label">{t('logs.live_position_lat_placeholder')}</span>
<input <input
type="text" type="text"
inputMode="decimal" inputMode="decimal"
className="input-text" className="input-text"
placeholder="54.123456" placeholder="54.123456"
value={fixLat} value={positionLat}
onChange={(e) => setFixLat(e.target.value)} onChange={(e) => { setPositionGpsSignal(null); setPositionLat(e.target.value) }}
autoFocus autoFocus
/> />
</label> </label>
<label className="live-log-fix-field"> <label className="live-log-position-field">
<span className="live-log-fix-field-label">{t('logs.live_fix_lng_placeholder')}</span> <span className="live-log-position-field-label">{t('logs.live_position_lng_placeholder')}</span>
<input <input
type="text" type="text"
inputMode="decimal" inputMode="decimal"
className="input-text" className="input-text"
placeholder="10.654321" placeholder="10.654321"
value={fixLng} value={positionLng}
onChange={(e) => setFixLng(e.target.value)} onChange={(e) => { setPositionGpsSignal(null); setPositionLng(e.target.value) }}
onKeyDown={(e) => { if (e.key === 'Enter') confirmFix() }} onKeyDown={(e) => { if (e.key === 'Enter') confirmPosition() }}
/> />
</label> </label>
</div> </div>
<div className="live-log-fix-gps-row"> {positionGpsSignal && (
<GpsSignalHint
quality={positionGpsSignal.quality}
accuracyM={positionGpsSignal.accuracyM}
className="gps-signal-hint-modal"
/>
)}
<div className="live-log-position-gps-row">
<button <button
type="button" type="button"
className="btn secondary live-log-fix-gps-btn" className="btn secondary live-log-position-gps-btn"
onClick={() => void retryFixGps()} onClick={() => void retryPositionGps()}
title={t('logs.gps_btn')} title={t('logs.gps_btn')}
disabled={fixGpsLoading} disabled={positionGpsLoading}
aria-label={t('logs.gps_btn')} aria-label={t('logs.gps_btn')}
> >
<MapPin size={16} /> <MapPin size={16} />
<span>{fixGpsLoading ? t('logs.live_fix_gps_loading') : t('logs.gps_btn')}</span> <span>{positionGpsLoading ? t('logs.live_position_gps_loading') : t('logs.gps_btn')}</span>
</button> </button>
</div> </div>
</fieldset> </fieldset>
<div className="live-log-modal-actions"> <div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={closeModal}>{t('logs.confirm_no')}</button> <button type="button" className="btn secondary" onClick={closeModal}>{t('logs.live_cancel')}</button>
<button <button
type="button" type="button"
className="btn primary" className="btn primary"
onClick={confirmFix} onClick={confirmPosition}
disabled={busy || !normalizeGpsCoordinates(fixLat, fixLng)} disabled={busy || !normalizeGpsCoordinates(positionLat, positionLng)}
> >
{t('logs.live_sails_confirm')} {t('logs.live_sails_confirm')}
</button> </button>
@@ -1115,7 +1330,7 @@ export default function LiveLogView({
<h3>{t('logs.live_comment_btn')}</h3> <h3>{t('logs.live_comment_btn')}</h3>
<input type="text" className="input-text" value={commentText} onChange={(e) => setCommentText(e.target.value)} placeholder={t('logs.live_comment_placeholder')} autoFocus onKeyDown={(e) => { if (e.key === 'Enter') confirmComment() }} /> <input type="text" className="input-text" value={commentText} onChange={(e) => setCommentText(e.target.value)} placeholder={t('logs.live_comment_placeholder')} autoFocus onKeyDown={(e) => { if (e.key === 'Enter') confirmComment() }} />
<div className="live-log-modal-actions"> <div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button> <button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.live_cancel')}</button>
<button type="button" className="btn primary" onClick={confirmComment} disabled={!commentText.trim()}>{t('logs.live_comment_confirm')}</button> <button type="button" className="btn primary" onClick={confirmComment} disabled={!commentText.trim()}>{t('logs.live_comment_confirm')}</button>
</div> </div>
</div> </div>
@@ -1149,7 +1364,7 @@ export default function LiveLogView({
/> />
</div> </div>
<div className="live-log-modal-actions"> <div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button> <button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.live_cancel')}</button>
<button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button> <button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button>
</div> </div>
</div> </div>
@@ -1171,14 +1386,14 @@ export default function LiveLogView({
/> />
</div> </div>
<div className="live-log-modal-actions"> <div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button> <button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.live_cancel')}</button>
<button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button> <button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button>
</div> </div>
</div> </div>
</div> </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-backdrop" onClick={() => setModal('none')}>
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}> <div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
<h3> <h3>
@@ -1186,6 +1401,7 @@ export default function LiveLogView({
{modal === 'temp' && t('logs.live_temp_btn')} {modal === 'temp' && t('logs.live_temp_btn')}
{modal === 'precip' && t('logs.live_precip_btn')} {modal === 'precip' && t('logs.live_precip_btn')}
{modal === 'sea_state' && t('logs.live_sea_state_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 === 'fuel' && t('logs.live_fuel_btn')}
{modal === 'water' && t('logs.live_water_btn')} {modal === 'water' && t('logs.live_water_btn')}
{modal === 'sog' && t('logs.live_sog_btn')} {modal === 'sog' && t('logs.live_sog_btn')}
@@ -1205,7 +1421,8 @@ export default function LiveLogView({
: modal === 'temp' ? t('logs.live_temp_placeholder') : modal === 'temp' ? t('logs.live_temp_placeholder')
: modal === 'precip' ? t('logs.live_precip_placeholder') : modal === 'precip' ? t('logs.live_precip_placeholder')
: modal === 'sea_state' ? t('logs.live_sea_state_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 === 'water' ? t('logs.live_water_placeholder')
: modal === 'sog' ? t('logs.live_sog_placeholder') : modal === 'sog' ? t('logs.live_sog_placeholder')
: t('logs.live_stw_placeholder') : t('logs.live_stw_placeholder')
@@ -1214,7 +1431,7 @@ export default function LiveLogView({
onKeyDown={(e) => { if (e.key === 'Enter') confirmValueModal() }} onKeyDown={(e) => { if (e.key === 'Enter') confirmValueModal() }}
/> />
<div className="live-log-modal-actions"> <div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button> <button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.live_cancel')}</button>
<button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button> <button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button>
</div> </div>
</div> </div>
@@ -1229,6 +1446,15 @@ export default function LiveLogView({
onClose={closePhotoModal} onClose={closePhotoModal}
onCapture={handlePhotoCapture} onCapture={handlePhotoCapture}
/> />
<LiveVoiceCapture
open={modal === 'voice'}
busy={voiceSaving}
caption={voiceCaption}
onCaptionChange={setVoiceCaption}
onClose={closeVoiceModal}
onSave={handleVoiceSave}
/>
</>, </>,
document.body document.body
)} )}
+412
View File
@@ -0,0 +1,412 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Mic, Square, X } from 'lucide-react'
import {
assertVoiceMemoBlobSize,
formatVoiceDuration,
pickMediaRecorderMimeType,
VOICE_MEMO_MAX_DURATION_SEC
} from '../utils/audioBlob.js'
interface LiveVoiceCaptureProps {
open: boolean
busy?: boolean
caption?: string
onCaptionChange?: (value: string) => void
onClose: () => void
onSave: (blob: Blob, mimeType: string, durationSec: number) => void
}
type Phase = 'idle' | 'recording' | 'preview'
export default function LiveVoiceCapture({
open,
busy = false,
caption = '',
onCaptionChange,
onClose,
onSave
}: LiveVoiceCaptureProps) {
const { t } = useTranslation()
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const chunksRef = useRef<Blob[]>([])
const previewUrlRef = useRef<string | null>(null)
const startedAtRef = useRef<number>(0)
const timerRef = useRef<number | null>(null)
const [phase, setPhase] = useState<Phase>('idle')
const [micError, setMicError] = useState<string | null>(null)
const [elapsedSec, setElapsedSec] = useState(0)
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [previewMime, setPreviewMime] = useState('audio/webm')
const [previewDurationSec, setPreviewDurationSec] = useState(0)
const [saving, setSaving] = useState(false)
const [logs, setLogs] = useState<string[]>([])
const log = useCallback((msg: string) => {
console.log(`[VoiceDebug] ${msg}`)
setLogs((prev) => [...prev, `${new Date().toLocaleTimeString()}: ${msg}`])
}, [])
const previewAudioRef = useRef<HTMLAudioElement | null>(null)
useEffect(() => {
const el = previewAudioRef.current
if (!el) {
log('previewAudioRef is null')
return
}
log('Preview audio player loaded. readyState=' + el.readyState + ', duration=' + el.duration + ', src=' + el.src)
const handleLoadedMetadata = () => {
log('loadedmetadata event fired. readyState=' + el.readyState + ', duration=' + el.duration)
if (el.duration === Infinity || isNaN(el.duration) || el.duration === 0) {
log('Duration correction hack triggered (duration=' + el.duration + '). Seeking to 1e10...')
el.currentTime = 1e10
const onTimeUpdate = () => {
log('timeupdate event. currentTime=' + el.currentTime + ', duration=' + el.duration)
el.currentTime = 0
el.removeEventListener('timeupdate', onTimeUpdate)
log('currentTime reset to 0. Final duration=' + el.duration)
}
el.addEventListener('timeupdate', onTimeUpdate)
} else {
log('Duration correction skipped (duration is valid)')
}
}
if (el.readyState >= 1) {
log('readyState >= 1. Executing hack immediately...')
handleLoadedMetadata()
} else {
log('readyState = 0. Adding loadedmetadata event listener...')
el.addEventListener('loadedmetadata', handleLoadedMetadata)
}
return () => {
el.removeEventListener('loadedmetadata', handleLoadedMetadata)
}
}, [previewUrl, log])
const stopStream = useCallback(() => {
for (const track of streamRef.current?.getTracks() ?? []) {
track.stop()
}
streamRef.current = null
}, [])
const clearPreview = useCallback(() => {
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current)
previewUrlRef.current = null
}
setPreviewUrl(null)
setPreviewBlob(null)
}, [])
const clearTimer = useCallback(() => {
if (timerRef.current != null) {
window.clearInterval(timerRef.current)
timerRef.current = null
}
}, [])
const resetAll = useCallback(() => {
if (mediaRecorderRef.current?.state === 'recording') {
mediaRecorderRef.current.stop()
}
mediaRecorderRef.current = null
chunksRef.current = []
clearTimer()
stopStream()
clearPreview()
setPhase('idle')
setMicError(null)
setElapsedSec(0)
setSaving(false)
}, [stopStream, clearPreview, clearTimer])
useEffect(() => {
if (!open) {
resetAll()
}
}, [open, resetAll])
useEffect(() => {
return () => {
resetAll()
}
}, [resetAll])
const finishRecording = useCallback((blob: Blob, mimeType: string, durationSec: number) => {
clearPreview()
const url = URL.createObjectURL(blob)
previewUrlRef.current = url
setPreviewBlob(blob)
setPreviewUrl(url)
setPreviewMime(mimeType)
setPreviewDurationSec(durationSec)
setPhase('preview')
}, [clearPreview])
const stopRecording = useCallback(() => {
const recorder = mediaRecorderRef.current
if (!recorder || recorder.state !== 'recording') return
recorder.stop()
clearTimer()
}, [clearTimer])
const startRecording = async () => {
setMicError(null)
chunksRef.current = []
log('startRecording flow triggered')
if (!navigator.mediaDevices?.getUserMedia) {
log('navigator.mediaDevices.getUserMedia is unavailable')
setMicError(t('logs.live_voice_mic_denied'))
return
}
try {
log('Requesting getUserMedia audio stream...')
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
streamRef.current = stream
log('Stream obtained successfully. active=' + stream.active)
stream.getTracks().forEach((track, i) => {
log(`Track ${i}: label="${track.label}" enabled=${track.enabled} readyState=${track.readyState} muted=${track.muted}`)
})
const mimeType = pickMediaRecorderMimeType()
log('MIME type candidates support check:')
const MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus'
]
MIME_CANDIDATES.forEach(mime => {
log(` - ${mime}: ${MediaRecorder.isTypeSupported(mime) ? 'SUPPORTED' : 'UNSUPPORTED'}`)
})
log('Selected MIME from picker: ' + mimeType)
const recorder = mimeType
? new MediaRecorder(stream, { mimeType })
: new MediaRecorder(stream)
mediaRecorderRef.current = recorder
const resolvedMime = recorder.mimeType || mimeType || 'audio/webm'
log('MediaRecorder created. Resolved mime=' + resolvedMime)
recorder.ondataavailable = (ev) => {
log(`ondataavailable event: data size=${ev.data?.size} bytes`)
if (ev.data && ev.data.size > 0) {
chunksRef.current.push(ev.data)
}
}
recorder.onstop = () => {
const durationSec = Math.min(
VOICE_MEMO_MAX_DURATION_SEC,
Math.max(1, Math.round((Date.now() - startedAtRef.current) / 1000))
)
log(`onstop triggered. durationSec=${durationSec}. Wrapping in 50ms timeout...`)
setTimeout(() => {
log(`Creating Blob from ${chunksRef.current.length} chunks. Resolved mime=${resolvedMime}`)
const totalChunksSize = chunksRef.current.reduce((acc, chunk) => acc + chunk.size, 0)
log(`Total raw chunks size: ${totalChunksSize} bytes`)
const blob = new Blob(chunksRef.current, { type: resolvedMime })
chunksRef.current = []
stopStream()
log(`Blob finalized: size=${blob.size} bytes, type=${blob.type}`)
try {
assertVoiceMemoBlobSize(blob)
log('Blob size assertion passed. Calling finishRecording...')
finishRecording(blob, resolvedMime, durationSec)
} catch (err) {
log('Blob size assertion failed (too large)')
setMicError(t('logs.live_voice_too_large'))
setPhase('idle')
}
}, 50)
}
recorder.onerror = (ev) => {
log('MediaRecorder onerror triggered: ' + JSON.stringify(ev))
setMicError(t('logs.live_voice_record_failed'))
resetAll()
}
startedAtRef.current = Date.now()
log('Calling recorder.start()...')
recorder.start()
log('recorder.start() called. State=' + recorder.state)
setPhase('recording')
setElapsedSec(0)
timerRef.current = window.setInterval(() => {
const sec = Math.floor((Date.now() - startedAtRef.current) / 1000)
setElapsedSec(sec)
if (sec >= VOICE_MEMO_MAX_DURATION_SEC) {
log('Max duration reached. Stopping recording...')
stopRecording()
}
}, 250)
} catch (err: any) {
log('Error in startRecording try-catch block: ' + (err instanceof Error ? err.stack || err.message : String(err)))
setMicError(t('logs.live_voice_mic_denied'))
stopStream()
}
}
const handleSave = async () => {
if (!previewBlob || saving || busy) {
log('handleSave ignored. previewBlob=' + (previewBlob ? 'PRESENT' : 'NULL') + ' saving=' + saving + ' busy=' + busy)
return
}
log('handleSave triggered. Saving blob size=' + previewBlob.size + ' mime=' + previewMime + ' duration=' + previewDurationSec)
setSaving(true)
try {
log('Invoking onSave callback...')
await onSave(previewBlob, previewMime, previewDurationSec)
log('onSave callback successfully finished!')
} catch (err: any) {
log('Error during onSave execution: ' + (err instanceof Error ? err.stack || err.message : String(err)))
} finally {
setSaving(false)
}
}
if (!open) return null
return (
<div
className="live-log-modal-backdrop"
onClick={(e) => {
if (e.target === e.currentTarget && !busy && !saving && phase !== 'recording') onClose()
}}
>
<div className="live-log-modal live-voice-modal" onClick={(e) => e.stopPropagation()}>
<div className="live-voice-modal-header">
<h3>{t('logs.live_voice_btn')}</h3>
<button
type="button"
className="btn-icon"
onClick={onClose}
disabled={busy || saving || phase === 'recording'}
aria-label={t('logs.live_cancel')}
>
<X size={18} />
</button>
</div>
{micError && <p className="live-log-modal-hint auth-error">{micError}</p>}
{phase === 'idle' && (
<>
<p className="live-log-modal-hint">{t('logs.live_voice_hint')}</p>
<button
type="button"
className="btn primary live-voice-record-btn"
onClick={() => void startRecording()}
disabled={busy || saving}
>
<Mic size={18} />
{t('logs.live_voice_record')}
</button>
</>
)}
{phase === 'recording' && (
<>
<p className="live-voice-recording-indicator" role="status" aria-live="polite">
<span className="live-voice-recording-dot" aria-hidden />
{t('logs.live_voice_recording', { time: formatVoiceDuration(elapsedSec) })}
</p>
<button
type="button"
className="btn primary live-voice-stop-btn"
onClick={stopRecording}
>
<Square size={16} fill="currentColor" />
{t('logs.live_voice_stop')}
</button>
</>
)}
{phase === 'preview' && previewUrl && (
<>
<audio ref={previewAudioRef} className="voice-memo-player" controls src={previewUrl} preload="auto" />
{onCaptionChange && (
<label className="live-voice-caption-field">
<span>{t('logs.live_voice_caption_label')}</span>
<input
type="text"
className="input-text"
value={caption}
onChange={(e) => onCaptionChange(e.target.value)}
placeholder={t('logs.live_voice_caption_placeholder')}
disabled={busy || saving}
/>
</label>
)}
<div className="live-log-modal-actions">
<button
type="button"
className="btn secondary"
onClick={() => {
clearPreview()
setPhase('idle')
}}
disabled={busy || saving}
>
{t('logs.live_voice_retake')}
</button>
<button
type="button"
className="btn primary"
onClick={() => void handleSave()}
disabled={busy || saving}
>
{saving ? t('logs.live_voice_saving') : t('logs.live_voice_save')}
</button>
</div>
</>
)}
{/* Debug Logs Panel */}
<div style={{
marginTop: '20px',
padding: '10px',
background: 'rgba(0,0,0,0.6)',
border: '1px solid rgba(255,255,255,0.15)',
borderRadius: '8px',
maxHeight: '180px',
overflowY: 'auto',
textAlign: 'left',
fontSize: '11px',
fontFamily: 'monospace',
color: '#4ade80',
width: '100%',
boxSizing: 'border-box'
}}>
<div style={{ fontWeight: 'bold', marginBottom: '4px', borderBottom: '1px solid rgba(255,255,255,0.2)', paddingBottom: '2px', display: 'flex', justifyContent: 'space-between' }}>
<span>Debug Console Logs:</span>
<button
type="button"
onClick={() => setLogs([])}
style={{ background: 'none', border: 'none', color: '#fda4af', cursor: 'pointer', fontSize: '10px', padding: '0 4px', textDecoration: 'underline' }}
>
Clear
</button>
</div>
{logs.length === 0 ? (
<span style={{ color: '#94a3b8' }}>No logs yet. Start recording to debug.</span>
) : (
logs.map((l, i) => (
<div key={i} style={{ wordBreak: 'break-all', marginBottom: '2px' }}>{l}</div>
))
)}
</div>
</div>
</div>
)
}
+51 -23
View File
@@ -3,18 +3,25 @@ import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js' import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js' import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js' import { getLogbookKey } from '../services/logbookKeys.js'
import { decryptJson, encryptJson } from '../services/crypto.js' import { encryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js' import { syncLogbook } from '../services/sync.js'
import { downloadCsv, shareCsv } from '../services/csvExport.js' import { downloadCsv, shareCsv } from '../services/csvExport.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js' import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js' import { getErrorMessage } from '../utils/errors.js'
import { findTodayEntryId } from '../services/quickEventLog.js' import { findTodayEntryId, pruneEmptyTodayDuplicates, tryDecryptEntryPayload } from '../services/quickEventLog.js'
import { localDateString } from '../utils/logEntryPayload.js'
import LogEntryEditor from './LogEntryEditor.tsx' import LogEntryEditor from './LogEntryEditor.tsx'
import LiveLogView from './LiveLogView.tsx' import LiveLogView from './LiveLogView.tsx'
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx' import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js' 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 { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react'
import { import {
carryOverFromPreviousDay, carryOverFromPreviousDay,
@@ -33,6 +40,7 @@ interface LogEntriesListProps {
preloadedYacht?: any preloadedYacht?: any
preloadedEntries?: any[] preloadedEntries?: any[]
preloadedPhotos?: any[] preloadedPhotos?: any[]
preloadedVoiceMemos?: import('./VoiceMemoPlayer.tsx').PreloadedVoiceMemo[]
preloadedGpsTracks?: any[] preloadedGpsTracks?: any[]
controlledSelectedEntryId?: string | null controlledSelectedEntryId?: string | null
onSelectedEntryIdChange?: (id: string | null) => void onSelectedEntryIdChange?: (id: string | null) => void
@@ -57,6 +65,7 @@ export default function LogEntriesList({
preloadedYacht, preloadedYacht,
preloadedEntries, preloadedEntries,
preloadedPhotos, preloadedPhotos,
preloadedVoiceMemos,
preloadedGpsTracks, preloadedGpsTracks,
controlledSelectedEntryId, controlledSelectedEntryId,
onSelectedEntryIdChange, onSelectedEntryIdChange,
@@ -115,25 +124,40 @@ export default function LogEntriesList({
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.') if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const todayEntryId = await findTodayEntryId(logbookId)
if (todayEntryId) {
await pruneEmptyTodayDuplicates(logbookId, todayEntryId)
}
const local = await db.entries.where({ logbookId }).toArray() const local = await db.entries.where({ logbookId }).toArray()
const list: DecryptedEntryItem[] = [] const list: DecryptedEntryItem[] = []
const needsDecrypt: typeof local = []
for (const entry of local) { for (const entry of local) {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey) const cached = entryListItemFromLocal(entry)
if (decrypted) { if (cached) {
list.push({ list.push(cached)
id: entry.payloadId, } else {
date: decrypted.date || '', needsDecrypt.push(entry)
dayOfTravel: decrypted.dayOfTravel || '',
departure: decrypted.departure || '',
destination: decrypted.destination || '',
updatedAt: entry.updatedAt,
skipperSignStatus: await getSkipperSignStatus(decrypted as Record<string, unknown>)
})
} }
} }
await forEachInBatches(needsDecrypt, 8, async (entry) => {
const decrypted = await tryDecryptEntryPayload(entry, 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) // Sort chronological descending (by date, or dayOfTravel numerical)
list.sort((a, b) => { list.sort((a, b) => {
const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime() const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime()
@@ -250,7 +274,7 @@ export default function LogEntriesList({
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = [] const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
for (const entry of localEntries) { for (const entry of localEntries) {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey) const decrypted = await tryDecryptEntryPayload(entry, masterKey)
if (decrypted) decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable) if (decrypted) decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable)
} }
@@ -282,7 +306,7 @@ export default function LogEntriesList({
const localId = window.crypto.randomUUID() const localId = window.crypto.randomUUID()
const nowStr = new Date().toISOString() const nowStr = new Date().toISOString()
const todayStr = nowStr.substring(0, 10) const todayStr = localDateString()
const { loadDefaultEntryCrewForNewDay } = await import('./EntryCrewSection.js') const { loadDefaultEntryCrewForNewDay } = await import('./EntryCrewSection.js')
const entryCrew = await loadDefaultEntryCrewForNewDay( const entryCrew = await loadDefaultEntryCrewForNewDay(
@@ -309,14 +333,17 @@ export default function LogEntriesList({
const encrypted = await encryptJson(initialPayload, masterKey) const encrypted = await encryptJson(initialPayload, masterKey)
// Save locally // Save locally
await db.entries.put({ await putEntryRecord(
payloadId: localId, {
logbookId, payloadId: localId,
encryptedData: encrypted.ciphertext, logbookId,
iv: encrypted.iv, encryptedData: encrypted.ciphertext,
tag: encrypted.tag, iv: encrypted.iv,
updatedAt: nowStr tag: encrypted.tag,
}) updatedAt: nowStr
},
initialPayload
)
// Queue for background sync // Queue for background sync
await db.syncQueue.put({ await db.syncQueue.put({
@@ -384,6 +411,7 @@ export default function LogEntriesList({
readOnly={readOnly} readOnly={readOnly}
preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)} preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)}
preloadedPhotos={preloadedPhotos} preloadedPhotos={preloadedPhotos}
preloadedVoiceMemos={preloadedVoiceMemos}
preloadedTrack={preloadedGpsTracks?.find(track => track.entryId === selectedEntryId)} preloadedTrack={preloadedGpsTracks?.find(track => track.entryId === selectedEntryId)}
/> />
) )
File diff suppressed because it is too large Load Diff
+62 -10
View File
@@ -5,10 +5,12 @@ import { useDialog } from './ModalDialog.tsx'
import { import {
downloadBackupBlob, downloadBackupBlob,
exportLogbookBackup, exportLogbookBackup,
formatBackupBytes,
parseLogbookBackupFile, parseLogbookBackupFile,
previewLogbookBackup, previewLogbookBackup,
restoreLogbookBackup, restoreLogbookBackup,
type LogbookBackupFile, BACKUP_SIZE_CONFIRM_BYTES,
type ParsedLogbookBackup,
type LogbookBackupPreview type LogbookBackupPreview
} from '../services/logbookBackup.js' } from '../services/logbookBackup.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
@@ -27,6 +29,12 @@ function mapBackupError(code: string, t: (key: string) => string): string {
return t('settings.backup_not_owner') return t('settings.backup_not_owner')
case 'BACKUP_INVALID_JSON': case 'BACKUP_INVALID_JSON':
return t('settings.backup_invalid_json') return t('settings.backup_invalid_json')
case 'BACKUP_INVALID_ARCHIVE':
return t('settings.backup_invalid_archive')
case 'BACKUP_VERSION_UNSUPPORTED':
return t('settings.backup_version_unsupported')
case 'BACKUP_WRONG_PASSPHRASE':
return t('settings.backup_wrong_passphrase')
case 'BACKUP_INVALID_FORMAT': case 'BACKUP_INVALID_FORMAT':
return t('settings.backup_invalid_format') return t('settings.backup_invalid_format')
case 'BACKUP_NOT_AUTHENTICATED': case 'BACKUP_NOT_AUTHENTICATED':
@@ -53,12 +61,16 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
const [importPassphrase, setImportPassphrase] = useState('') const [importPassphrase, setImportPassphrase] = useState('')
const [importFile, setImportFile] = useState<File | null>(null) const [importFile, setImportFile] = useState<File | null>(null)
const [importPreview, setImportPreview] = useState<LogbookBackupPreview | null>(null) const [importPreview, setImportPreview] = useState<LogbookBackupPreview | null>(null)
const [parsedBackup, setParsedBackup] = useState<LogbookBackupFile | null>(null) const [parsedBackup, setParsedBackup] = useState<ParsedLogbookBackup | null>(null)
const [importing, setImporting] = useState(false) const [importing, setImporting] = useState(false)
const [previewing, setPreviewing] = useState(false) const [previewing, setPreviewing] = useState(false)
const [exportProgress, setExportProgress] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null) const [success, setSuccess] = useState<string | null>(null)
const exportPassphrasesMatch =
exportPassphrase.length >= 8 && exportPassphrase === exportConfirm
const handleExportSubmit = async (e: React.FormEvent) => { const handleExportSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
await handleExport() await handleExport()
@@ -83,21 +95,36 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
} }
setExporting(true) setExporting(true)
setExportProgress(null)
try { try {
const { blob, filename, backup } = await exportLogbookBackup(logbookId, exportPassphrase) const { blob, filename, manifest } = await exportLogbookBackup(logbookId, exportPassphrase, {
onProgress: (p) => {
if (p.phase === 'pack') {
setExportProgress(
t('settings.backup_export_progress', {
current: p.current,
total: p.total
})
)
}
}
})
downloadBackupBlob(blob, filename) downloadBackupBlob(blob, filename)
setSuccess(t('settings.backup_export_success', { count: backup.counts.entries })) setSuccess(t('settings.backup_export_success', { count: manifest.counts.entries }))
setExportPassphrase('') setExportPassphrase('')
setExportConfirm('') setExportConfirm('')
trackPlausibleEvent(PlausibleEvents.BACKUP_EXPORTED, { trackPlausibleEvent(PlausibleEvents.BACKUP_EXPORTED, {
entries: backup.counts.entries, entries: manifest.counts.entries,
photos: backup.counts.photos photos: manifest.counts.photos,
voiceMemos: manifest.counts.voiceMemos,
bytes: manifest.totalUncompressedBytes
}) })
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
setError(mapBackupError(message, t)) setError(mapBackupError(message, t))
} finally { } finally {
setExporting(false) setExporting(false)
setExportProgress(null)
} }
} }
@@ -138,6 +165,18 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => { const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => {
if (!parsedBackup || !importPassphrase) return if (!parsedBackup || !importPassphrase) return
if (parsedBackup.manifest.totalUncompressedBytes > BACKUP_SIZE_CONFIRM_BYTES) {
const ok = await showConfirm(
t('settings.backup_import_size_confirm', {
size: formatBackupBytes(parsedBackup.manifest.totalUncompressedBytes)
}),
t('settings.backup_restore_title'),
t('logs.confirm_yes'),
t('logs.confirm_no')
)
if (!ok) return
}
setImporting(true) setImporting(true)
setError(null) setError(null)
try { try {
@@ -149,8 +188,10 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
setParsedBackup(null) setParsedBackup(null)
if (fileInputRef.current) fileInputRef.current.value = '' if (fileInputRef.current) fileInputRef.current.value = ''
trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, { trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, {
entries: parsedBackup.counts.entries, entries: parsedBackup.manifest.counts.entries,
photos: parsedBackup.counts.photos, photos: parsedBackup.manifest.counts.photos,
voiceMemos: parsedBackup.manifest.counts.voiceMemos,
bytes: parsedBackup.manifest.totalUncompressedBytes,
mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id' mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id'
}) })
onRestored?.(result.logbookId, result.title) onRestored?.(result.logbookId, result.title)
@@ -253,11 +294,16 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
<button <button
type="submit" type="submit"
className="btn primary" className="btn primary"
disabled={exporting || !exportPassphrase || !exportConfirm} disabled={exporting || !exportPassphrasesMatch}
> >
<Download size={16} /> <Download size={16} />
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')} {exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')}
</button> </button>
{exportProgress && (
<p className="text-muted backup-export-progress" role="status">
{exportProgress}
</p>
)}
</form> </form>
</section> </section>
@@ -275,7 +321,7 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
id="backup-import-file" id="backup-import-file"
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept=".daagbok.json,application/json" accept=".daagbok,application/zip"
className="input-text" className="input-text"
onChange={handleFileChange} onChange={handleFileChange}
disabled={importing} disabled={importing}
@@ -330,8 +376,14 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
<ul className="backup-preview-stats"> <ul className="backup-preview-stats">
<li>{t('settings.backup_stat_entries', { count: importPreview.counts.entries })}</li> <li>{t('settings.backup_stat_entries', { count: importPreview.counts.entries })}</li>
<li>{t('settings.backup_stat_photos', { count: importPreview.counts.photos })}</li> <li>{t('settings.backup_stat_photos', { count: importPreview.counts.photos })}</li>
<li>{t('settings.backup_stat_voice', { count: importPreview.counts.voiceMemos })}</li>
<li>{t('settings.backup_stat_crew', { count: importPreview.counts.crews })}</li> <li>{t('settings.backup_stat_crew', { count: importPreview.counts.crews })}</li>
<li>{t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}</li> <li>{t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}</li>
<li className="text-muted">
{t('settings.backup_stat_size', {
size: formatBackupBytes(importPreview.totalUncompressedBytes)
})}
</li>
</ul> </ul>
<p className="text-muted backup-preview-date"> <p className="text-muted backup-preview-date">
{t('settings.backup_exported_at', { {t('settings.backup_exported_at', {
+32 -24
View File
@@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next'
import { cycleAppLanguage } from '../utils/i18nLanguages.js' import { cycleAppLanguage } from '../utils/i18nLanguages.js'
import { useSyncIndicator } from '../hooks/useSyncIndicator.js' import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.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 LogbookRoleBadge from './LogbookRoleBadge.tsx'
import BetaBadge from './BetaBadge.tsx' import BetaBadge from './BetaBadge.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
@@ -20,26 +22,6 @@ interface LogbookDashboardProps {
onOpenProfile: () => void 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 LogbookSortKey = 'name' | 'date'
type LogbookSortDirection = 'asc' | 'desc' type LogbookSortDirection = 'asc' | 'desc'
@@ -72,6 +54,9 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [filterQuery, setFilterQuery] = useState('') const [filterQuery, setFilterQuery] = useState('')
const [searchFieldsByLogbookId, setSearchFieldsByLogbookId] = useState<Map<string, LogbookSearchFields>>(
() => new Map()
)
const [sortBy, setSortBy] = useState<LogbookSortKey>('date') const [sortBy, setSortBy] = useState<LogbookSortKey>('date')
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc') const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
const filterInputRef = useRef<HTMLInputElement>(null) const filterInputRef = useRef<HTMLInputElement>(null)
@@ -96,6 +81,23 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
loadLogbooks() 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) => { const loadLogbooks = async (isRefresh = false) => {
if (isRefresh) setRefreshing(true) if (isRefresh) setRefreshing(true)
else setLoading(true) else setLoading(true)
@@ -203,12 +205,18 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
const filterActive = filterQuery.trim().length > 0 const filterActive = filterQuery.trim().length > 0
const filteredOwnedLogbooks = useMemo( 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( 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( const sortedOwnedLogbooks = useMemo(
() => sortLogbooks(filteredOwnedLogbooks, sortBy, sortDirection, i18n.language), () => 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' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export type ConfirmLeaveChoice = 'stay' | 'save' | 'discard'
interface DialogContextType { interface DialogContextType {
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void> showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
showConfirm: (message: string, title?: string, confirmText?: string, cancelText?: string) => Promise<boolean> 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) const DialogContext = createContext<DialogContextType | undefined>(undefined)
@@ -34,12 +44,16 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [message, setMessage] = 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 [confirmLabel, setConfirmLabel] = useState('OK')
const [cancelLabel, setCancelLabel] = useState('Cancel') 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 alertResolveRef = useRef<(() => void) | null>(null)
const confirmResolveRef = useRef<((val: boolean) => 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> => { const showAlert = useCallback((msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
setMessage(msg) setMessage(msg)
@@ -71,6 +85,36 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
}) })
}, [t]) }, [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(() => { const handleConfirm = useCallback(() => {
setIsOpen(false) setIsOpen(false)
if (type === 'confirm' && confirmResolveRef.current) { if (type === 'confirm' && confirmResolveRef.current) {
@@ -83,19 +127,23 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
}, [type]) }, [type])
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
if (type === 'confirm-leave') {
closeConfirmLeave('stay')
return
}
setIsOpen(false) setIsOpen(false)
if (confirmResolveRef.current) { if (confirmResolveRef.current) {
confirmResolveRef.current(false) confirmResolveRef.current(false)
confirmResolveRef.current = null confirmResolveRef.current = null
} }
}, []) }, [type, closeConfirmLeave])
useEffect(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return
confirmRef.current?.focus() confirmRef.current?.focus()
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (type === 'confirm') handleCancel() if (type === 'confirm' || type === 'confirm-leave') handleCancel()
else handleConfirm() else handleConfirm()
} }
} }
@@ -104,8 +152,8 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
}, [isOpen, type, handleCancel, handleConfirm]) }, [isOpen, type, handleCancel, handleConfirm])
const contextValue = useMemo( const contextValue = useMemo(
() => ({ showAlert, showConfirm }), () => ({ showAlert, showConfirm, showConfirmLeave }),
[showAlert, showConfirm] [showAlert, showConfirm, showConfirmLeave]
) )
return ( return (
@@ -114,7 +162,7 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
{isOpen && ( {isOpen && (
<div <div
className="custom-dialog-overlay" className="custom-dialog-overlay"
onClick={type === 'confirm' ? handleCancel : handleConfirm} onClick={type === 'confirm' || type === 'confirm-leave' ? handleCancel : handleConfirm}
> >
<div <div
className="custom-dialog-card glass scale-in" className="custom-dialog-card glass scale-in"
@@ -133,25 +181,59 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
{message} {message}
</p> </p>
<div className="custom-dialog-actions"> <div className="custom-dialog-actions">
{type === 'confirm' && ( {type === 'confirm-leave' ? (
<button <>
type="button" <button
className="btn secondary" ref={confirmRef}
onClick={handleCancel} type="button"
style={{ width: 'auto', padding: '8px 20px', margin: 0 }} className="btn secondary"
> onClick={handleCancel}
{cancelLabel} style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
</button> >
{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> </div>
</div> </div>
+1 -1
View File
@@ -147,7 +147,7 @@ export default function PersonPoolForm() {
</button> </button>
<button <button
type="button" type="button"
className="btn-icon logout" className="btn-icon danger"
onClick={() => void handleDelete(person.payloadId)} onClick={() => void handleDelete(person.payloadId)}
title="Delete" title="Delete"
> >
@@ -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, enableCollaboratorChangePush,
fetchPushPrefs, fetchPushPrefs,
getNotificationPermission, getNotificationPermission,
isPushSupported isPushSupported,
preloadPushService
} from '../services/pushNotifications.js' } from '../services/pushNotifications.js'
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js' import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
@@ -28,6 +29,7 @@ export default function PushNotificationSettings() {
setLoading(false) setLoading(false)
return return
} }
void preloadPushService()
try { try {
const prefs = await fetchPushPrefs() const prefs = await fetchPushPrefs()
setEnabled(prefs.collaboratorChangesEnabled) setEnabled(prefs.collaboratorChangesEnabled)
@@ -56,7 +58,8 @@ export default function PushNotificationSettings() {
trackPlausibleEvent(PlausibleEvents.PUSH_DISABLED) trackPlausibleEvent(PlausibleEvents.PUSH_DISABLED)
} }
} catch (err: unknown) { } 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) showAlert(message)
void loadPrefs() void loadPrefs()
} finally { } finally {
+53 -3
View File
@@ -3,8 +3,10 @@ import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18nLanguages.js' import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18nLanguages.js'
import { decryptJson } from '../services/crypto.js' import { decryptJson } from '../services/crypto.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import VesselForm from './VesselForm.tsx' import LogbookVesselPicker from './LogbookVesselPicker.tsx'
import LogbookCrewPicker from './LogbookCrewPicker.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 type { LogbookCrewSelectionData } from '../types/person.js'
import { emptyLogbookCrewSelection } from '../types/person.js' import { emptyLogbookCrewSelection } from '../types/person.js'
import { legacyCrewRecordsToLogbookSelection } from '../utils/personSnapshots.js' import { legacyCrewRecordsToLogbookSelection } from '../utils/personSnapshots.js'
@@ -38,9 +40,13 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
const [logbookCrewSelection, setLogbookCrewSelection] = useState<LogbookCrewSelectionData>( const [logbookCrewSelection, setLogbookCrewSelection] = useState<LogbookCrewSelectionData>(
emptyLogbookCrewSelection() emptyLogbookCrewSelection()
) )
const [logbookVesselSelection, setLogbookVesselSelection] = useState<LogbookVesselSelectionData>(
emptyLogbookVesselSelection()
)
const [legacyCrews, setLegacyCrews] = useState<any[]>([]) const [legacyCrews, setLegacyCrews] = useState<any[]>([])
const [entries, setEntries] = useState<any[]>([]) const [entries, setEntries] = useState<any[]>([])
const [photos, setPhotos] = useState<any[]>([]) const [photos, setPhotos] = useState<any[]>([])
const [voiceMemos, setVoiceMemos] = useState<any[]>([])
const [gpsTracks, setGpsTracks] = useState<any[]>([]) const [gpsTracks, setGpsTracks] = useState<any[]>([])
useEffect(() => { useEffect(() => {
@@ -97,6 +103,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 }> = [] const decCrews: Array<{ payloadId: string; data: PersonData }> = []
if (data.crews) { if (data.crews) {
for (const c of data.crews) { for (const c of data.crews) {
@@ -144,6 +175,23 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
} }
setPhotos(decPhotos) setPhotos(decPhotos)
const decVoiceMemos = []
if (data.voiceMemos) {
for (const v of data.voiceMemos) {
const dec = await decryptJson(v.encryptedData, v.iv, v.tag, keyBuffer)
if (dec) {
decVoiceMemos.push({
payloadId: v.payloadId,
audio: dec.audio,
mimeType: dec.mimeType,
durationSec: dec.durationSec,
caption: dec.caption || ''
})
}
}
}
setVoiceMemos(decVoiceMemos)
// Decrypt GPS Tracks // Decrypt GPS Tracks
const decGpsTracks = [] const decGpsTracks = []
if (data.gpsTracks) { if (data.gpsTracks) {
@@ -252,15 +300,17 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
preloadedYacht={yacht} preloadedYacht={yacht}
preloadedEntries={entries} preloadedEntries={entries}
preloadedPhotos={photos} preloadedPhotos={photos}
preloadedVoiceMemos={voiceMemos}
preloadedGpsTracks={gpsTracks} preloadedGpsTracks={gpsTracks}
/> />
)} )}
{activeTab === 'vessel' && ( {activeTab === 'vessel' && (
<VesselForm <LogbookVesselPicker
logbookId="shared" logbookId="shared"
readOnly={true} readOnly={true}
preloadedData={yacht} selectionOnly={true}
preloadedSelection={logbookVesselSelection}
/> />
)} )}
+6 -3
View File
@@ -10,7 +10,8 @@ import { apiFetch } from '../services/api.js'
import { import {
enableCollaboratorChangePush, enableCollaboratorChangePush,
isCollaboratorPushActive, isCollaboratorPushActive,
isPushSupported isPushSupported,
preloadPushService
} from '../services/pushNotifications.js' } from '../services/pushNotifications.js'
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js' import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
@@ -55,6 +56,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
loadCollaborators() loadCollaborators()
loadShareLink() loadShareLink()
} }
void preloadPushService()
}, [logbookId]) }, [logbookId])
const loadShareLink = async () => { const loadShareLink = async () => {
@@ -191,7 +193,8 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED) trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED)
} catch (err: unknown) { } catch (err: unknown) {
console.error('Failed to enable push after invite:', err) 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)
} }
} }
@@ -426,7 +429,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
<td> <td>
<button <button
type="button" type="button"
className="btn-icon logout" className="btn-icon danger"
onClick={() => handleRevoke(c.id, c.username)} onClick={() => handleRevoke(c.id, c.username)}
title="Revoke access" title="Revoke access"
> >
+3 -2
View File
@@ -14,6 +14,7 @@ import {
} from '../services/statsAggregation.js' } from '../services/statsAggregation.js'
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js' import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
import { formatFuelPerMotorHour } from '../utils/fuelStats.js' import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
import { formatAppDecimal } from '../utils/numberFormat.js'
import { import {
loadLogbookEventSeries, loadLogbookEventSeries,
type EventSeriesPoint, type EventSeriesPoint,
@@ -211,8 +212,8 @@ function PropulsionBreakdown({ totals }: { totals: StatsTotals }) {
)} )}
</div> </div>
<div className="stats-propulsion-labels"> <div className="stats-propulsion-labels">
<span>{t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({sailPct.toFixed(0)}%)</span> <span>{t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({formatAppDecimal(sailPct, { maximumFractionDigits: 0 })}%)</span>
<span>{t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({motorPct.toFixed(0)}%)</span> <span>{t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({formatAppDecimal(motorPct, { maximumFractionDigits: 0 })}%)</span>
{totals.unknownPropulsionNm > 0 && ( {totals.unknownPropulsionNm > 0 && (
<span>{t('stats.unknown_propulsion')}: {formatNm(totals.unknownPropulsionNm)} {t('stats.unit_nm')}</span> <span>{t('stats.unknown_propulsion')}: {formatNm(totals.unknownPropulsionNm)} {t('stats.unit_nm')}</span>
)} )}
+4 -6
View File
@@ -1,6 +1,7 @@
import React, { useCallback } from 'react' import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { clampTankLiters } from '../utils/tankCapacity.js' import { clampTankLiters } from '../utils/tankCapacity.js'
import { formatTankLiters, parseAppDecimalOrZero } from '../utils/numberFormat.js'
interface TankLiterInputProps { interface TankLiterInputProps {
id?: string id?: string
@@ -13,10 +14,8 @@ interface TankLiterInputProps {
} }
function parseInputLiters(value: string): number { function parseInputLiters(value: string): number {
const trimmed = value.trim().replace(',', '.') if (!value.trim()) return 0
if (!trimmed) return 0 return parseAppDecimalOrZero(value)
const parsed = Number(trimmed)
return Number.isFinite(parsed) ? parsed : 0
} }
export default function TankLiterInput({ export default function TankLiterInput({
@@ -34,8 +33,7 @@ export default function TankLiterInput({
const emitValue = useCallback( const emitValue = useCallback(
(liters: number) => { (liters: number) => {
const clamped = clampTankLiters(liters, useSlider ? maxLiters : undefined) const clamped = clampTankLiters(liters, useSlider ? maxLiters : undefined)
const str = const str = formatTankLiters(clamped)
Number.isInteger(clamped) ? String(clamped) : String(Number(clamped.toFixed(1)))
onChange(str) onChange(str)
}, },
[onChange, maxLiters, useSlider] [onChange, maxLiters, useSlider]
+47 -4
View File
@@ -15,6 +15,7 @@ import {
Anchor, Anchor,
Gauge, Gauge,
Sailboat, Sailboat,
Ship,
Timer, Timer,
Share2, Share2,
Calendar, Calendar,
@@ -31,6 +32,9 @@ import {
import AccountDangerZone from './AccountDangerZone.tsx' import AccountDangerZone from './AccountDangerZone.tsx'
import UserProfilePreferences from './UserProfilePreferences.tsx' import UserProfilePreferences from './UserProfilePreferences.tsx'
import PersonPoolForm from './PersonPoolForm.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 BetaBadge from './BetaBadge.tsx'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import { import {
@@ -137,6 +141,11 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
connStatusClassName connStatusClassName
} = useSyncIndicator() } = useSyncIndicator()
const { isActive: tourActive, currentStepId: tourStepId } = useAppTour()
const fleetSectionTourOpen =
tourActive &&
(tourStepId === 'profile_vessel_pool' || tourStepId === 'profile_crew_pool')
const sharedLogbookCount = useLiveQuery( const sharedLogbookCount = useLiveQuery(
() => db.logbooks.filter((lb) => lb.isShared === 1).count(), () => db.logbooks.filter((lb) => lb.isShared === 1).count(),
[] []
@@ -444,8 +453,14 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
</section> </section>
) : profile ? ( ) : profile ? (
<> <>
<ProfileAccordionSection
id="account"
title={t('profile.sections.account')}
icon={<User size={20} aria-hidden="true" />}
defaultOpen
>
<div data-tour="profile-preferences"> <div data-tour="profile-preferences">
<section className="form-card"> <section className="form-card profile-accordion-inner-card">
<div className="form-header"> <div className="form-header">
<User size={24} className="form-icon" /> <User size={24} className="form-icon" />
<h2>{t('profile.identity_title')}</h2> <h2>{t('profile.identity_title')}</h2>
@@ -487,10 +502,25 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
<UserProfilePreferences userId={profile.userId} /> <UserProfilePreferences userId={profile.userId} />
</div> </div>
</ProfileAccordionSection>
<ProfileAccordionSection
id="fleet"
title={t('profile.sections.fleet')}
icon={<Ship size={20} aria-hidden="true" />}
defaultOpen
forceOpen={fleetSectionTourOpen ? true : undefined}
>
<VesselPoolForm />
<PersonPoolForm /> <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"> <div className="profile-section-header">
<Shield size={20} /> <Shield size={20} />
<h3>{t('profile.security_title')}</h3> <h3>{t('profile.security_title')}</h3>
@@ -729,7 +759,14 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
</div> </div>
</section> </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"> <div className="form-header">
<BarChart2 size={24} className="form-icon" /> <BarChart2 size={24} className="form-icon" />
<div> <div>
@@ -791,8 +828,14 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
</div> </div>
)} )}
</section> </section>
</ProfileAccordionSection>
<AccountDangerZone className="mt-6" /> <ProfileAccordionSection
id="danger"
title={t('profile.sections.danger')}
>
<AccountDangerZone className="profile-accordion-inner-card" />
</ProfileAccordionSection>
</> </>
) : null} ) : null}
</main> </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 danger"
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>
)
}
+109
View File
@@ -0,0 +1,109 @@
import { useEffect, useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { decryptJson } from '../services/crypto.js'
export interface PreloadedVoiceMemo {
payloadId: string
audio: string
mimeType?: string
durationSec?: number
caption?: string
}
interface VoiceMemoPlayerProps {
audioId: string
logbookId: string
preloaded?: PreloadedVoiceMemo | null
compact?: boolean
}
export default function VoiceMemoPlayer({
audioId,
logbookId,
preloaded,
compact = false
}: VoiceMemoPlayerProps) {
const { t } = useTranslation()
const [src, setSrc] = useState<string | null>(preloaded?.audio ?? null)
const [error, setError] = useState(false)
const audioRef = useRef<HTMLAudioElement | null>(null)
useEffect(() => {
const el = audioRef.current
if (!el) return
const handleLoadedMetadata = () => {
if (el.duration === Infinity || isNaN(el.duration) || el.duration === 0) {
el.currentTime = 1e10
const onTimeUpdate = () => {
el.currentTime = 0
el.removeEventListener('timeupdate', onTimeUpdate)
}
el.addEventListener('timeupdate', onTimeUpdate)
}
}
if (el.readyState >= 1) {
handleLoadedMetadata()
} else {
el.addEventListener('loadedmetadata', handleLoadedMetadata)
}
return () => {
el.removeEventListener('loadedmetadata', handleLoadedMetadata)
}
}, [src])
useEffect(() => {
if (preloaded?.audio) {
setSrc(preloaded.audio)
setError(false)
return
}
let cancelled = false
void (async () => {
try {
const record = await db.voiceMemos.get(audioId)
if (!record || cancelled) return
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey || cancelled) return
const decrypted = await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)
if (!decrypted?.audio || cancelled) {
setError(true)
return
}
setSrc(String(decrypted.audio))
setError(false)
} catch {
if (!cancelled) setError(true)
}
})()
return () => {
cancelled = true
}
}, [audioId, logbookId, preloaded?.audio])
if (error || !src) {
return (
<span className="voice-memo-player-unavailable">
{t('logs.live_voice_unavailable')}
</span>
)
}
const playerClass = compact
? 'voice-memo-player voice-memo-player--compact'
: 'voice-memo-player'
return (
<div className="voice-memo-player-shell">
<audio ref={audioRef} className={playerClass} controls preload="none" src={src} />
</div>
)
}
+5 -1
View File
@@ -17,9 +17,13 @@ describe('AppTourContext step order', () => {
expect(profileIndex).toBeGreaterThan(FULL_STEP_ORDER.indexOf('nav_feedback')) expect(profileIndex).toBeGreaterThan(FULL_STEP_ORDER.indexOf('nav_feedback'))
expect(prefsIndex).toBe(profileIndex + 1) expect(prefsIndex).toBe(profileIndex + 1)
expect(finishIndex).toBe(prefsIndex + 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('profile_crew_pool')
expect(FULL_STEP_ORDER).toContain('nav_logbook_crew') 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', () => { it('excludes profile, stats and feedback from demo tour', () => {
+10 -2
View File
@@ -26,6 +26,7 @@ export type TourStepId =
| 'entry_open' | 'entry_open'
| 'entry_track' | 'entry_track'
| 'nav_vessel' | 'nav_vessel'
| 'profile_vessel_pool'
| 'profile_crew_pool' | 'profile_crew_pool'
| 'nav_logbook_crew' | 'nav_logbook_crew'
| 'nav_stats' | 'nav_stats'
@@ -72,6 +73,7 @@ export const FULL_STEP_ORDER: TourStepId[] = [
'entry_open', 'entry_open',
'entry_track', 'entry_track',
'nav_vessel', 'nav_vessel',
'profile_vessel_pool',
'profile_crew_pool', 'profile_crew_pool',
'nav_logbook_crew', 'nav_logbook_crew',
'nav_stats', 'nav_stats',
@@ -115,6 +117,7 @@ const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
entry_open: '[data-tour="entry-first"]', entry_open: '[data-tour="entry-first"]',
entry_track: '[data-tour="entry-track"]', entry_track: '[data-tour="entry-track"]',
nav_vessel: '[data-tour="nav-vessel"]', nav_vessel: '[data-tour="nav-vessel"]',
profile_vessel_pool: '[data-tour="profile-vessel-pool"]',
profile_crew_pool: '[data-tour="profile-crew-pool"]', profile_crew_pool: '[data-tour="profile-crew-pool"]',
nav_logbook_crew: '[data-tour="nav-logbook-crew"]', nav_logbook_crew: '[data-tour="nav-logbook-crew"]',
nav_stats: '[data-tour="stats-dashboard"]', nav_stats: '[data-tour="stats-dashboard"]',
@@ -131,7 +134,12 @@ export function tourStepOpensEntry(stepId: TourStepId): boolean {
export function getTourTargetDelay(stepId: TourStepId): number { export function getTourTargetDelay(stepId: TourStepId): number {
if (stepId === 'entry_track') return 400 if (stepId === 'entry_track') return 400
if (stepId === 'nav_feedback') return 180 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 250
} }
return 0 return 0
@@ -189,7 +197,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
nav.setSelectedEntryId(null) nav.setSelectedEntryId(null)
nav.setActiveTab('vessel') nav.setActiveTab('vessel')
} }
if (stepId === 'profile_crew_pool') { if (stepId === 'profile_vessel_pool' || stepId === 'profile_crew_pool') {
nav.setSelectedEntryId(null) nav.setSelectedEntryId(null)
nav.setLogbookActive(false) nav.setLogbookActive(false)
nav.setProfileOpen(true) nav.setProfileOpen(true)
+53 -8
View File
@@ -12,6 +12,7 @@ import { useDialog } from '../components/ModalDialog.tsx'
interface UnsavedChangesContextValue { interface UnsavedChangesContextValue {
setDirty: (source: string, dirty: boolean) => void setDirty: (source: string, dirty: boolean) => void
registerSaveHandler: (source: string, handler: (() => Promise<void>) | null) => void
confirmLeave: () => Promise<boolean> confirmLeave: () => Promise<boolean>
} }
@@ -19,23 +20,51 @@ const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(n
export function UnsavedChangesProvider({ children }: { children: ReactNode }) { export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation() const { t } = useTranslation()
const { showConfirm } = useDialog() const { showConfirmLeave, showAlert } = useDialog()
const dirtySources = useRef(new Set<string>()) const dirtySources = useRef(new Set<string>())
const saveHandlers = useRef(new Map<string, () => Promise<void>>())
const setDirty = useCallback((source: string, dirty: boolean) => { const setDirty = useCallback((source: string, dirty: boolean) => {
if (dirty) dirtySources.current.add(source) if (dirty) dirtySources.current.add(source)
else dirtySources.current.delete(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> => { const confirmLeave = useCallback(async (): Promise<boolean> => {
if (dirtySources.current.size === 0) return true 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_message'),
t('common.unsaved_changes_title'), 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(() => { useEffect(() => {
const handler = (e: BeforeUnloadEvent) => { const handler = (e: BeforeUnloadEvent) => {
@@ -47,7 +76,10 @@ export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
return () => window.removeEventListener('beforeunload', handler) return () => window.removeEventListener('beforeunload', handler)
}, []) }, [])
const value = useMemo(() => ({ setDirty, confirmLeave }), [setDirty, confirmLeave]) const value = useMemo(
() => ({ setDirty, registerSaveHandler, confirmLeave }),
[setDirty, registerSaveHandler, confirmLeave]
)
return ( return (
<UnsavedChangesContext.Provider value={value}> <UnsavedChangesContext.Provider value={value}>
@@ -65,13 +97,26 @@ export function useUnsavedChangesContext(): UnsavedChangesContextValue {
} }
/** Register a form/view as having unsaved changes (cleared automatically on unmount). */ /** Register a form/view as having unsaved changes (cleared automatically on unmount). */
export function useRegisterUnsavedChanges(source: string, isDirty: boolean) { export function useRegisterUnsavedChanges(
const { setDirty, confirmLeave } = useUnsavedChangesContext() source: string,
isDirty: boolean,
onSave?: () => Promise<void>
) {
const { setDirty, registerSaveHandler, confirmLeave } = useUnsavedChangesContext()
useEffect(() => { useEffect(() => {
setDirty(source, isDirty) setDirty(source, isDirty)
return () => setDirty(source, false) return () => setDirty(source, false)
}, [source, isDirty, setDirty]) }, [source, isDirty, setDirty])
useEffect(() => {
if (!onSave) {
registerSaveHandler(source, null)
return
}
registerSaveHandler(source, onSave)
return () => registerSaveHandler(source, null)
}, [source, onSave, registerSaveHandler])
return { confirmLeave } return { confirmLeave }
} }
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { decryptJson } from '../services/crypto.js'
import type { PreloadedVoiceMemo } from '../components/VoiceMemoPlayer.tsx'
export function useEntryVoiceMemos(
logbookId: string,
entryId: string | null,
preloaded?: PreloadedVoiceMemo[]
): Map<string, PreloadedVoiceMemo> {
const localMemos = useLiveQuery(
() => (entryId ? db.voiceMemos.where({ entryId }).toArray() : []),
[entryId]
)
const [lookup, setLookup] = useState<Map<string, PreloadedVoiceMemo>>(new Map())
useEffect(() => {
if (preloaded && preloaded.length > 0) {
const map = new Map<string, PreloadedVoiceMemo>()
for (const m of preloaded) {
map.set(m.payloadId, m)
}
setLookup(map)
return
}
if (!entryId || !localMemos) {
setLookup(new Map())
return
}
let cancelled = false
void (async () => {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey || cancelled) return
const map = new Map<string, PreloadedVoiceMemo>()
for (const row of localMemos) {
try {
const decrypted = await decryptJson(row.encryptedData, row.iv, row.tag, masterKey)
if (!decrypted?.audio) continue
map.set(row.payloadId, {
payloadId: row.payloadId,
audio: String(decrypted.audio),
mimeType: decrypted.mimeType ? String(decrypted.mimeType) : undefined,
durationSec: typeof decrypted.durationSec === 'number' ? decrypted.durationSec : undefined,
caption: decrypted.caption ? String(decrypted.caption) : ''
})
} catch {
// skip corrupt memo
}
}
if (!cancelled) setLookup(map)
})()
return () => {
cancelled = true
}
}, [localMemos, entryId, logbookId, preloaded])
return lookup
}
+4 -2
View File
@@ -42,12 +42,14 @@ function scheduleUpdateChecks(
const onVisibilityChange = () => { const onVisibilityChange = () => {
if (document.visibilityState === 'visible') { if (document.visibilityState === 'visible') {
checkForUpdate() // Delay check on wake-up to allow the mobile network stack to stabilize
setTimeout(checkForUpdate, 2000)
} }
} }
const onOnline = () => { const onOnline = () => {
checkForUpdate() // Small delay to ensure connection is fully established
setTimeout(checkForUpdate, 500)
} }
document.addEventListener('visibilitychange', onVisibilityChange) document.addEventListener('visibilitychange', onVisibilityChange)
+130 -24
View File
@@ -6,6 +6,10 @@
"beta": "Beta", "beta": "Beta",
"beta_hint": "Betaversion - funktioner kan stadig ændres" "beta_hint": "Betaversion - funktioner kan stadig ændres"
}, },
"footer": {
"kofi_label": "Ko-fi",
"kofi_title": "Støt projektet, videreudvikling og driftsomkostninger på Ko-fi"
},
"languages": { "languages": {
"de": "Deutsch", "de": "Deutsch",
"en": "English", "en": "English",
@@ -27,8 +31,10 @@
"common": { "common": {
"unsaved_changes_title": "Ikke gemte ændringer", "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_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": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -243,13 +249,13 @@
"live_sails_confirm": "Indtast", "live_sails_confirm": "Indtast",
"live_sails_confirm_count": "Indtast ({{count}})", "live_sails_confirm_count": "Indtast ({{count}})",
"live_sails": "Sejl: {{sails}}", "live_sails": "Sejl: {{sails}}",
"live_fix": "Fix", "live_position": "Position",
"live_fix_coords": "Fix {{lat}}, {{lng}}", "live_position_coords": "Position {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS ikke tilgængelig. Indtast bredde- og længdegrad manuelt, eller prøv igen med GPS-knappen.", "live_position_manual_hint": "GPS ikke tilgængelig. Indtast bredde- og længdegrad manuelt, eller prøv igen med GPS-knappen.",
"live_fix_gps_loading": "Henter GPS-position…", "live_position_gps_loading": "Henter GPS-position…",
"live_fix_invalid": "Indtast gyldige koordinater (bredde 90…90, længde 180…180).", "live_position_invalid": "Indtast gyldige koordinater (bredde 90…90, længde 180…180).",
"live_fix_lat_placeholder": "Bredde (Lat)", "live_position_lat_placeholder": "Bredde (Lat)",
"live_fix_lng_placeholder": "Længde (Lng)", "live_position_lng_placeholder": "Længde (Lng)",
"live_photo_btn": "Foto (kamera)", "live_photo_btn": "Foto (kamera)",
"live_photo_capture_btn": "Tag billede", "live_photo_capture_btn": "Tag billede",
"live_photo_save_btn": "Gem", "live_photo_save_btn": "Gem",
@@ -260,10 +266,29 @@
"live_photo_camera_starting": "Starter kamera…", "live_photo_camera_starting": "Starter kamera…",
"live_photo_camera_denied": "Kameraadgang nægtet eller utilgængelig.", "live_photo_camera_denied": "Kameraadgang nægtet eller utilgængelig.",
"live_photo_camera_unavailable": "Kamera understøttes ikke i denne browser.", "live_photo_camera_unavailable": "Kamera understøttes ikke i denne browser.",
"live_photo_no_camera": "Der er intet kamera tilgængeligt på denne enhed.",
"live_photo_error": "Foto kunne ikke gemmes.", "live_photo_error": "Foto kunne ikke gemmes.",
"live_photo_entry": "Foto: {{caption}}", "live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto taget", "live_photo_entry_plain": "Foto taget",
"live_undo_photo_hint": "Foto gemt", "live_undo_photo_hint": "Foto gemt",
"live_voice_btn": "Stemmenotat",
"live_voice_hint": "Optag en kort stemmenotat (maks. 60 sekunder).",
"live_voice_record": "Start optagelse",
"live_voice_stop": "Stop optagelse",
"live_voice_recording": "Optager {{time}}",
"live_voice_save": "Gem",
"live_voice_saving": "Gemmer…",
"live_voice_retake": "Optag igen",
"live_voice_mic_denied": "Mikrofonadgang nægtet eller utilgængelig.",
"live_voice_record_failed": "Optagelse mislykkedes. Prøv igen.",
"live_voice_unavailable": "Stemmenotat utilgængelig",
"live_voice_too_large": "Optagelsen er for stor. Optag venligst kortere.",
"live_voice_error": "Kunne ikke gemme stemmenotat.",
"live_voice_entry": "Stemmenotat: {{caption}}",
"live_voice_entry_plain": "Stemmenotat",
"live_voice_caption_label": "Billedtekst (valgfrit)",
"live_voice_caption_placeholder": "f.eks. radiokontakt med havnemester",
"live_undo_voice_hint": "Stemmenotat gemt",
"live_comment_btn": "Kommentar", "live_comment_btn": "Kommentar",
"live_comment_placeholder": "Indtast tekst…", "live_comment_placeholder": "Indtast tekst…",
"live_comment_confirm": "Indtast", "live_comment_confirm": "Indtast",
@@ -273,31 +298,35 @@
"live_weather_btn": "Vejr", "live_weather_btn": "Vejr",
"live_weather_owm_btn": "Hent OpenWeatherMap-vejr", "live_weather_owm_btn": "Hent OpenWeatherMap-vejr",
"live_weather_owm_loading": "Henter vejr…", "live_weather_owm_loading": "Henter vejr…",
"live_weather_fix_required": "Log først en GPS-fix (Fix-knap) for at hente OpenWeatherMap-vejr. Positionen må højst være 6 timer gammel.", "live_weather_position_required": "Log først en position (Position-knap) for at hente OpenWeatherMap-vejr. Positionen må højst være 6 timer gammel.",
"live_weather_fix_stale": "Den seneste GPS-fix er ældre end 6 timer. Log en ny fix, før du henter vejr.", "live_weather_position_stale": "Den seneste position er ældre end 6 timer. Log en ny position, før du henter vejr.",
"live_wind_btn": "Vind", "live_wind_btn": "Vind",
"live_temp_btn": "T °C", "live_temp_btn": "T °C",
"live_pressure_btn": "Lufttryk", "live_pressure_btn": "Lufttryk",
"live_precip_btn": "Nedbør", "live_precip_btn": "Nedbør",
"live_sea_state_btn": "Søgang", "live_sea_state_btn": "Søgang",
"live_visibility_btn": "Sigtbarhed",
"live_course_btn": "Kurs", "live_course_btn": "Kurs",
"live_fuel_btn": "Diesel", "live_fuel_btn": "+ Diesel",
"live_water_btn": "Vand", "live_water_btn": "+ Vand",
"live_wind_entry": "Vind {{value}}", "live_wind_entry": "Vind {{value}}",
"live_temp_entry": "Temperatur {{temp}} °C", "live_temp_entry": "Temperatur {{temp}} °C",
"live_pressure_entry": "Lufttryk {{value}} hPa", "live_pressure_entry": "Lufttryk {{value}} hPa",
"live_precip_entry": "Nedbør {{value}}", "live_precip_entry": "Nedbør {{value}}",
"live_sea_state_entry": "Søgang {{value}}", "live_sea_state_entry": "Søgang {{value}}",
"live_visibility_entry": "Sigtbarhed {{value}}",
"live_course_entry": "Kurs {{course}}", "live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L", "live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Vand +{{liters}} L", "live_water_entry": "Vand +{{liters}} L",
"live_auto_position": "Auto-position", "live_auto_position": "Auto-position",
"live_undo_hint": "Indtastning gemt", "live_undo_hint": "Indtastning gemt",
"live_undo_btn": "Fortryd", "live_undo_btn": "Fortryd",
"live_cancel": "Annuller",
"live_pressure_placeholder": "f.eks. 1013", "live_pressure_placeholder": "f.eks. 1013",
"live_temp_placeholder": "f.eks. 18", "live_temp_placeholder": "f.eks. 18",
"live_precip_placeholder": "f.eks. let regn", "live_precip_placeholder": "f.eks. let regn",
"live_sea_state_placeholder": "f.eks. 3", "live_sea_state_placeholder": "f.eks. 3",
"live_visibility_placeholder": "f.eks. 10 km",
"live_course_placeholder": "f.eks. 245", "live_course_placeholder": "f.eks. 245",
"live_fuel_placeholder": "Optankede liter", "live_fuel_placeholder": "Optankede liter",
"live_water_placeholder": "Optankede liter", "live_water_placeholder": "Optankede liter",
@@ -339,6 +368,12 @@
"event_wind_direction": "Vindretning", "event_wind_direction": "Vindretning",
"event_wind_strength": "Vindstyrke", "event_wind_strength": "Vindstyrke",
"event_sea_state": "Havets tilstand", "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_weather": "Vejret",
"event_log": "Log (sm)", "event_log": "Log (sm)",
"event_gps": "GPS-position", "event_gps": "GPS-position",
@@ -346,7 +381,26 @@
"event_location_placeholder": "z. f.eks. Kiel", "event_location_placeholder": "z. f.eks. Kiel",
"event_remarks": "Bemærkninger / hændelser", "event_remarks": "Bemærkninger / hændelser",
"gps_btn": "Hent GPS-koordinater", "gps_btn": "Hent GPS-koordinater",
"gps_permission_denied": "Adgang til placering blev nægtet. Tillad det i browser- eller enhedsindstillinger og prøv igen.",
"gps_timeout": "GPS fik timeout. Prøv igen udendørs med frit udsyn til himlen.",
"gps_position_unavailable": "Intet GPS-signal tilgængeligt. Vent og prøv igen, eller indtast koordinater manuelt.",
"gps_unavailable": "GPS understøttes ikke af denne browser eller enhed.",
"gps_failed": "GPS-position kunne ikke bestemmes.",
"gps_fallback_no_location": "GPS mislykkedes. Angiv et sted under placering/havn, afgang eller destination, eller indtast koordinater manuelt.",
"gps_fallback_success": "Koordinater for \"{{location}}\" fundet via stedsnavn (ikke GPS).",
"gps_fallback_failed": "GPS og stedsnavnssøgning mislykkedes. Indtast koordinater manuelt.",
"gps_quality_excellent": "Stærk GPS-modtagelse (±{{accuracy}} m)",
"gps_quality_good": "God GPS-modtagelse (±{{accuracy}} m)",
"gps_quality_fair": "Middel GPS-modtagelse (±{{accuracy}} m) gå udendørs for bedre signal.",
"gps_quality_poor": "Svag GPS-modtagelse (±{{accuracy}} m) sandsynligvis få satellitter. Prøv udendørs igen eller kontroller positionen.",
"gps_quality_unknown": "GPS-position overtaget (nøjagtighed ikke rapporteret af enheden).",
"gps_live_intro_title": "Placering til live-log",
"gps_live_intro_body": "Appen har brug for din placering til automatiske positionsindlæg og GPS-knappen.\n\nTryk på „Tillad placering“ og bekræft i den næste dialog. Du kan altid indtaste position manuelt via „Position“.",
"gps_live_intro_allow": "Tillad placering",
"gps_live_intro_later": "Senere",
"gps_enable_in_settings_hint": "Adgang til placering er blokeret. Du kan tillade det senere i browser- eller enhedsindstillinger (websted / app → Placering).",
"weather_btn": "OpenWeatherMap Kald vejret op", "weather_btn": "OpenWeatherMap Kald vejret op",
"weather_offline": "OpenWeatherMap kræver internetforbindelse. Du er offline lige nu.",
"event_wind_pressure": "Lufttryk (hPa)", "event_wind_pressure": "Lufttryk (hPa)",
"event_heel": "Krængning (°)", "event_heel": "Krængning (°)",
"event_sails": "Sejlhåndtering/motor", "event_sails": "Sejlhåndtering/motor",
@@ -360,6 +414,18 @@
"share_csv": "CSV andel", "share_csv": "CSV andel",
"export_pdf": "Download PDF.", "export_pdf": "Download PDF.",
"exporting_pdf": "PDF er genereret...", "exporting_pdf": "PDF er genereret...",
"ai_summary_title": "AI-resumé",
"ai_summary_read_only": "Oprettet af skipperen — kun læsning for besætningen.",
"ai_summary_empty": "Intet resumé endnu.",
"ai_summary_generate": "Generér resumé",
"ai_summary_regenerate": "Generér igen",
"ai_summary_generating": "Genererer…",
"ai_summary_attempts_remaining": "{{remaining}} af {{max}} forsøg tilbage",
"ai_summary_error": "AI-resumé mislykkedes. Prøv igen senere.",
"ai_summary_error_no_key": "Ingen OpenRouter API-nøgle konfigureret på serveren.",
"ai_summary_error_rate_limited": "Maksimalt antal genereringer nået for denne rejsedag.",
"ai_summary_error_forbidden": "Kun skipperen må generere AI-resuméer.",
"ai_summary_offline": "AI-resumé kræver internetforbindelse. Du er offline lige nu.",
"photos_title": "Vedhæftede billeder (E2E-krypteret)", "photos_title": "Vedhæftede billeder (E2E-krypteret)",
"photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)", "photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)",
"photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen", "photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen",
@@ -438,8 +504,8 @@
"nmea_change_engine_stop": "Engine off", "nmea_change_engine_stop": "Engine off",
"nmea_change_autopilot_on": "Autopilot on", "nmea_change_autopilot_on": "Autopilot on",
"nmea_change_autopilot_off": "Autopilot off", "nmea_change_autopilot_off": "Autopilot off",
"nmea_change_gps_lost": "GPS fix lost", "nmea_change_gps_lost": "GPS-position mistet",
"nmea_change_gps_regained": "GPS fix restored", "nmea_change_gps_regained": "GPS-position gendannet",
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C", "nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway", "nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop", "nmea_change_anchor": "Anchored / stop",
@@ -453,7 +519,7 @@
"new_logbook_placeholder": "Navn på logbog eller yacht", "new_logbook_placeholder": "Navn på logbog eller yacht",
"logout": "Log ud", "logout": "Log ud",
"logged_in_as": "Logget ind som {{name}}", "logged_in_as": "Logget ind som {{name}}",
"delete_confirm": "Er du sikker på, at du vil slette denne logbog permanent? Alle lokale data og serverkopier vil blive destrueret.\n\nTip: Lav en sikkerhedskopi (.daagbok.json) på forhånd under Indstillinger → Sikkerhedskopiering og gendannelse, hvis du vil beholde dataene senere.", "delete_confirm": "Er du sikker på, at du vil slette denne logbog permanent? Alle lokale data og serverkopier vil blive destrueret.\n\nTip: Lav en sikkerhedskopi (.daagbok) på forhånd under Indstillinger → Sikkerhedskopiering og gendannelse, hvis du vil beholde dataene senere.",
"no_logbooks": "Ingen logbøger fundet. Opret din første logbog for at komme i gang!", "no_logbooks": "Ingen logbøger fundet. Opret din første logbog for at komme i gang!",
"loading": "Logbøgerne er fyldt op...", "loading": "Logbøgerne er fyldt op...",
"status_synced": "Synkroniseret", "status_synced": "Synkroniseret",
@@ -475,7 +541,7 @@
"edit_success": "Logbog omdøbt med succes", "edit_success": "Logbog omdøbt med succes",
"edit_btn": "Omdøb", "edit_btn": "Omdøb",
"filter_label": "Filtrer logbøger", "filter_label": "Filtrer logbøger",
"filter_placeholder": "Navn, årstal eller dato ...", "filter_placeholder": "Navn, årstal, dato, crew eller skib …",
"filter_clear": "Nulstil filter", "filter_clear": "Nulstil filter",
"filter_results": "{{count}} Hits", "filter_results": "{{count}} Hits",
"filter_no_results": "Ingen logbøger matcher din søgning. Prøv med et andet navn eller et andet år.", "filter_no_results": "Ingen logbøger matcher din søgning. Prøv med et andet navn eller et andet år.",
@@ -606,7 +672,37 @@
"push_unsupported": "Push-meddelelser understøttes ikke i denne browser.", "push_unsupported": "Push-meddelelser understøttes ikke i denne browser.",
"push_denied_hint": "Notifikationer er blokeret. Tillad dem i browserens eller enhedens indstillinger.", "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_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": { "person_pool": {
"title": "Stamm-Crew og skippere", "title": "Stamm-Crew og skippere",
@@ -702,7 +798,7 @@
"delete_account_confirm_yes": "Ja, slet konto og alle data", "delete_account_confirm_yes": "Ja, slet konto og alle data",
"delete_account_confirm_no": "Annuller", "delete_account_confirm_no": "Annuller",
"delete_account_failed": "Kontoen kunne ikke slettes. Prøv venligst igen.", "delete_account_failed": "Kontoen kunne ikke slettes. Prøv venligst igen.",
"delete_backup_hint": "Tip: Lav sikkerhedskopier af dine logbøger (.daagbok.json) i indstillingerne for hver logbog, før du sletter dem.", "delete_backup_hint": "Tip: Lav sikkerhedskopier af dine logbøger (.daagbok) i indstillingerne for hver logbog, før du sletter dem.",
"deleting_account": "Kontoen vil blive slettet...", "deleting_account": "Kontoen vil blive slettet...",
"invite_push_prompt_title": "Aktivere push-meddelelser?", "invite_push_prompt_title": "Aktivere push-meddelelser?",
"invite_push_prompt_message": "Så snart inviterede Crew-medlemmer synkroniserer ændringer, kan du blive informeret via push. Intet logbogsindhold sendes i almindelig tekst.", "invite_push_prompt_message": "Så snart inviterede Crew-medlemmer synkroniserer ændringer, kan du blive informeret via push. Intet logbogsindhold sendes i almindelig tekst.",
@@ -713,7 +809,7 @@
"backup_title": "Sikkerhedskopiering og gendannelse", "backup_title": "Sikkerhedskopiering og gendannelse",
"backup_desc": "Komplet krypteret backup af denne logbog (poster, fotos, GPS-spor, crew, skib). Beskyttet med backup-passphrase - til gendannelse til denne eller en ny konto.", "backup_desc": "Komplet krypteret backup af denne logbog (poster, fotos, GPS-spor, crew, skib). Beskyttet med backup-passphrase - til gendannelse til denne eller en ny konto.",
"backup_export_title": "Opret backup", "backup_export_title": "Opret backup",
"backup_export_desc": "Downloader alle lokale data som .daagbok.json. Hold filen og adgangssætningen adskilt og sikker.", "backup_export_desc": "Downloader alle lokale data som et komprimeret .daagbok-arkiv. Hold filen og adgangssætningen adskilt og sikker.",
"backup_restore_title": "Gendan sikkerhedskopi", "backup_restore_title": "Gendan sikkerhedskopi",
"backup_restore_desc": "Gendanner en sikkerhedskopi til din nuværende konto - selv efter registrering af en ny konto.", "backup_restore_desc": "Gendanner en sikkerhedskopi til din nuværende konto - selv efter registrering af en ny konto.",
"backup_passphrase": "Backup-passphrase", "backup_passphrase": "Backup-passphrase",
@@ -725,7 +821,13 @@
"backup_export_btn": "Download backup", "backup_export_btn": "Download backup",
"backup_exporting": "Sikkerhedskopien er oprettet...", "backup_exporting": "Sikkerhedskopien er oprettet...",
"backup_export_success": "Backup oprettet ({{count}} rejsedage).", "backup_export_success": "Backup oprettet ({{count}} rejsedage).",
"backup_file_label": "Backup-fil (.daagbok.json)", "backup_file_label": "Backup-fil (.daagbok)",
"backup_export_progress": "Pakker filer {{current}} / {{total}}…",
"backup_invalid_archive": "Filen er ikke et gyldigt backup-arkiv.",
"backup_version_unsupported": "Gammelt backup-format (v1). Brug en aktuel .daagbok-backup.",
"backup_import_size_confirm": "Denne backup er ca. {{size}} ukomprimeret. Gendannelse kan tage længere tid. Fortsæt?",
"backup_stat_voice": "{{count}} stemmenotater",
"backup_stat_size": "Ca. {{size}} ukomprimeret",
"backup_preview_btn": "Tjek indhold", "backup_preview_btn": "Tjek indhold",
"backup_previewing": "Tjek...", "backup_previewing": "Tjek...",
"backup_restore_btn": "Gendan", "backup_restore_btn": "Gendan",
@@ -883,7 +985,7 @@
}, },
"welcome_public": { "welcome_public": {
"title": "Velkommen om bord!", "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": { "nav_logs": {
"title": "Indlæg i logbogen", "title": "Indlæg i logbogen",
@@ -902,8 +1004,12 @@
"body": "Upload GPX-filer, eller se allerede gemte ruter på kortet - inklusive afstand og hastighed." "body": "Upload GPX-filer, eller se allerede gemte ruter på kortet - inklusive afstand og hastighed."
}, },
"nav_vessel": { "nav_vessel": {
"title": "Skibsdata", "title": "Skib for logbog",
"body": "Indtast navn, dimensioner og tekniske data for din yacht - udfyld én gang, tilgængelig for alle rejsedage." "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": { "profile_crew_pool": {
"title": "Stamm-Crew og skippere", "title": "Stamm-Crew og skippere",
+132 -26
View File
@@ -6,6 +6,10 @@
"beta": "Beta", "beta": "Beta",
"beta_hint": "Beta-Version — Funktionen können sich noch ändern" "beta_hint": "Beta-Version — Funktionen können sich noch ändern"
}, },
"footer": {
"kofi_label": "Ko-fi",
"kofi_title": "Projekt, Weiterentwicklung und Betriebskosten auf Ko-fi unterstützen"
},
"languages": { "languages": {
"de": "Deutsch", "de": "Deutsch",
"en": "English", "en": "English",
@@ -27,8 +31,10 @@
"common": { "common": {
"unsaved_changes_title": "Ungespeicherte Änderungen", "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_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": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -243,13 +249,13 @@
"live_sails_confirm": "Eintragen", "live_sails_confirm": "Eintragen",
"live_sails_confirm_count": "Eintragen ({{count}})", "live_sails_confirm_count": "Eintragen ({{count}})",
"live_sails": "Segel: {{sails}}", "live_sails": "Segel: {{sails}}",
"live_fix": "Fix", "live_position": "Position",
"live_fix_coords": "Fix {{lat}}, {{lng}}", "live_position_coords": "Position {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS nicht verfügbar. Breiten- und Längengrad manuell eingeben oder erneut per GPS-Knopf versuchen.", "live_position_manual_hint": "GPS nicht verfügbar. Breiten- und Längengrad manuell eingeben oder erneut per GPS-Knopf versuchen.",
"live_fix_gps_loading": "GPS-Position wird ermittelt…", "live_position_gps_loading": "GPS-Position wird ermittelt…",
"live_fix_invalid": "Bitte gültige Koordinaten eingeben (Breite 90…90, Länge 180…180).", "live_position_invalid": "Bitte gültige Koordinaten eingeben (Breite 90…90, Länge 180…180).",
"live_fix_lat_placeholder": "Breite (Lat)", "live_position_lat_placeholder": "Breite (Lat)",
"live_fix_lng_placeholder": "Länge (Lng)", "live_position_lng_placeholder": "Länge (Lng)",
"live_photo_btn": "Foto (Kamera)", "live_photo_btn": "Foto (Kamera)",
"live_photo_capture_btn": "Aufnehmen", "live_photo_capture_btn": "Aufnehmen",
"live_photo_save_btn": "Speichern", "live_photo_save_btn": "Speichern",
@@ -260,44 +266,67 @@
"live_photo_camera_starting": "Kamera wird gestartet…", "live_photo_camera_starting": "Kamera wird gestartet…",
"live_photo_camera_denied": "Kamerazugriff verweigert oder nicht verfügbar.", "live_photo_camera_denied": "Kamerazugriff verweigert oder nicht verfügbar.",
"live_photo_camera_unavailable": "Kamera wird von diesem Browser nicht unterstützt.", "live_photo_camera_unavailable": "Kamera wird von diesem Browser nicht unterstützt.",
"live_photo_no_camera": "Auf diesem Gerät ist keine Kamera verfügbar.",
"live_photo_error": "Foto konnte nicht gespeichert werden.", "live_photo_error": "Foto konnte nicht gespeichert werden.",
"live_photo_entry": "Foto: {{caption}}", "live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto aufgenommen", "live_photo_entry_plain": "Foto aufgenommen",
"live_undo_photo_hint": "Foto gespeichert", "live_undo_photo_hint": "Foto gespeichert",
"live_voice_btn": "Sprachnotiz",
"live_voice_hint": "Kurze Sprachnotiz aufnehmen (max. 60 Sekunden).",
"live_voice_record": "Aufnahme starten",
"live_voice_stop": "Aufnahme beenden",
"live_voice_recording": "Aufnahme {{time}}",
"live_voice_save": "Speichern",
"live_voice_saving": "Wird gespeichert…",
"live_voice_retake": "Neu aufnehmen",
"live_voice_mic_denied": "Mikrofonzugriff verweigert oder nicht verfügbar.",
"live_voice_record_failed": "Aufnahme fehlgeschlagen. Bitte erneut versuchen.",
"live_voice_unavailable": "Sprachnotiz nicht verfügbar",
"live_voice_too_large": "Aufnahme ist zu groß. Bitte kürzer aufnehmen.",
"live_voice_error": "Sprachnotiz konnte nicht gespeichert werden.",
"live_voice_entry": "Sprachnotiz: {{caption}}",
"live_voice_entry_plain": "Sprachnotiz",
"live_voice_caption_label": "Beschriftung (optional)",
"live_voice_caption_placeholder": "z. B. Funkverkehr mit Hafenmeister",
"live_undo_voice_hint": "Sprachnotiz gespeichert",
"live_comment_btn": "Kommentar", "live_comment_btn": "Kommentar",
"live_comment_placeholder": "Freitext eingeben…", "live_comment_placeholder": "Freitext eingeben…",
"live_comment_confirm": "Eintragen", "live_comment_confirm": "Eintragen",
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.", "live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
"live_gps_start_hint": "Beginne deine Tagesreise immer mit einem Standort.", "live_gps_start_hint": "Beginne deine Tagesreise immer mit einer Position.",
"live_event_generic": "Ereignis", "live_event_generic": "Ereignis",
"live_weather_btn": "Wetter", "live_weather_btn": "Wetter",
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen", "live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
"live_weather_owm_loading": "Wetter wird geladen…", "live_weather_owm_loading": "Wetter wird geladen…",
"live_weather_fix_required": "Für Wetter von OpenWeatherMap zuerst einen GPS-Fix eintragen (Schaltfläche „Fix“). Die Position darf höchstens 6 Stunden alt sein.", "live_weather_position_required": "Für Wetter von OpenWeatherMap zuerst eine Position eintragen (Schaltfläche „Position“). Die Position darf höchstens 6 Stunden alt sein.",
"live_weather_fix_stale": "Der letzte GPS-Fix ist älter als 6 Stunden. Bitte erneut einen Fix loggen, bevor du Wetter abrufst.", "live_weather_position_stale": "Die letzte Position ist älter als 6 Stunden. Bitte erneut eine Position loggen, bevor du Wetter abrufst.",
"live_wind_btn": "Wind", "live_wind_btn": "Wind",
"live_temp_btn": "T °C", "live_temp_btn": "T °C",
"live_pressure_btn": "Luftdruck", "live_pressure_btn": "Luftdruck",
"live_precip_btn": "Niederschlag", "live_precip_btn": "Niederschlag",
"live_sea_state_btn": "Seegang", "live_sea_state_btn": "Seegang",
"live_visibility_btn": "Sichtweite",
"live_course_btn": "Kurs", "live_course_btn": "Kurs",
"live_fuel_btn": "Diesel", "live_fuel_btn": "+ Diesel",
"live_water_btn": "Wasser", "live_water_btn": "+ Wasser",
"live_wind_entry": "Wind {{value}}", "live_wind_entry": "Wind {{value}}",
"live_temp_entry": "Temperatur {{temp}} °C", "live_temp_entry": "Temperatur {{temp}} °C",
"live_pressure_entry": "Luftdruck {{value}} hPa", "live_pressure_entry": "Luftdruck {{value}} hPa",
"live_precip_entry": "Niederschlag {{value}}", "live_precip_entry": "Niederschlag {{value}}",
"live_sea_state_entry": "Seegang {{value}}", "live_sea_state_entry": "Seegang {{value}}",
"live_visibility_entry": "Sichtweite {{value}}",
"live_course_entry": "Kurs {{course}}", "live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L", "live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Wasser +{{liters}} L", "live_water_entry": "Wasser +{{liters}} L",
"live_auto_position": "Auto-Position", "live_auto_position": "Auto-Position",
"live_undo_hint": "Eintrag gespeichert", "live_undo_hint": "Eintrag gespeichert",
"live_undo_btn": "Rückgängig", "live_undo_btn": "Rückgängig",
"live_cancel": "Abbruch",
"live_pressure_placeholder": "z. B. 1013", "live_pressure_placeholder": "z. B. 1013",
"live_temp_placeholder": "z. B. 18", "live_temp_placeholder": "z. B. 18",
"live_precip_placeholder": "z. B. leichter Regen", "live_precip_placeholder": "z. B. leichter Regen",
"live_sea_state_placeholder": "z. B. 3", "live_sea_state_placeholder": "z. B. 3",
"live_visibility_placeholder": "z. B. 10 km",
"live_course_placeholder": "z. B. 245", "live_course_placeholder": "z. B. 245",
"live_fuel_placeholder": "Nachgefüllte Liter", "live_fuel_placeholder": "Nachgefüllte Liter",
"live_water_placeholder": "Nachgefüllte Liter", "live_water_placeholder": "Nachgefüllte Liter",
@@ -339,6 +368,12 @@
"event_wind_direction": "Wind-Richtung", "event_wind_direction": "Wind-Richtung",
"event_wind_strength": "Windstärke", "event_wind_strength": "Windstärke",
"event_sea_state": "Seegang", "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_weather": "Wetter",
"event_log": "Logge (sm)", "event_log": "Logge (sm)",
"event_gps": "GPS-Position", "event_gps": "GPS-Position",
@@ -346,7 +381,26 @@
"event_location_placeholder": "z. B. Kiel", "event_location_placeholder": "z. B. Kiel",
"event_remarks": "Bemerkungen / Vorkommnisse", "event_remarks": "Bemerkungen / Vorkommnisse",
"gps_btn": "GPS-Koordinaten abrufen", "gps_btn": "GPS-Koordinaten abrufen",
"gps_permission_denied": "Standortzugriff wurde verweigert. Bitte in den Browser- oder Geräteeinstellungen erlauben und erneut versuchen.",
"gps_timeout": "GPS-Zeitüberschreitung. Bitte erneut versuchen am besten im Freien mit gutem Empfang.",
"gps_position_unavailable": "Kein GPS-Signal verfügbar. Bitte warten oder Koordinaten manuell eingeben.",
"gps_unavailable": "GPS wird von diesem Browser oder Gerät nicht unterstützt.",
"gps_failed": "GPS-Position konnte nicht ermittelt werden.",
"gps_fallback_no_location": "GPS fehlgeschlagen. Bitte einen Ort unter „Ort / Hafen“, Start- oder Zielhafen eintragen, oder Koordinaten manuell eingeben.",
"gps_fallback_success": "Koordinaten für „{{location}}“ über den Ortsnamen ermittelt (nicht per GPS).",
"gps_fallback_failed": "GPS und Ortsnamen-Suche sind fehlgeschlagen. Bitte Koordinaten manuell eingeben.",
"gps_quality_excellent": "Starker GPS-Empfang (±{{accuracy}} m)",
"gps_quality_good": "Guter GPS-Empfang (±{{accuracy}} m)",
"gps_quality_fair": "Mäßiger GPS-Empfang (±{{accuracy}} m) für besseren Empfang ins Freie gehen.",
"gps_quality_poor": "Schwacher GPS-Empfang (±{{accuracy}} m) vermutlich wenig Satelliten. Im Freien erneut versuchen oder Position prüfen.",
"gps_quality_unknown": "GPS-Position übernommen (Genauigkeit vom Gerät nicht gemeldet).",
"gps_live_intro_title": "Standort für Live-Log",
"gps_live_intro_body": "Für automatische Positions-Einträge und den GPS-Knopf braucht die App Zugriff auf deinen Standort.\n\nTippe auf „Standort erlauben“ im nächsten Dialog die Freigabe bestätigen. Du kannst jederzeit manuell unter „Position“ eintragen.",
"gps_live_intro_allow": "Standort erlauben",
"gps_live_intro_later": "Später",
"gps_enable_in_settings_hint": "Standortzugriff ist blockiert. In den Browser- oder Geräteeinstellungen (Website / App → Standort) kannst du die Freigabe nachträglich erlauben.",
"weather_btn": "OpenWeatherMap Wetter abrufen", "weather_btn": "OpenWeatherMap Wetter abrufen",
"weather_offline": "OpenWeatherMap erfordert eine Internetverbindung. Du bist derzeit offline.",
"event_wind_pressure": "Luftdruck (hPa)", "event_wind_pressure": "Luftdruck (hPa)",
"event_heel": "Krängung (°)", "event_heel": "Krängung (°)",
"event_sails": "Segelführung / Motor", "event_sails": "Segelführung / Motor",
@@ -360,6 +414,18 @@
"share_csv": "CSV teilen", "share_csv": "CSV teilen",
"export_pdf": "PDF herunterladen", "export_pdf": "PDF herunterladen",
"exporting_pdf": "PDF wird generiert...", "exporting_pdf": "PDF wird generiert...",
"ai_summary_title": "KI-Zusammenfassung",
"ai_summary_read_only": "Vom Skipper erstellt — nur lesbar für die Crew.",
"ai_summary_empty": "Noch keine Zusammenfassung vorhanden.",
"ai_summary_generate": "Zusammenfassung generieren",
"ai_summary_regenerate": "Neu generieren",
"ai_summary_generating": "Wird generiert…",
"ai_summary_attempts_remaining": "Noch {{remaining}} von {{max}} Versuchen",
"ai_summary_error": "KI-Zusammenfassung fehlgeschlagen. Bitte später erneut versuchen.",
"ai_summary_error_no_key": "Kein OpenRouter API-Schlüssel auf dem Server konfiguriert.",
"ai_summary_error_rate_limited": "Maximale Anzahl an Generierungen für diesen Reisetag erreicht.",
"ai_summary_error_forbidden": "Nur der Skipper darf KI-Zusammenfassungen generieren.",
"ai_summary_offline": "Die KI-Zusammenfassung erfordert eine Internetverbindung. Du bist derzeit offline.",
"photos_title": "Foto-Anhänge (E2E-verschlüsselt)", "photos_title": "Foto-Anhänge (E2E-verschlüsselt)",
"photo_caption_label": "Foto-Beschreibung / Label (Optional)", "photo_caption_label": "Foto-Beschreibung / Label (Optional)",
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt", "photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
@@ -428,8 +494,8 @@
"nmea_change_engine_stop": "Motor aus", "nmea_change_engine_stop": "Motor aus",
"nmea_change_autopilot_on": "Autopilot ein", "nmea_change_autopilot_on": "Autopilot ein",
"nmea_change_autopilot_off": "Autopilot aus", "nmea_change_autopilot_off": "Autopilot aus",
"nmea_change_gps_lost": "GPS-Fix verloren", "nmea_change_gps_lost": "GPS-Position verloren",
"nmea_change_gps_regained": "GPS-Fix wiederhergestellt", "nmea_change_gps_regained": "GPS-Position wiederhergestellt",
"nmea_change_water_temp": "Wassertemp. {{from}} → {{to}} °C", "nmea_change_water_temp": "Wassertemp. {{from}} → {{to}} °C",
"nmea_change_departure": "Abfahrt / Fahrtbeginn", "nmea_change_departure": "Abfahrt / Fahrtbeginn",
"nmea_change_anchor": "Ankern / Stop", "nmea_change_anchor": "Ankern / Stop",
@@ -453,7 +519,7 @@
"new_logbook_placeholder": "Name des Logbuchs oder der Yacht", "new_logbook_placeholder": "Name des Logbuchs oder der Yacht",
"logout": "Abmelden", "logout": "Abmelden",
"logged_in_as": "Angemeldet als {{name}}", "logged_in_as": "Angemeldet als {{name}}",
"delete_confirm": "Bist du sicher, dass du dieses Logbuch unwiderruflich löschen möchtest? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstelle vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok.json), falls du die Daten später behalten möchtest.", "delete_confirm": "Bist du sicher, dass du dieses Logbuch unwiderruflich löschen möchtest? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstelle vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok), falls du die Daten später behalten möchtest.",
"no_logbooks": "Keine Logbücher gefunden. Erstelle dein erstes Logbuch, um zu beginnen!", "no_logbooks": "Keine Logbücher gefunden. Erstelle dein erstes Logbuch, um zu beginnen!",
"loading": "Logbücher werden geladen...", "loading": "Logbücher werden geladen...",
"status_synced": "Synchronisiert", "status_synced": "Synchronisiert",
@@ -475,7 +541,7 @@
"edit_success": "Logbuch erfolgreich umbenannt", "edit_success": "Logbuch erfolgreich umbenannt",
"edit_btn": "Umbenennen", "edit_btn": "Umbenennen",
"filter_label": "Logbücher filtern", "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_clear": "Filter zurücksetzen",
"filter_results": "{{count}} Treffer", "filter_results": "{{count}} Treffer",
"filter_no_results": "Keine Logbücher passen zu deiner Suche. Probiere einen anderen Namen oder ein anderes Jahr.", "filter_no_results": "Keine Logbücher passen zu deiner Suche. Probiere einen anderen Namen oder ein anderes Jahr.",
@@ -606,7 +672,37 @@
"push_unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.", "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_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_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": { "person_pool": {
"title": "Stammcrew & Skipper", "title": "Stammcrew & Skipper",
@@ -702,7 +798,7 @@
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen", "delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
"delete_account_confirm_no": "Abbrechen", "delete_account_confirm_no": "Abbrechen",
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.", "delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.",
"delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok.json) in den Einstellungen jedes Logbuchs.", "delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok) in den Einstellungen jedes Logbuchs.",
"deleting_account": "Konto wird gelöscht…", "deleting_account": "Konto wird gelöscht…",
"invite_push_prompt_title": "Push-Benachrichtigungen aktivieren?", "invite_push_prompt_title": "Push-Benachrichtigungen aktivieren?",
"invite_push_prompt_message": "Sobald eingeladene Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Es werden keine Logbuch-Inhalte im Klartext gesendet.", "invite_push_prompt_message": "Sobald eingeladene Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Es werden keine Logbuch-Inhalte im Klartext gesendet.",
@@ -711,9 +807,9 @@
"invite_push_prompt_later": "Später", "invite_push_prompt_later": "Später",
"invite_push_prompt_success": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.", "invite_push_prompt_success": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
"backup_title": "Backup & Wiederherstellung", "backup_title": "Backup & Wiederherstellung",
"backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.", "backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, Sprachnotizen, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.",
"backup_export_title": "Backup erstellen", "backup_export_title": "Backup erstellen",
"backup_export_desc": "Lädt alle lokalen Daten als .daagbok.json herunter. Bewahre Datei und Passphrase getrennt und sicher auf.", "backup_export_desc": "Lädt alle lokalen Daten als komprimierte .daagbok-Datei herunter. Bewahre Datei und Passphrase getrennt und sicher auf.",
"backup_restore_title": "Backup wiederherstellen", "backup_restore_title": "Backup wiederherstellen",
"backup_restore_desc": "Stellt ein Backup in deinem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.", "backup_restore_desc": "Stellt ein Backup in deinem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.",
"backup_passphrase": "Backup-Passphrase", "backup_passphrase": "Backup-Passphrase",
@@ -725,7 +821,13 @@
"backup_export_btn": "Backup herunterladen", "backup_export_btn": "Backup herunterladen",
"backup_exporting": "Backup wird erstellt…", "backup_exporting": "Backup wird erstellt…",
"backup_export_success": "Backup erstellt ({{count}} Reisetage).", "backup_export_success": "Backup erstellt ({{count}} Reisetage).",
"backup_file_label": "Backup-Datei (.daagbok.json)", "backup_file_label": "Backup-Datei (.daagbok)",
"backup_export_progress": "Packe Dateien {{current}} / {{total}}…",
"backup_invalid_archive": "Die Datei ist kein gültiges Backup-Archiv.",
"backup_version_unsupported": "Altes Backup-Format (v1). Bitte ein aktuelles .daagbok-Backup verwenden.",
"backup_import_size_confirm": "Dieses Backup ist etwa {{size}} groß. Wiederherstellung kann auf dem Gerät länger dauern und viel Speicher belegen. Fortfahren?",
"backup_stat_voice": "{{count}} Sprachnotizen",
"backup_stat_size": "Unkomprimiert ca. {{size}}",
"backup_preview_btn": "Inhalt prüfen", "backup_preview_btn": "Inhalt prüfen",
"backup_previewing": "Prüfe…", "backup_previewing": "Prüfe…",
"backup_restore_btn": "Wiederherstellen", "backup_restore_btn": "Wiederherstellen",
@@ -883,7 +985,7 @@
}, },
"welcome_public": { "welcome_public": {
"title": "Willkommen an Bord!", "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": { "nav_logs": {
"title": "Logbucheinträge", "title": "Logbucheinträge",
@@ -902,8 +1004,12 @@
"body": "Lade GPX-Dateien hoch oder sieh bereits gespeicherte Routen auf der Karte inklusive Distanz und Geschwindigkeit." "body": "Lade GPX-Dateien hoch oder sieh bereits gespeicherte Routen auf der Karte inklusive Distanz und Geschwindigkeit."
}, },
"nav_vessel": { "nav_vessel": {
"title": "Schiffsdaten", "title": "Schiff fürs Logbuch",
"body": "Hinterlege Name, Maße und technische Daten deiner Yacht einmal ausfüllen, für alle Reisetage verfügbar." "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": { "profile_crew_pool": {
"title": "Stammcrew & Skipper", "title": "Stammcrew & Skipper",
+132 -26
View File
@@ -6,6 +6,10 @@
"beta": "Beta", "beta": "Beta",
"beta_hint": "Beta release — features may still change" "beta_hint": "Beta release — features may still change"
}, },
"footer": {
"kofi_label": "Ko-fi",
"kofi_title": "Support the project, development, and running costs on Ko-fi"
},
"languages": { "languages": {
"de": "Deutsch", "de": "Deutsch",
"en": "English", "en": "English",
@@ -27,8 +31,10 @@
"common": { "common": {
"unsaved_changes_title": "Unsaved changes", "unsaved_changes_title": "Unsaved changes",
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.", "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": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -243,13 +249,13 @@
"live_sails_confirm": "Log entry", "live_sails_confirm": "Log entry",
"live_sails_confirm_count": "Log entry ({{count}})", "live_sails_confirm_count": "Log entry ({{count}})",
"live_sails": "Sails: {{sails}}", "live_sails": "Sails: {{sails}}",
"live_fix": "Fix", "live_position": "Position",
"live_fix_coords": "Fix {{lat}}, {{lng}}", "live_position_coords": "Position {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS unavailable. Enter latitude and longitude manually, or try again with the GPS button.", "live_position_manual_hint": "GPS unavailable. Enter latitude and longitude manually, or try again with the GPS button.",
"live_fix_gps_loading": "Getting GPS position…", "live_position_gps_loading": "Getting GPS position…",
"live_fix_invalid": "Please enter valid coordinates (latitude 90…90, longitude 180…180).", "live_position_invalid": "Please enter valid coordinates (latitude 90…90, longitude 180…180).",
"live_fix_lat_placeholder": "Latitude (Lat)", "live_position_lat_placeholder": "Latitude (Lat)",
"live_fix_lng_placeholder": "Longitude (Lng)", "live_position_lng_placeholder": "Longitude (Lng)",
"live_photo_btn": "Photo (camera)", "live_photo_btn": "Photo (camera)",
"live_photo_capture_btn": "Capture", "live_photo_capture_btn": "Capture",
"live_photo_save_btn": "Save", "live_photo_save_btn": "Save",
@@ -260,44 +266,67 @@
"live_photo_camera_starting": "Starting camera…", "live_photo_camera_starting": "Starting camera…",
"live_photo_camera_denied": "Camera access denied or unavailable.", "live_photo_camera_denied": "Camera access denied or unavailable.",
"live_photo_camera_unavailable": "Camera is not supported in this browser.", "live_photo_camera_unavailable": "Camera is not supported in this browser.",
"live_photo_no_camera": "No camera is available on this device.",
"live_photo_error": "Could not save photo.", "live_photo_error": "Could not save photo.",
"live_photo_entry": "Photo: {{caption}}", "live_photo_entry": "Photo: {{caption}}",
"live_photo_entry_plain": "Photo captured", "live_photo_entry_plain": "Photo captured",
"live_undo_photo_hint": "Photo saved", "live_undo_photo_hint": "Photo saved",
"live_voice_btn": "Voice memo",
"live_voice_hint": "Record a short voice memo (max. 60 seconds).",
"live_voice_record": "Start recording",
"live_voice_stop": "Stop recording",
"live_voice_recording": "Recording {{time}}",
"live_voice_save": "Save",
"live_voice_saving": "Saving…",
"live_voice_retake": "Record again",
"live_voice_mic_denied": "Microphone access denied or unavailable.",
"live_voice_record_failed": "Recording failed. Please try again.",
"live_voice_unavailable": "Voice memo unavailable",
"live_voice_too_large": "Recording is too large. Please record a shorter memo.",
"live_voice_error": "Could not save voice memo.",
"live_voice_entry": "Voice memo: {{caption}}",
"live_voice_entry_plain": "Voice memo",
"live_voice_caption_label": "Caption (optional)",
"live_voice_caption_placeholder": "e.g. radio call with harbour master",
"live_undo_voice_hint": "Voice memo saved",
"live_comment_btn": "Comment", "live_comment_btn": "Comment",
"live_comment_placeholder": "Enter text…", "live_comment_placeholder": "Enter text…",
"live_comment_confirm": "Log entry", "live_comment_confirm": "Log entry",
"live_gps_error": "Could not determine GPS position.", "live_gps_error": "Could not determine GPS position.",
"live_gps_start_hint": "Always start your day's voyage with a position fix.", "live_gps_start_hint": "Always start your day's voyage with a position.",
"live_event_generic": "Event", "live_event_generic": "Event",
"live_weather_btn": "Weather", "live_weather_btn": "Weather",
"live_weather_owm_btn": "Fetch OpenWeatherMap weather", "live_weather_owm_btn": "Fetch OpenWeatherMap weather",
"live_weather_owm_loading": "Loading weather…", "live_weather_owm_loading": "Loading weather…",
"live_weather_fix_required": "Log a GPS fix first (Fix button) to fetch OpenWeatherMap weather. The position must be at most 6 hours old.", "live_weather_position_required": "Log a position first (Position button) to fetch OpenWeatherMap weather. The position must be at most 6 hours old.",
"live_weather_fix_stale": "The last GPS fix is older than 6 hours. Log a new fix before fetching weather.", "live_weather_position_stale": "The last position is older than 6 hours. Log a new position before fetching weather.",
"live_wind_btn": "Wind", "live_wind_btn": "Wind",
"live_temp_btn": "Temp °C", "live_temp_btn": "Temp °C",
"live_pressure_btn": "Pressure", "live_pressure_btn": "Pressure",
"live_precip_btn": "Precipitation", "live_precip_btn": "Precipitation",
"live_sea_state_btn": "Sea state", "live_sea_state_btn": "Sea state",
"live_visibility_btn": "Visibility",
"live_course_btn": "Course", "live_course_btn": "Course",
"live_fuel_btn": "Fuel", "live_fuel_btn": "+ Fuel",
"live_water_btn": "Water", "live_water_btn": "+ Water",
"live_wind_entry": "Wind {{value}}", "live_wind_entry": "Wind {{value}}",
"live_temp_entry": "Temperature {{temp}} °C", "live_temp_entry": "Temperature {{temp}} °C",
"live_pressure_entry": "Pressure {{value}} hPa", "live_pressure_entry": "Pressure {{value}} hPa",
"live_precip_entry": "Precipitation {{value}}", "live_precip_entry": "Precipitation {{value}}",
"live_sea_state_entry": "Sea state {{value}}", "live_sea_state_entry": "Sea state {{value}}",
"live_visibility_entry": "Visibility {{value}}",
"live_course_entry": "Course {{course}}", "live_course_entry": "Course {{course}}",
"live_fuel_entry": "Fuel +{{liters}} L", "live_fuel_entry": "Fuel +{{liters}} L",
"live_water_entry": "Water +{{liters}} L", "live_water_entry": "Water +{{liters}} L",
"live_auto_position": "Auto position", "live_auto_position": "Auto position",
"live_undo_hint": "Entry saved", "live_undo_hint": "Entry saved",
"live_undo_btn": "Undo", "live_undo_btn": "Undo",
"live_cancel": "Cancel",
"live_pressure_placeholder": "e.g. 1013", "live_pressure_placeholder": "e.g. 1013",
"live_temp_placeholder": "e.g. 18", "live_temp_placeholder": "e.g. 18",
"live_precip_placeholder": "e.g. light rain", "live_precip_placeholder": "e.g. light rain",
"live_sea_state_placeholder": "e.g. 3", "live_sea_state_placeholder": "e.g. 3",
"live_visibility_placeholder": "e.g. 10 km",
"live_course_placeholder": "e.g. 245", "live_course_placeholder": "e.g. 245",
"live_fuel_placeholder": "Liters refilled", "live_fuel_placeholder": "Liters refilled",
"live_water_placeholder": "Liters refilled", "live_water_placeholder": "Liters refilled",
@@ -339,6 +368,12 @@
"event_wind_direction": "Wind Dir", "event_wind_direction": "Wind Dir",
"event_wind_strength": "Wind Str", "event_wind_strength": "Wind Str",
"event_sea_state": "Sea State", "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_weather": "Weather",
"event_log": "Log (nm)", "event_log": "Log (nm)",
"event_gps": "GPS Position", "event_gps": "GPS Position",
@@ -346,7 +381,26 @@
"event_location_placeholder": "e.g. Kiel", "event_location_placeholder": "e.g. Kiel",
"event_remarks": "Remarks / Events", "event_remarks": "Remarks / Events",
"gps_btn": "Get GPS Location", "gps_btn": "Get GPS Location",
"gps_permission_denied": "Location access was denied. Allow it in your browser or device settings and try again.",
"gps_timeout": "GPS timed out. Try again outdoors with a clear view of the sky.",
"gps_position_unavailable": "No GPS signal available. Wait and retry, or enter coordinates manually.",
"gps_unavailable": "GPS is not supported by this browser or device.",
"gps_failed": "Could not determine GPS position.",
"gps_fallback_no_location": "GPS failed. Enter a place under Location / harbour, departure, or destination, or type coordinates manually.",
"gps_fallback_success": "Coordinates for \"{{location}}\" resolved from place name (not GPS).",
"gps_fallback_failed": "GPS and place-name lookup both failed. Please enter coordinates manually.",
"gps_quality_excellent": "Strong GPS reception (±{{accuracy}} m)",
"gps_quality_good": "Good GPS reception (±{{accuracy}} m)",
"gps_quality_fair": "Fair GPS reception (±{{accuracy}} m) — move outdoors for a better fix.",
"gps_quality_poor": "Weak GPS reception (±{{accuracy}} m) — likely few satellites. Retry outdoors or verify the position.",
"gps_quality_unknown": "GPS position applied (accuracy not reported by device).",
"gps_live_intro_title": "Location for Live Log",
"gps_live_intro_body": "The app needs your location for automatic position entries and the GPS button.\n\nTap “Allow location” and confirm in the next dialog. You can always enter a position manually via “Position”.",
"gps_live_intro_allow": "Allow location",
"gps_live_intro_later": "Later",
"gps_enable_in_settings_hint": "Location access is blocked. You can allow it later in your browser or device settings (site / app → Location).",
"weather_btn": "Fetch OpenWeatherMap Weather", "weather_btn": "Fetch OpenWeatherMap Weather",
"weather_offline": "OpenWeatherMap requires an internet connection. You are currently offline.",
"event_wind_pressure": "Barometer (hPa)", "event_wind_pressure": "Barometer (hPa)",
"event_heel": "Heel Angle (°)", "event_heel": "Heel Angle (°)",
"event_sails": "Sails / Motor Status", "event_sails": "Sails / Motor Status",
@@ -360,6 +414,18 @@
"share_csv": "Share CSV", "share_csv": "Share CSV",
"export_pdf": "Download PDF", "export_pdf": "Download PDF",
"exporting_pdf": "Generating PDF...", "exporting_pdf": "Generating PDF...",
"ai_summary_title": "AI Summary",
"ai_summary_read_only": "Created by the skipper — read-only for crew.",
"ai_summary_empty": "No summary yet.",
"ai_summary_generate": "Generate summary",
"ai_summary_regenerate": "Regenerate",
"ai_summary_generating": "Generating…",
"ai_summary_attempts_remaining": "{{remaining}} of {{max}} attempts remaining",
"ai_summary_error": "AI summary failed. Please try again later.",
"ai_summary_error_no_key": "No OpenRouter API key configured on the server.",
"ai_summary_error_rate_limited": "Maximum number of generations reached for this travel day.",
"ai_summary_error_forbidden": "Only the skipper may generate AI summaries.",
"ai_summary_offline": "AI summary generation requires an internet connection. You are currently offline.",
"photos_title": "Photo Attachments (E2E Encrypted)", "photos_title": "Photo Attachments (E2E Encrypted)",
"photo_caption_label": "Photo Caption / Label (Optional)", "photo_caption_label": "Photo Caption / Label (Optional)",
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance", "photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
@@ -428,8 +494,8 @@
"nmea_change_engine_stop": "Engine off", "nmea_change_engine_stop": "Engine off",
"nmea_change_autopilot_on": "Autopilot on", "nmea_change_autopilot_on": "Autopilot on",
"nmea_change_autopilot_off": "Autopilot off", "nmea_change_autopilot_off": "Autopilot off",
"nmea_change_gps_lost": "GPS fix lost", "nmea_change_gps_lost": "GPS position lost",
"nmea_change_gps_regained": "GPS fix restored", "nmea_change_gps_regained": "GPS position restored",
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C", "nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway", "nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop", "nmea_change_anchor": "Anchored / stop",
@@ -453,7 +519,7 @@
"new_logbook_placeholder": "Logbook or Yacht Name", "new_logbook_placeholder": "Logbook or Yacht Name",
"logout": "Logout", "logout": "Logout",
"logged_in_as": "Signed in as {{name}}", "logged_in_as": "Signed in as {{name}}",
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok.json) if you may need the data later.", "delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok) if you may need the data later.",
"no_logbooks": "No logbooks found. Create your first logbook to begin!", "no_logbooks": "No logbooks found. Create your first logbook to begin!",
"loading": "Loading logbooks...", "loading": "Loading logbooks...",
"status_synced": "Synced", "status_synced": "Synced",
@@ -475,7 +541,7 @@
"edit_success": "Logbook renamed successfully", "edit_success": "Logbook renamed successfully",
"edit_btn": "Rename", "edit_btn": "Rename",
"filter_label": "Filter logbooks", "filter_label": "Filter logbooks",
"filter_placeholder": "Name, year or date …", "filter_placeholder": "Name, year, date, crew or vessel …",
"filter_clear": "Clear filter", "filter_clear": "Clear filter",
"filter_results": "{{count}} matches", "filter_results": "{{count}} matches",
"filter_no_results": "No logbooks match your search. Try a different name or year.", "filter_no_results": "No logbooks match your search. Try a different name or year.",
@@ -606,7 +672,37 @@
"push_unsupported": "Push notifications are not supported in this browser.", "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_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_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": { "person_pool": {
"title": "Core Crew & skippers", "title": "Core Crew & skippers",
@@ -702,7 +798,7 @@
"delete_account_confirm_yes": "Yes, Delete Account and All Data", "delete_account_confirm_yes": "Yes, Delete Account and All Data",
"delete_account_confirm_no": "Cancel", "delete_account_confirm_no": "Cancel",
"delete_account_failed": "Failed to delete account. Please try again.", "delete_account_failed": "Failed to delete account. Please try again.",
"delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok.json) in each logbook's settings.", "delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok) in each logbook's settings.",
"deleting_account": "Deleting account…", "deleting_account": "Deleting account…",
"invite_push_prompt_title": "Enable push notifications?", "invite_push_prompt_title": "Enable push notifications?",
"invite_push_prompt_message": "When invited crew members sync changes, you can be notified via push. No logbook content is sent in plain text.", "invite_push_prompt_message": "When invited crew members sync changes, you can be notified via push. No logbook content is sent in plain text.",
@@ -711,9 +807,9 @@
"invite_push_prompt_later": "Later", "invite_push_prompt_later": "Later",
"invite_push_prompt_success": "Push notifications are active on this device.", "invite_push_prompt_success": "Push notifications are active on this device.",
"backup_title": "Backup & restore", "backup_title": "Backup & restore",
"backup_desc": "Full encrypted backup of this logbook (entries, photos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.", "backup_desc": "Full encrypted backup of this logbook (entries, photos, voice memos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.",
"backup_export_title": "Create backup", "backup_export_title": "Create backup",
"backup_export_desc": "Downloads all local data as a .daagbok.json file. Keep the file and passphrase separate and secure.", "backup_export_desc": "Downloads all local data as a compressed .daagbok archive. Keep the file and passphrase separate and secure.",
"backup_restore_title": "Restore backup", "backup_restore_title": "Restore backup",
"backup_restore_desc": "Restores a backup into your current account — including after registering a new account.", "backup_restore_desc": "Restores a backup into your current account — including after registering a new account.",
"backup_passphrase": "Backup passphrase", "backup_passphrase": "Backup passphrase",
@@ -725,7 +821,13 @@
"backup_export_btn": "Download backup", "backup_export_btn": "Download backup",
"backup_exporting": "Creating backup…", "backup_exporting": "Creating backup…",
"backup_export_success": "Backup created ({{count}} travel days).", "backup_export_success": "Backup created ({{count}} travel days).",
"backup_file_label": "Backup file (.daagbok.json)", "backup_file_label": "Backup file (.daagbok)",
"backup_export_progress": "Packing files {{current}} / {{total}}…",
"backup_invalid_archive": "The file is not a valid backup archive.",
"backup_version_unsupported": "Legacy backup format (v1). Please use a current .daagbok backup.",
"backup_import_size_confirm": "This backup is about {{size}} uncompressed. Restore may take longer and use significant memory. Continue?",
"backup_stat_voice": "{{count}} voice memos",
"backup_stat_size": "Approx. {{size}} uncompressed",
"backup_preview_btn": "Verify contents", "backup_preview_btn": "Verify contents",
"backup_previewing": "Verifying…", "backup_previewing": "Verifying…",
"backup_restore_btn": "Restore", "backup_restore_btn": "Restore",
@@ -883,7 +985,7 @@
}, },
"welcome_public": { "welcome_public": {
"title": "Welcome aboard!", "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": { "nav_logs": {
"title": "Log entries", "title": "Log entries",
@@ -902,8 +1004,12 @@
"body": "Upload GPX files or view saved routes on the map including distance and speed stats." "body": "Upload GPX files or view saved routes on the map including distance and speed stats."
}, },
"nav_vessel": { "nav_vessel": {
"title": "Vessel data", "title": "Vessel for logbook",
"body": "Enter your yacht's name, dimensions, and technical details fill once, use on every travel day." "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": { "profile_crew_pool": {
"title": "Core Crew & skippers", "title": "Core Crew & skippers",
+130 -24
View File
@@ -6,6 +6,10 @@
"beta": "Beta", "beta": "Beta",
"beta_hint": "Betaversjon - funksjoner kan fortsatt endres" "beta_hint": "Betaversjon - funksjoner kan fortsatt endres"
}, },
"footer": {
"kofi_label": "Ko-fi",
"kofi_title": "Støtt prosjektet, videreutvikling og driftskostnader på Ko-fi"
},
"languages": { "languages": {
"de": "Deutsch", "de": "Deutsch",
"en": "English", "en": "English",
@@ -27,8 +31,10 @@
"common": { "common": {
"unsaved_changes_title": "Ikke-lagrede endringer", "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_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": { "nav": {
"dashboard": "Dashbord", "dashboard": "Dashbord",
@@ -243,13 +249,13 @@
"live_sails_confirm": "Loggfør", "live_sails_confirm": "Loggfør",
"live_sails_confirm_count": "Loggfør ({{count}})", "live_sails_confirm_count": "Loggfør ({{count}})",
"live_sails": "Seil: {{sails}}", "live_sails": "Seil: {{sails}}",
"live_fix": "Fix", "live_position": "Posisjon",
"live_fix_coords": "Fix {{lat}}, {{lng}}", "live_position_coords": "Posisjon {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS ikke tilgjengelig. Skriv inn bredde- og lengdegrad manuelt, eller prøv igjen med GPS-knappen.", "live_position_manual_hint": "GPS ikke tilgjengelig. Skriv inn bredde- og lengdegrad manuelt, eller prøv igjen med GPS-knappen.",
"live_fix_gps_loading": "Henter GPS-posisjon…", "live_position_gps_loading": "Henter GPS-posisjon…",
"live_fix_invalid": "Skriv inn gyldige koordinater (bredde 90…90, lengde 180…180).", "live_position_invalid": "Skriv inn gyldige koordinater (bredde 90…90, lengde 180…180).",
"live_fix_lat_placeholder": "Bredde (Lat)", "live_position_lat_placeholder": "Bredde (Lat)",
"live_fix_lng_placeholder": "Lengde (Lng)", "live_position_lng_placeholder": "Lengde (Lng)",
"live_photo_btn": "Foto (kamera)", "live_photo_btn": "Foto (kamera)",
"live_photo_capture_btn": "Ta bilde", "live_photo_capture_btn": "Ta bilde",
"live_photo_save_btn": "Lagre", "live_photo_save_btn": "Lagre",
@@ -260,10 +266,29 @@
"live_photo_camera_starting": "Starter kamera…", "live_photo_camera_starting": "Starter kamera…",
"live_photo_camera_denied": "Kameratilgang nektet eller utilgjengelig.", "live_photo_camera_denied": "Kameratilgang nektet eller utilgjengelig.",
"live_photo_camera_unavailable": "Kamera støttes ikke i denne nettleseren.", "live_photo_camera_unavailable": "Kamera støttes ikke i denne nettleseren.",
"live_photo_no_camera": "Ingen kamera er tilgjengelig på denne enheten.",
"live_photo_error": "Kunne ikke lagre foto.", "live_photo_error": "Kunne ikke lagre foto.",
"live_photo_entry": "Foto: {{caption}}", "live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto tatt", "live_photo_entry_plain": "Foto tatt",
"live_undo_photo_hint": "Foto lagret", "live_undo_photo_hint": "Foto lagret",
"live_voice_btn": "Talemelding",
"live_voice_hint": "Ta opp en kort talemelding (maks. 60 sekunder).",
"live_voice_record": "Start opptak",
"live_voice_stop": "Stopp opptak",
"live_voice_recording": "Tar opp {{time}}",
"live_voice_save": "Lagre",
"live_voice_saving": "Lagrer…",
"live_voice_retake": "Ta opp på nytt",
"live_voice_mic_denied": "Mikrofontilgang nektet eller utilgjengelig.",
"live_voice_record_failed": "Opptak mislyktes. Prøv igjen.",
"live_voice_unavailable": "Talemelding utilgjengelig",
"live_voice_too_large": "Opptaket er for stort. Ta et kortere opptak.",
"live_voice_error": "Kunne ikke lagre talemelding.",
"live_voice_entry": "Talemelding: {{caption}}",
"live_voice_entry_plain": "Talemelding",
"live_voice_caption_label": "Bildetekst (valgfritt)",
"live_voice_caption_placeholder": "f.eks. radiokontakt med havnesjef",
"live_undo_voice_hint": "Talemelding lagret",
"live_comment_btn": "Kommentar", "live_comment_btn": "Kommentar",
"live_comment_placeholder": "Skriv inn tekst…", "live_comment_placeholder": "Skriv inn tekst…",
"live_comment_confirm": "Loggfør", "live_comment_confirm": "Loggfør",
@@ -273,31 +298,35 @@
"live_weather_btn": "Vær", "live_weather_btn": "Vær",
"live_weather_owm_btn": "Hent OpenWeatherMap-vær", "live_weather_owm_btn": "Hent OpenWeatherMap-vær",
"live_weather_owm_loading": "Henter vær…", "live_weather_owm_loading": "Henter vær…",
"live_weather_fix_required": "Logg først en GPS-fix (Fix-knapp) for å hente OpenWeatherMap-vær. Posisjonen må være maks 6 timer gammel.", "live_weather_position_required": "Logg først en posisjon (Posisjon-knapp) for å hente OpenWeatherMap-vær. Posisjonen må være maks 6 timer gammel.",
"live_weather_fix_stale": "Siste GPS-fix er eldre enn 6 timer. Logg en ny fix før du henter vær.", "live_weather_position_stale": "Siste posisjon er eldre enn 6 timer. Logg en ny posisjon før du henter vær.",
"live_wind_btn": "Vind", "live_wind_btn": "Vind",
"live_temp_btn": "T °C", "live_temp_btn": "T °C",
"live_pressure_btn": "Lufttrykk", "live_pressure_btn": "Lufttrykk",
"live_precip_btn": "Nedbør", "live_precip_btn": "Nedbør",
"live_sea_state_btn": "Sjøgang", "live_sea_state_btn": "Sjøgang",
"live_visibility_btn": "Sikt",
"live_course_btn": "Kurs", "live_course_btn": "Kurs",
"live_fuel_btn": "Diesel", "live_fuel_btn": "+ Diesel",
"live_water_btn": "Vann", "live_water_btn": "+ Vann",
"live_wind_entry": "Vind {{value}}", "live_wind_entry": "Vind {{value}}",
"live_temp_entry": "Temperatur {{temp}} °C", "live_temp_entry": "Temperatur {{temp}} °C",
"live_pressure_entry": "Lufttrykk {{value}} hPa", "live_pressure_entry": "Lufttrykk {{value}} hPa",
"live_precip_entry": "Nedbør {{value}}", "live_precip_entry": "Nedbør {{value}}",
"live_sea_state_entry": "Sjøgang {{value}}", "live_sea_state_entry": "Sjøgang {{value}}",
"live_visibility_entry": "Sikt {{value}}",
"live_course_entry": "Kurs {{course}}", "live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L", "live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Vann +{{liters}} L", "live_water_entry": "Vann +{{liters}} L",
"live_auto_position": "Auto-posisjon", "live_auto_position": "Auto-posisjon",
"live_undo_hint": "Oppføring lagret", "live_undo_hint": "Oppføring lagret",
"live_undo_btn": "Angre", "live_undo_btn": "Angre",
"live_cancel": "Avbryt",
"live_pressure_placeholder": "f.eks. 1013", "live_pressure_placeholder": "f.eks. 1013",
"live_temp_placeholder": "f.eks. 18", "live_temp_placeholder": "f.eks. 18",
"live_precip_placeholder": "f.eks. lett regn", "live_precip_placeholder": "f.eks. lett regn",
"live_sea_state_placeholder": "f.eks. 3", "live_sea_state_placeholder": "f.eks. 3",
"live_visibility_placeholder": "f.eks. 10 km",
"live_course_placeholder": "f.eks. 245", "live_course_placeholder": "f.eks. 245",
"live_fuel_placeholder": "Påfylte liter", "live_fuel_placeholder": "Påfylte liter",
"live_water_placeholder": "Påfylte liter", "live_water_placeholder": "Påfylte liter",
@@ -339,6 +368,12 @@
"event_wind_direction": "Vindretning", "event_wind_direction": "Vindretning",
"event_wind_strength": "Vindstyrke", "event_wind_strength": "Vindstyrke",
"event_sea_state": "Havets tilstand", "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_weather": "Været",
"event_log": "Logg (sm)", "event_log": "Logg (sm)",
"event_gps": "GPS-posisjon", "event_gps": "GPS-posisjon",
@@ -346,7 +381,26 @@
"event_location_placeholder": "z. f.eks. Kiel", "event_location_placeholder": "z. f.eks. Kiel",
"event_remarks": "Merknader / hendelser", "event_remarks": "Merknader / hendelser",
"gps_btn": "Hent GPS-koordinater", "gps_btn": "Hent GPS-koordinater",
"gps_permission_denied": "Tilgang til posisjon ble nektet. Tillat det i nettleser- eller enhetsinnstillinger og prøv igjen.",
"gps_timeout": "GPS fikk tidsavbrudd. Prøv igjen utendørs med fri sikt mot himmelen.",
"gps_position_unavailable": "Ingen GPS-signal tilgjengelig. Vent og prøv igjen, eller skriv inn koordinater manuelt.",
"gps_unavailable": "GPS støttes ikke av denne nettleseren eller enheten.",
"gps_failed": "GPS-posisjon kunne ikke bestemmes.",
"gps_fallback_no_location": "GPS mislyktes. Skriv inn et sted under sted/havn, avreise eller destinasjon, eller koordinater manuelt.",
"gps_fallback_success": "Koordinater for «{{location}}» funnet via stedsnavn (ikke GPS).",
"gps_fallback_failed": "GPS og stedsnavnssøk mislyktes. Skriv inn koordinater manuelt.",
"gps_quality_excellent": "Sterk GPS-mottak (±{{accuracy}} m)",
"gps_quality_good": "God GPS-mottak (±{{accuracy}} m)",
"gps_quality_fair": "Middels GPS-mottak (±{{accuracy}} m) gå utendørs for bedre signal.",
"gps_quality_poor": "Svakt GPS-mottak (±{{accuracy}} m) sannsynligvis få satellitter. Prøv utendørs igjen eller kontroller posisjonen.",
"gps_quality_unknown": "GPS-posisjon tatt i bruk (nøyaktighet ikke rapportert av enheten).",
"gps_live_intro_title": "Posisjon for live-logg",
"gps_live_intro_body": "Appen trenger posisjonen din for automatiske posisjonsregistreringer og GPS-knappen.\n\nTrykk «Tillat posisjon» og bekreft i neste dialog. Du kan alltid legge inn posisjon manuelt via «Posisjon».",
"gps_live_intro_allow": "Tillat posisjon",
"gps_live_intro_later": "Senere",
"gps_enable_in_settings_hint": "Posisjonstilgang er blokkert. Du kan tillate det senere i nettleser- eller enhetsinnstillinger (nettsted / app → Posisjon).",
"weather_btn": "OpenWeatherMap Ring opp været", "weather_btn": "OpenWeatherMap Ring opp været",
"weather_offline": "OpenWeatherMap krever internettforbindelse. Du er frakoblet.",
"event_wind_pressure": "Lufttrykk (hPa)", "event_wind_pressure": "Lufttrykk (hPa)",
"event_heel": "Helning (°)", "event_heel": "Helning (°)",
"event_sails": "Seilhåndtering / motor", "event_sails": "Seilhåndtering / motor",
@@ -360,6 +414,18 @@
"share_csv": "CSV andel", "share_csv": "CSV andel",
"export_pdf": "Last ned PDF", "export_pdf": "Last ned PDF",
"exporting_pdf": "PDF genereres...", "exporting_pdf": "PDF genereres...",
"ai_summary_title": "AI-sammendrag",
"ai_summary_read_only": "Opprettet av skipperen — kun lesbar for mannskapet.",
"ai_summary_empty": "Ingen sammendrag ennå.",
"ai_summary_generate": "Generer sammendrag",
"ai_summary_regenerate": "Generer på nytt",
"ai_summary_generating": "Genererer…",
"ai_summary_attempts_remaining": "{{remaining}} av {{max}} forsøk igjen",
"ai_summary_error": "AI-sammendrag mislyktes. Prøv igjen senere.",
"ai_summary_error_no_key": "Ingen OpenRouter API-nøkkel konfigurert på serveren.",
"ai_summary_error_rate_limited": "Maksimalt antall genereringer nådd for denne reisedagen.",
"ai_summary_error_forbidden": "Kun skipperen kan generere AI-sammendrag.",
"ai_summary_offline": "AI-sammendrag krever internettforbindelse. Du er frakoblet.",
"photos_title": "Bildevedlegg (E2E-kryptert)", "photos_title": "Bildevedlegg (E2E-kryptert)",
"photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)", "photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)",
"photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen", "photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen",
@@ -438,8 +504,8 @@
"nmea_change_engine_stop": "Engine off", "nmea_change_engine_stop": "Engine off",
"nmea_change_autopilot_on": "Autopilot on", "nmea_change_autopilot_on": "Autopilot on",
"nmea_change_autopilot_off": "Autopilot off", "nmea_change_autopilot_off": "Autopilot off",
"nmea_change_gps_lost": "GPS fix lost", "nmea_change_gps_lost": "GPS-posisjon tapt",
"nmea_change_gps_regained": "GPS fix restored", "nmea_change_gps_regained": "GPS-posisjon gjenopprettet",
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C", "nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway", "nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop", "nmea_change_anchor": "Anchored / stop",
@@ -453,7 +519,7 @@
"new_logbook_placeholder": "Navn på loggboken eller båten", "new_logbook_placeholder": "Navn på loggboken eller båten",
"logout": "Logg ut", "logout": "Logg ut",
"logged_in_as": "Innlogget som {{name}}", "logged_in_as": "Innlogget som {{name}}",
"delete_confirm": "Er du sikker på at du vil slette denne loggboken permanent? Alle lokale data og serverkopier vil bli ødelagt.\n\nTips: Lag en sikkerhetskopi (.daagbok.json) på forhånd under Innstillinger → Sikkerhetskopiering og gjenoppretting hvis du ønsker å beholde dataene senere.", "delete_confirm": "Er du sikker på at du vil slette denne loggboken permanent? Alle lokale data og serverkopier vil bli ødelagt.\n\nTips: Lag en sikkerhetskopi (.daagbok) på forhånd under Innstillinger → Sikkerhetskopiering og gjenoppretting hvis du ønsker å beholde dataene senere.",
"no_logbooks": "Ingen loggbøker funnet. Opprett din første loggbok for å komme i gang!", "no_logbooks": "Ingen loggbøker funnet. Opprett din første loggbok for å komme i gang!",
"loading": "Loggbøker er lastet...", "loading": "Loggbøker er lastet...",
"status_synced": "Synkronisert", "status_synced": "Synkronisert",
@@ -475,7 +541,7 @@
"edit_success": "Loggboken har fått nytt navn", "edit_success": "Loggboken har fått nytt navn",
"edit_btn": "Gi nytt navn", "edit_btn": "Gi nytt navn",
"filter_label": "Filtrer loggbøker", "filter_label": "Filtrer loggbøker",
"filter_placeholder": "Navn, årstall eller dato ...", "filter_placeholder": "Navn, årstall, dato, crew eller skip …",
"filter_clear": "Tilbakestill filter", "filter_clear": "Tilbakestill filter",
"filter_results": "{{count}} Treff", "filter_results": "{{count}} Treff",
"filter_no_results": "Ingen loggbøker samsvarer med søket ditt. Prøv et annet navn eller et annet år.", "filter_no_results": "Ingen loggbøker samsvarer med søket ditt. Prøv et annet navn eller et annet år.",
@@ -606,7 +672,37 @@
"push_unsupported": "Push-varsler støttes ikke i denne nettleseren.", "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_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_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": { "person_pool": {
"title": "Stamm-Crew og skippere", "title": "Stamm-Crew og skippere",
@@ -702,7 +798,7 @@
"delete_account_confirm_yes": "Ja, slett konto og alle data", "delete_account_confirm_yes": "Ja, slett konto og alle data",
"delete_account_confirm_no": "Avbryt", "delete_account_confirm_no": "Avbryt",
"delete_account_failed": "Kontoen kunne ikke slettes. Vennligst prøv igjen.", "delete_account_failed": "Kontoen kunne ikke slettes. Vennligst prøv igjen.",
"delete_backup_hint": "Tips: Lag sikkerhetskopier av loggbøkene dine (.daagbok.json) i innstillingene for hver loggbok før du sletter dem.", "delete_backup_hint": "Tips: Lag sikkerhetskopier av loggbøkene dine (.daagbok) i innstillingene for hver loggbok før du sletter dem.",
"deleting_account": "Kontoen vil bli slettet...", "deleting_account": "Kontoen vil bli slettet...",
"invite_push_prompt_title": "Aktivere push-varsler?", "invite_push_prompt_title": "Aktivere push-varsler?",
"invite_push_prompt_message": "Så snart inviterte Crew-medlemmer synkroniserer endringer, kan du bli informert via push. Ingen loggbokinnhold sendes i ren tekst.", "invite_push_prompt_message": "Så snart inviterte Crew-medlemmer synkroniserer endringer, kan du bli informert via push. Ingen loggbokinnhold sendes i ren tekst.",
@@ -713,7 +809,7 @@
"backup_title": "Sikkerhetskopiering og gjenoppretting", "backup_title": "Sikkerhetskopiering og gjenoppretting",
"backup_desc": "Fullstendig kryptert sikkerhetskopi av denne loggboken (oppføringer, bilder, GPS-spor, crew, skip). Beskyttet med sikkerhetskopieringspassord - for gjenoppretting til denne eller en ny konto.", "backup_desc": "Fullstendig kryptert sikkerhetskopi av denne loggboken (oppføringer, bilder, GPS-spor, crew, skip). Beskyttet med sikkerhetskopieringspassord - for gjenoppretting til denne eller en ny konto.",
"backup_export_title": "Opprett sikkerhetskopi", "backup_export_title": "Opprett sikkerhetskopi",
"backup_export_desc": "Laster ned alle lokale data som .daagbok.json. Hold filen og passordfrasen adskilt og sikker.", "backup_export_desc": "Laster ned alle lokale data som et komprimert .daagbok-arkiv. Hold filen og passordfrasen adskilt og sikker.",
"backup_restore_title": "Gjenopprett sikkerhetskopi", "backup_restore_title": "Gjenopprett sikkerhetskopi",
"backup_restore_desc": "Gjenoppretter en sikkerhetskopi til din nåværende konto - selv etter at du har registrert en ny konto.", "backup_restore_desc": "Gjenoppretter en sikkerhetskopi til din nåværende konto - selv etter at du har registrert en ny konto.",
"backup_passphrase": "Passord for sikkerhetskopiering", "backup_passphrase": "Passord for sikkerhetskopiering",
@@ -725,7 +821,13 @@
"backup_export_btn": "Last ned sikkerhetskopi", "backup_export_btn": "Last ned sikkerhetskopi",
"backup_exporting": "Sikkerhetskopien er opprettet...", "backup_exporting": "Sikkerhetskopien er opprettet...",
"backup_export_success": "Sikkerhetskopi opprettet ({{count}} reisedager).", "backup_export_success": "Sikkerhetskopi opprettet ({{count}} reisedager).",
"backup_file_label": "Sikkerhetskopifil (.daagbok.json)", "backup_file_label": "Sikkerhetskopifil (.daagbok)",
"backup_export_progress": "Pakker filer {{current}} / {{total}}…",
"backup_invalid_archive": "Filen er ikke et gyldig backup-arkiv.",
"backup_version_unsupported": "Gammelt backup-format (v1). Bruk en aktuell .daagbok-sikkerhetskopi.",
"backup_import_size_confirm": "Denne sikkerhetskopien er ca. {{size}} ukomprimert. Gjenoppretting kan ta lengre tid. Fortsette?",
"backup_stat_voice": "{{count}} talemeldinger",
"backup_stat_size": "Ca. {{size}} ukomprimert",
"backup_preview_btn": "Sjekk innhold", "backup_preview_btn": "Sjekk innhold",
"backup_previewing": "Sjekk...", "backup_previewing": "Sjekk...",
"backup_restore_btn": "Gjenopprett", "backup_restore_btn": "Gjenopprett",
@@ -883,7 +985,7 @@
}, },
"welcome_public": { "welcome_public": {
"title": "Velkommen om bord!", "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": { "nav_logs": {
"title": "Loggbokoppføringer", "title": "Loggbokoppføringer",
@@ -902,8 +1004,12 @@
"body": "Last opp GPX-filer eller se allerede lagrede ruter på kartet - inkludert avstand og hastighet." "body": "Last opp GPX-filer eller se allerede lagrede ruter på kartet - inkludert avstand og hastighet."
}, },
"nav_vessel": { "nav_vessel": {
"title": "Skipsdata", "title": "Skip for loggbok",
"body": "Skriv inn navn, dimensjoner og tekniske data for båten din - fyll inn én gang, tilgjengelig for alle reisedager." "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": { "profile_crew_pool": {
"title": "Stamm-Crew og skippere", "title": "Stamm-Crew og skippere",
+130 -24
View File
@@ -6,6 +6,10 @@
"beta": "Beta", "beta": "Beta",
"beta_hint": "Betaversion - funktioner kan fortfarande ändras" "beta_hint": "Betaversion - funktioner kan fortfarande ändras"
}, },
"footer": {
"kofi_label": "Ko-fi",
"kofi_title": "Stöd projektet, vidareutveckling och driftskostnader på Ko-fi"
},
"languages": { "languages": {
"de": "Deutsch", "de": "Deutsch",
"en": "English", "en": "English",
@@ -27,8 +31,10 @@
"common": { "common": {
"unsaved_changes_title": "Osparade ändringar", "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_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": { "nav": {
"dashboard": "Instrumentpanel", "dashboard": "Instrumentpanel",
@@ -243,13 +249,13 @@
"live_sails_confirm": "Logga", "live_sails_confirm": "Logga",
"live_sails_confirm_count": "Logga ({{count}})", "live_sails_confirm_count": "Logga ({{count}})",
"live_sails": "Segel: {{sails}}", "live_sails": "Segel: {{sails}}",
"live_fix": "Fix", "live_position": "Position",
"live_fix_coords": "Fix {{lat}}, {{lng}}", "live_position_coords": "Position {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS ej tillgänglig. Ange latitud och longitud manuellt, eller försök igen med GPS-knappen.", "live_position_manual_hint": "GPS ej tillgänglig. Ange latitud och longitud manuellt, eller försök igen med GPS-knappen.",
"live_fix_gps_loading": "Hämtar GPS-position…", "live_position_gps_loading": "Hämtar GPS-position…",
"live_fix_invalid": "Ange giltiga koordinater (latitud 90…90, longitud 180…180).", "live_position_invalid": "Ange giltiga koordinater (latitud 90…90, longitud 180…180).",
"live_fix_lat_placeholder": "Latitud (Lat)", "live_position_lat_placeholder": "Latitud (Lat)",
"live_fix_lng_placeholder": "Longitud (Lng)", "live_position_lng_placeholder": "Longitud (Lng)",
"live_photo_btn": "Foto (kamera)", "live_photo_btn": "Foto (kamera)",
"live_photo_capture_btn": "Ta foto", "live_photo_capture_btn": "Ta foto",
"live_photo_save_btn": "Spara", "live_photo_save_btn": "Spara",
@@ -260,10 +266,29 @@
"live_photo_camera_starting": "Startar kamera…", "live_photo_camera_starting": "Startar kamera…",
"live_photo_camera_denied": "Kameraåtkomst nekad eller ej tillgänglig.", "live_photo_camera_denied": "Kameraåtkomst nekad eller ej tillgänglig.",
"live_photo_camera_unavailable": "Kameran stöds inte i den här webbläsaren.", "live_photo_camera_unavailable": "Kameran stöds inte i den här webbläsaren.",
"live_photo_no_camera": "Ingen kamera finns på den här enheten.",
"live_photo_error": "Foto kunde inte sparas.", "live_photo_error": "Foto kunde inte sparas.",
"live_photo_entry": "Foto: {{caption}}", "live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto taget", "live_photo_entry_plain": "Foto taget",
"live_undo_photo_hint": "Foto sparat", "live_undo_photo_hint": "Foto sparat",
"live_voice_btn": "Röstanteckning",
"live_voice_hint": "Spela in en kort röstanteckning (max 60 sekunder).",
"live_voice_record": "Starta inspelning",
"live_voice_stop": "Stoppa inspelning",
"live_voice_recording": "Spelar in {{time}}",
"live_voice_save": "Spara",
"live_voice_saving": "Sparar…",
"live_voice_retake": "Spela in igen",
"live_voice_mic_denied": "Mikrofonåtkomst nekad eller ej tillgänglig.",
"live_voice_record_failed": "Inspelning misslyckades. Försök igen.",
"live_voice_unavailable": "Röstanteckning ej tillgänglig",
"live_voice_too_large": "Inspelningen är för stor. Spela in kortare.",
"live_voice_error": "Kunde inte spara röstanteckning.",
"live_voice_entry": "Röstanteckning: {{caption}}",
"live_voice_entry_plain": "Röstanteckning",
"live_voice_caption_label": "Bildtext (valfritt)",
"live_voice_caption_placeholder": "t.ex. radiokontakt med hamnmästare",
"live_undo_voice_hint": "Röstanteckning sparad",
"live_comment_btn": "Kommentar", "live_comment_btn": "Kommentar",
"live_comment_placeholder": "Ange text…", "live_comment_placeholder": "Ange text…",
"live_comment_confirm": "Logga", "live_comment_confirm": "Logga",
@@ -273,31 +298,35 @@
"live_weather_btn": "Väder", "live_weather_btn": "Väder",
"live_weather_owm_btn": "Hämta OpenWeatherMap-väder", "live_weather_owm_btn": "Hämta OpenWeatherMap-väder",
"live_weather_owm_loading": "Hämtar väder…", "live_weather_owm_loading": "Hämtar väder…",
"live_weather_fix_required": "Logga först en GPS-fix (Fix-knappen) för att hämta OpenWeatherMap-väder. Positionen får högst vara 6 timmar gammal.", "live_weather_position_required": "Logga först en position (Position-knappen) för att hämta OpenWeatherMap-väder. Positionen får högst vara 6 timmar gammal.",
"live_weather_fix_stale": "Senaste GPS-fixen är äldre än 6 timmar. Logga en ny fix innan du hämtar väder.", "live_weather_position_stale": "Senaste positionen är äldre än 6 timmar. Logga en ny position innan du hämtar väder.",
"live_wind_btn": "Vind", "live_wind_btn": "Vind",
"live_temp_btn": "T °C", "live_temp_btn": "T °C",
"live_pressure_btn": "Lufttryck", "live_pressure_btn": "Lufttryck",
"live_precip_btn": "Nederbörd", "live_precip_btn": "Nederbörd",
"live_sea_state_btn": "Sjögang", "live_sea_state_btn": "Sjögang",
"live_visibility_btn": "Sikt",
"live_course_btn": "Kurs", "live_course_btn": "Kurs",
"live_fuel_btn": "Diesel", "live_fuel_btn": "+ Diesel",
"live_water_btn": "Vatten", "live_water_btn": "+ Vatten",
"live_wind_entry": "Vind {{value}}", "live_wind_entry": "Vind {{value}}",
"live_temp_entry": "Temperatur {{temp}} °C", "live_temp_entry": "Temperatur {{temp}} °C",
"live_pressure_entry": "Lufttryck {{value}} hPa", "live_pressure_entry": "Lufttryck {{value}} hPa",
"live_precip_entry": "Nederbörd {{value}}", "live_precip_entry": "Nederbörd {{value}}",
"live_sea_state_entry": "Sjögang {{value}}", "live_sea_state_entry": "Sjögang {{value}}",
"live_visibility_entry": "Sikt {{value}}",
"live_course_entry": "Kurs {{course}}", "live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L", "live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Vatten +{{liters}} L", "live_water_entry": "Vatten +{{liters}} L",
"live_auto_position": "Auto-position", "live_auto_position": "Auto-position",
"live_undo_hint": "Post sparad", "live_undo_hint": "Post sparad",
"live_undo_btn": "Ångra", "live_undo_btn": "Ångra",
"live_cancel": "Avbryt",
"live_pressure_placeholder": "t.ex. 1013", "live_pressure_placeholder": "t.ex. 1013",
"live_temp_placeholder": "t.ex. 18", "live_temp_placeholder": "t.ex. 18",
"live_precip_placeholder": "t.ex. lätt regn", "live_precip_placeholder": "t.ex. lätt regn",
"live_sea_state_placeholder": "t.ex. 3", "live_sea_state_placeholder": "t.ex. 3",
"live_visibility_placeholder": "t.ex. 10 km",
"live_course_placeholder": "t.ex. 245", "live_course_placeholder": "t.ex. 245",
"live_fuel_placeholder": "Påfyllda liter", "live_fuel_placeholder": "Påfyllda liter",
"live_water_placeholder": "Påfyllda liter", "live_water_placeholder": "Påfyllda liter",
@@ -339,6 +368,12 @@
"event_wind_direction": "Vindriktning", "event_wind_direction": "Vindriktning",
"event_wind_strength": "Vindstyrka", "event_wind_strength": "Vindstyrka",
"event_sea_state": "Havets tillstånd", "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_weather": "Väder",
"event_log": "Log (sm)", "event_log": "Log (sm)",
"event_gps": "GPS-position", "event_gps": "GPS-position",
@@ -346,7 +381,26 @@
"event_location_placeholder": "z. t.ex. Kiel", "event_location_placeholder": "z. t.ex. Kiel",
"event_remarks": "Anmärkningar / incidenter", "event_remarks": "Anmärkningar / incidenter",
"gps_btn": "Hämta GPS-koordinater", "gps_btn": "Hämta GPS-koordinater",
"gps_permission_denied": "Platstillgång nekades. Tillåt det i webbläsar- eller enhetsinställningar och försök igen.",
"gps_timeout": "GPS fick tidsgräns. Försök igen utomhus med fri sikt mot himlen.",
"gps_position_unavailable": "Ingen GPS-signal tillgänglig. Vänta och försök igen, eller ange koordinater manuellt.",
"gps_unavailable": "GPS stöds inte av denna webbläsare eller enhet.",
"gps_failed": "GPS-position kunde inte bestämmas.",
"gps_fallback_no_location": "GPS misslyckades. Ange en plats under ort/hamn, avresa eller destination, eller skriv koordinater manuellt.",
"gps_fallback_success": "Koordinater för \"{{location}}\" hittades via ortsnamn (inte GPS).",
"gps_fallback_failed": "GPS och ortnamnssökning misslyckades. Ange koordinater manuellt.",
"gps_quality_excellent": "Stark GPS-mottagning (±{{accuracy}} m)",
"gps_quality_good": "Bra GPS-mottagning (±{{accuracy}} m)",
"gps_quality_fair": "Måttlig GPS-mottagning (±{{accuracy}} m) gå utomhus för bättre signal.",
"gps_quality_poor": "Svag GPS-mottagning (±{{accuracy}} m) troligen få satelliter. Försök utomhus igen eller kontrollera positionen.",
"gps_quality_unknown": "GPS-position övertagen (noggrannhet ej rapporterad av enheten).",
"gps_live_intro_title": "Plats för live-logg",
"gps_live_intro_body": "Appen behöver din plats för automatiska positionsregistreringar och GPS-knappen.\n\nTryck på „Tillåt plats“ och bekräfta i nästa dialog. Du kan alltid ange position manuellt via „Position“.",
"gps_live_intro_allow": "Tillåt plats",
"gps_live_intro_later": "Senare",
"gps_enable_in_settings_hint": "Platstillgång är blockerad. Du kan tillåta det senare i webbläsar- eller enhetsinställningar (webbplats / app → Plats).",
"weather_btn": "OpenWeatherMap Ring upp väder", "weather_btn": "OpenWeatherMap Ring upp väder",
"weather_offline": "OpenWeatherMap kräver internetanslutning. Du är offline.",
"event_wind_pressure": "Lufttryck (hPa)", "event_wind_pressure": "Lufttryck (hPa)",
"event_heel": "Krängning (°)", "event_heel": "Krängning (°)",
"event_sails": "Segelhantering / motor", "event_sails": "Segelhantering / motor",
@@ -360,6 +414,18 @@
"share_csv": "Aktie", "share_csv": "Aktie",
"export_pdf": "Hämta PDF.", "export_pdf": "Hämta PDF.",
"exporting_pdf": "PDF genereras...", "exporting_pdf": "PDF genereras...",
"ai_summary_title": "AI-sammanfattning",
"ai_summary_read_only": "Skapad av skepparen — endast läsning för besättningen.",
"ai_summary_empty": "Ingen sammanfattning ännu.",
"ai_summary_generate": "Generera sammanfattning",
"ai_summary_regenerate": "Generera igen",
"ai_summary_generating": "Genererar…",
"ai_summary_attempts_remaining": "{{remaining}} av {{max}} försök kvar",
"ai_summary_error": "AI-sammanfattning misslyckades. Försök igen senare.",
"ai_summary_error_no_key": "Ingen OpenRouter API-nyckel konfigurerad på servern.",
"ai_summary_error_rate_limited": "Maximalt antal genereringar nått för denna resedag.",
"ai_summary_error_forbidden": "Endast skepparen får generera AI-sammanfattningar.",
"ai_summary_offline": "AI-sammanfattning kräver internetanslutning. Du är offline.",
"photos_title": "Fotobilagor (E2E-krypterade)", "photos_title": "Fotobilagor (E2E-krypterade)",
"photo_caption_label": "Fotobeskrivning/etikett (valfritt)", "photo_caption_label": "Fotobeskrivning/etikett (valfritt)",
"photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet", "photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet",
@@ -438,8 +504,8 @@
"nmea_change_engine_stop": "Engine off", "nmea_change_engine_stop": "Engine off",
"nmea_change_autopilot_on": "Autopilot on", "nmea_change_autopilot_on": "Autopilot on",
"nmea_change_autopilot_off": "Autopilot off", "nmea_change_autopilot_off": "Autopilot off",
"nmea_change_gps_lost": "GPS fix lost", "nmea_change_gps_lost": "GPS-position förlorad",
"nmea_change_gps_regained": "GPS fix restored", "nmea_change_gps_regained": "GPS-position återställd",
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C", "nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway", "nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop", "nmea_change_anchor": "Anchored / stop",
@@ -453,7 +519,7 @@
"new_logbook_placeholder": "Loggbokens eller båtens namn", "new_logbook_placeholder": "Loggbokens eller båtens namn",
"logout": "Logga ut", "logout": "Logga ut",
"logged_in_as": "Inloggad som {{name}}", "logged_in_as": "Inloggad som {{name}}",
"delete_confirm": "Är du säker på att du vill radera den här loggboken permanent? Alla lokala data och serverkopior kommer att förstöras.\n\nTips: Skapa en säkerhetskopia (.daagbok.json) i förväg under Inställningar → Säkerhetskopiering och återställning om du vill behålla data senare.", "delete_confirm": "Är du säker på att du vill radera den här loggboken permanent? Alla lokala data och serverkopior kommer att förstöras.\n\nTips: Skapa en säkerhetskopia (.daagbok) i förväg under Inställningar → Säkerhetskopiering och återställning om du vill behålla data senare.",
"no_logbooks": "Inga loggböcker hittades. Skapa din första loggbok för att komma igång!", "no_logbooks": "Inga loggböcker hittades. Skapa din första loggbok för att komma igång!",
"loading": "Loggböckerna är fulla...", "loading": "Loggböckerna är fulla...",
"status_synced": "Synkroniserad", "status_synced": "Synkroniserad",
@@ -475,7 +541,7 @@
"edit_success": "Loggboken har framgångsrikt bytt namn", "edit_success": "Loggboken har framgångsrikt bytt namn",
"edit_btn": "Byt namn på", "edit_btn": "Byt namn på",
"filter_label": "Filtrera loggböcker", "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_clear": "Återställ filter",
"filter_results": "{{count}} Träffar", "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.", "filter_no_results": "Inga loggböcker matchar din sökning. Försök med ett annat namn eller ett annat år.",
@@ -606,7 +672,37 @@
"push_unsupported": "Push-meddelanden stöds inte i den här webbläsaren.", "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_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_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": { "person_pool": {
"title": "Stamm-Crew och skeppare", "title": "Stamm-Crew och skeppare",
@@ -702,7 +798,7 @@
"delete_account_confirm_yes": "Ja, radera konto och all data", "delete_account_confirm_yes": "Ja, radera konto och all data",
"delete_account_confirm_no": "Avbryt", "delete_account_confirm_no": "Avbryt",
"delete_account_failed": "Kontot kunde inte raderas. Vänligen försök igen.", "delete_account_failed": "Kontot kunde inte raderas. Vänligen försök igen.",
"delete_backup_hint": "Tips: Skapa säkerhetskopior av dina loggböcker (.daagbok.json) i inställningarna för varje loggbok innan du raderar dem.", "delete_backup_hint": "Tips: Skapa säkerhetskopior av dina loggböcker (.daagbok) i inställningarna för varje loggbok innan du raderar dem.",
"deleting_account": "Kontot kommer att raderas...", "deleting_account": "Kontot kommer att raderas...",
"invite_push_prompt_title": "Aktivera push-meddelanden?", "invite_push_prompt_title": "Aktivera push-meddelanden?",
"invite_push_prompt_message": "Så snart inbjudna Crew-medlemmar synkroniserar ändringar kan du bli informerad via push. Inget loggboksinnehåll skickas i klartext.", "invite_push_prompt_message": "Så snart inbjudna Crew-medlemmar synkroniserar ändringar kan du bli informerad via push. Inget loggboksinnehåll skickas i klartext.",
@@ -713,7 +809,7 @@
"backup_title": "Säkerhetskopiering och återställning", "backup_title": "Säkerhetskopiering och återställning",
"backup_desc": "Komplett krypterad säkerhetskopia av denna loggbok (poster, foton, GPS-spår, crew, fartyg). Skyddad med lösenfras för säkerhetskopian - för återställning till detta eller ett nytt konto.", "backup_desc": "Komplett krypterad säkerhetskopia av denna loggbok (poster, foton, GPS-spår, crew, fartyg). Skyddad med lösenfras för säkerhetskopian - för återställning till detta eller ett nytt konto.",
"backup_export_title": "Skapa säkerhetskopia", "backup_export_title": "Skapa säkerhetskopia",
"backup_export_desc": "Laddar ner alla lokala data som .daagbok.json. Förvara filen och lösenfrasen separat och säkert.", "backup_export_desc": "Laddar ner alla lokala data som ett komprimerat .daagbok-arkiv. Förvara filen och lösenfrasen separat och säkert.",
"backup_restore_title": "Återställ säkerhetskopian", "backup_restore_title": "Återställ säkerhetskopian",
"backup_restore_desc": "Återställer en säkerhetskopia till ditt nuvarande konto - även efter att du har registrerat ett nytt konto.", "backup_restore_desc": "Återställer en säkerhetskopia till ditt nuvarande konto - även efter att du har registrerat ett nytt konto.",
"backup_passphrase": "Lösenord för säkerhetskopiering", "backup_passphrase": "Lösenord för säkerhetskopiering",
@@ -725,7 +821,13 @@
"backup_export_btn": "Ladda ner backup", "backup_export_btn": "Ladda ner backup",
"backup_exporting": "Säkerhetskopian skapas...", "backup_exporting": "Säkerhetskopian skapas...",
"backup_export_success": "Säkerhetskopia skapad ({{count}} resdagar).", "backup_export_success": "Säkerhetskopia skapad ({{count}} resdagar).",
"backup_file_label": "Säkerhetskopieringsfil (.daagbok.json)", "backup_file_label": "Säkerhetskopieringsfil (.daagbok)",
"backup_export_progress": "Packar filer {{current}} / {{total}}…",
"backup_invalid_archive": "Filen är inte ett giltigt backup-arkiv.",
"backup_version_unsupported": "Gammalt backup-format (v1). Använd en aktuell .daagbok-säkerhetskopia.",
"backup_import_size_confirm": "Denna säkerhetskopia är ca. {{size}} okomprimerad. Återställning kan ta längre tid. Fortsätta?",
"backup_stat_voice": "{{count}} röstanteckningar",
"backup_stat_size": "Ca. {{size}} okomprimerat",
"backup_preview_btn": "Kontrollera innehåll", "backup_preview_btn": "Kontrollera innehåll",
"backup_previewing": "Check...", "backup_previewing": "Check...",
"backup_restore_btn": "Återställ", "backup_restore_btn": "Återställ",
@@ -883,7 +985,7 @@
}, },
"welcome_public": { "welcome_public": {
"title": "Välkommen ombord!", "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": { "nav_logs": {
"title": "Loggboksanteckningar", "title": "Loggboksanteckningar",
@@ -902,8 +1004,12 @@
"body": "Ladda upp GPX-filer eller visa redan sparade rutter på kartan - inklusive avstånd och hastighet." "body": "Ladda upp GPX-filer eller visa redan sparade rutter på kartan - inklusive avstånd och hastighet."
}, },
"nav_vessel": { "nav_vessel": {
"title": "Fartygsdata", "title": "Fartyg för loggbok",
"body": "Ange namn, dimensioner och tekniska data för din yacht - fyll i en gång, tillgänglig för alla resdagar." "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": { "profile_crew_pool": {
"title": "Stamm-Crew och skeppare", "title": "Stamm-Crew och skeppare",
+59
View File
@@ -18,3 +18,62 @@ body {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
} }
/* Scrollbars — auf Touch-Geräten breiter und besser sichtbar */
:root {
--app-scrollbar-size: 10px;
}
@media (hover: none), (pointer: coarse), (max-width: 768px) {
:root {
--app-scrollbar-size: 14px;
}
}
html {
scrollbar-width: auto;
scrollbar-color: var(--app-accent-light) var(--app-surface-inset);
-webkit-overflow-scrolling: touch;
}
html::-webkit-scrollbar,
body::-webkit-scrollbar,
*::-webkit-scrollbar {
width: var(--app-scrollbar-size);
height: var(--app-scrollbar-size);
}
html::-webkit-scrollbar-track,
body::-webkit-scrollbar-track,
*::-webkit-scrollbar-track {
background: var(--app-surface-inset);
border-radius: calc(var(--app-scrollbar-size) / 2);
}
html::-webkit-scrollbar-thumb,
body::-webkit-scrollbar-thumb,
*::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--app-accent-light) 55%, transparent);
border-radius: calc(var(--app-scrollbar-size) / 2);
min-height: 48px;
}
html::-webkit-scrollbar-thumb:hover,
body::-webkit-scrollbar-thumb:hover,
*::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--app-accent-light) 80%, transparent);
}
@media (hover: none), (pointer: coarse), (max-width: 768px) {
html::-webkit-scrollbar-thumb,
body::-webkit-scrollbar-thumb,
*::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--app-accent-light) 70%, transparent);
}
html::-webkit-scrollbar-thumb:active,
body::-webkit-scrollbar-thumb:active,
*::-webkit-scrollbar-thumb:active {
background: var(--app-accent-light);
}
}
+27
View File
@@ -7,6 +7,7 @@ import './App.css'
import './i18n' import './i18n'
import App from './App.tsx' import App from './App.tsx'
import { applyAppearanceToDocument } from './services/appearance.ts' import { applyAppearanceToDocument } from './services/appearance.ts'
import { flushPendingPwaBootEvents } from './services/analytics.ts'
import { import {
installStaleAssetRecovery, installStaleAssetRecovery,
markReloadAttempt, markReloadAttempt,
@@ -14,6 +15,15 @@ import {
} from './services/pwaStartup.ts' } from './services/pwaStartup.ts'
import { redirectToPasskeyCompatibleHostIfNeeded } from './utils/passkeyHost.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. */ /** Stale PWA precache on localhost can shadow Vite dev modules. */
async function clearDevServiceWorkerCaches(): Promise<void> { async function clearDevServiceWorkerCaches(): Promise<void> {
if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return
@@ -47,6 +57,10 @@ async function bootstrap(): Promise<void> {
applyAppearanceToDocument() applyAppearanceToDocument()
installStaleAssetRecovery() installStaleAssetRecovery()
flushPendingPwaBootEvents()
window.addEventListener('load', () => {
flushPendingPwaBootEvents()
}, { once: true })
await clearDevServiceWorkerCaches() await clearDevServiceWorkerCaches()
const startupResult = await reconcileVersionOnStartup() const startupResult = await reconcileVersionOnStartup()
@@ -59,6 +73,17 @@ async function bootstrap(): Promise<void> {
return return
} }
if ('serviceWorker' in navigator && !import.meta.env.DEV) {
navigator.serviceWorker
.register('/sw.js', { scope: '/' })
.then((reg) => {
console.log('Service Worker registered successfully with scope:', reg.scope)
})
.catch((err) => {
console.error('Service Worker registration failed:', err)
})
}
const rootEl = document.getElementById('root') const rootEl = document.getElementById('root')
if (!rootEl) { if (!rootEl) {
throw new Error('Missing #root element') throw new Error('Missing #root element')
@@ -69,6 +94,7 @@ async function bootstrap(): Promise<void> {
<App /> <App />
</StrictMode>, </StrictMode>,
) )
window.__KDB_APP_BOOTSTRAPPED = true
} }
void bootstrap().catch((err) => { void bootstrap().catch((err) => {
@@ -76,4 +102,5 @@ void bootstrap().catch((err) => {
renderBootstrapError( renderBootstrapError(
'Die App konnte nicht gestartet werden. Bitte neu laden oder die App vollständig beenden und erneut öffnen.', 'Die App konnte nicht gestartet werden. Bitte neu laden oder die App vollständig beenden und erneut öffnen.',
) )
window.__KDB_APP_BOOTSTRAPPED = false
}) })
+61
View File
@@ -0,0 +1,61 @@
import { describe, it, expect, vi } from 'vitest'
import { buildTravelDayContext } from './aiSummary.js'
import type { LogEventPayload } from '../utils/logEntryPayload.js'
const t = ((key: string, opts?: Record<string, unknown>) => {
if (key === 'logs.live_motor_start') return 'Motor started'
if (key === 'logs.live_event_generic') return 'Event'
if (opts && 'course' in opts) return `Course ${opts.course}`
return key
}) as any
describe('buildTravelDayContext', () => {
it('includes route metadata and formatted events', () => {
const events: LogEventPayload[] = [
{
time: '09:00',
mgk: '180',
rwk: '',
windPressure: '',
windDirection: '',
windStrength: '',
seaState: '',
visibility: '',
weatherIcon: '',
current: '',
heel: '',
sailsOrMotor: 'Genua',
logReading: '',
distance: '',
gpsLat: '',
gpsLng: '',
remarks: '__live:motor_start'
}
]
const context = buildTravelDayContext(
{
date: '2026-06-03',
dayOfTravel: '5',
departure: 'Kiel',
destination: 'Copenhagen',
freshwater: { morning: 100, refilled: 0, evening: 80, consumption: 20 },
fuel: { morning: 50, refilled: 10, evening: 40, consumption: 20 },
greywaterLevel: 0,
trackDistanceNm: 42.5,
motorHours: 3.5,
events
},
t
)
expect(context.departure).toBe('Kiel')
expect(context.destination).toBe('Copenhagen')
expect(context.trackDistanceNm).toBe(42.5)
expect(context.motorHours).toBe(3.5)
expect(context.events).toHaveLength(1)
expect(context.events[0].summary).toBe('Motor started')
expect(context.events[0].sailsOrMotor).toBe('Genua')
expect(context.greywater).toBeUndefined()
})
})
+178
View File
@@ -0,0 +1,178 @@
import type { TFunction } from 'i18next'
import { apiFetch } from './api.js'
import { formatEventSummary } from '../utils/formatEventSummary.js'
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
export class TravelDaySummaryApiError extends Error {
code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'OFFLINE' | 'REQUEST_FAILED'
constructor(
message: string,
code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'OFFLINE' | 'REQUEST_FAILED' = 'REQUEST_FAILED'
) {
super(message)
this.name = 'TravelDaySummaryApiError'
this.code = code
}
}
export interface TravelDaySummaryContext {
date: string
dayOfTravel: string
departure: string
destination: string
trackDistanceNm?: number
trackSpeedMaxKn?: number
trackSpeedAvgKn?: number
motorHours?: number
freshwater?: {
morning: number
refilled: number
evening: number
consumption: number
}
fuel?: {
morning: number
refilled: number
evening: number
consumption: number
}
greywater?: { level: number }
events: Array<{
time: string
summary: string
sailsOrMotor?: string
mgk?: string
windDirection?: string
windStrength?: string
windPressure?: string
seaState?: string
visibility?: string
distance?: string
}>
}
export interface TravelDaySummaryInput {
date: string
dayOfTravel: string
departure: string
destination: string
trackDistanceNm?: number
trackSpeedMaxKn?: number
trackSpeedAvgKn?: number
motorHours?: number
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
fuel: { morning: number; refilled: number; evening: number; consumption: number }
greywaterLevel?: number
events: LogEventPayload[]
}
const SUMMARY_FETCH_TIMEOUT_MS = 90_000
export function buildTravelDayContext(
input: TravelDaySummaryInput,
t: TFunction
): TravelDaySummaryContext {
const context: TravelDaySummaryContext = {
date: input.date,
dayOfTravel: input.dayOfTravel,
departure: input.departure,
destination: input.destination,
freshwater: input.freshwater,
fuel: input.fuel,
events: sortLogEventsByTime(input.events).map((event) => ({
time: event.time,
summary: formatEventSummary(event, t),
...(event.sailsOrMotor ? { sailsOrMotor: event.sailsOrMotor } : {}),
...(event.mgk ? { mgk: event.mgk } : {}),
...(event.windDirection ? { windDirection: event.windDirection } : {}),
...(event.windStrength ? { windStrength: event.windStrength } : {}),
...(event.windPressure ? { windPressure: event.windPressure } : {}),
...(event.seaState ? { seaState: event.seaState } : {}),
...(event.visibility ? { visibility: event.visibility } : {}),
...(event.distance ? { distance: event.distance } : {})
}))
}
if (input.trackDistanceNm !== undefined) context.trackDistanceNm = input.trackDistanceNm
if (input.trackSpeedMaxKn !== undefined) context.trackSpeedMaxKn = input.trackSpeedMaxKn
if (input.trackSpeedAvgKn !== undefined) context.trackSpeedAvgKn = input.trackSpeedAvgKn
if (input.motorHours !== undefined && input.motorHours > 0) context.motorHours = input.motorHours
if (input.greywaterLevel !== undefined && input.greywaterLevel > 0) {
context.greywater = { level: input.greywaterLevel }
}
return context
}
function mapApiError(status: number, data: unknown): TravelDaySummaryApiError {
const code =
typeof data === 'object' && data !== null && 'code' in data
? String((data as { code?: string }).code)
: ''
if (status === 503 || code === 'NO_KEY') {
return new TravelDaySummaryApiError('No OpenRouter API key configured', 'NO_KEY')
}
if (status === 403) {
return new TravelDaySummaryApiError('Forbidden', 'FORBIDDEN')
}
if (status === 429 || code === 'RATE_LIMITED') {
return new TravelDaySummaryApiError('Rate limit exceeded', 'RATE_LIMITED')
}
const message =
typeof data === 'object' && data !== null && 'error' in data && typeof (data as { error: unknown }).error === 'string'
? (data as { error: string }).error
: 'Request failed'
return new TravelDaySummaryApiError(message, 'REQUEST_FAILED')
}
export async function fetchTravelDaySummaryUsage(
logbookId: string,
entryId: string
): Promise<{ remainingAttempts: number; maxAttempts: number }> {
const params = new URLSearchParams({ logbookId, entryId })
const res = await apiFetch(`/api/ai/usage?${params.toString()}`)
const data = await res.json().catch(() => ({}))
if (!res.ok) throw mapApiError(res.status, data)
return data as { remainingAttempts: number; maxAttempts: number }
}
export async function generateTravelDaySummary(params: {
logbookId: string
entryId: string
language: string
context: TravelDaySummaryContext
}): Promise<{ summary: string; remainingAttempts: number; maxAttempts: number }> {
if (!navigator.onLine) {
throw new TravelDaySummaryApiError('Offline', 'OFFLINE')
}
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), SUMMARY_FETCH_TIMEOUT_MS)
let res: Response
try {
res = await apiFetch('/api/ai/summary', {
method: 'POST',
body: JSON.stringify(params),
signal: controller.signal
})
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new TravelDaySummaryApiError('AI summary request timed out')
}
throw err
} finally {
window.clearTimeout(timeoutId)
}
const data = await res.json().catch(() => ({}))
if (!res.ok) throw mapApiError(res.status, data)
trackPlausibleEvent(PlausibleEvents.AI_SUMMARY_GENERATED)
return data as { summary: string; remainingAttempts: number; maxAttempts: number }
}
+64 -2
View File
@@ -26,6 +26,7 @@ export const PlausibleEvents = {
PUSH_ENABLED: 'Push Enabled', PUSH_ENABLED: 'Push Enabled',
PUSH_DISABLED: 'Push Disabled', PUSH_DISABLED: 'Push Disabled',
FOOTER_LINK_CLICKED: 'Footer Link Clicked', FOOTER_LINK_CLICKED: 'Footer Link Clicked',
KOFI_LINK_CLICKED: 'Ko-fi Link Clicked',
PROFILE_OPENED: 'Profile Opened', PROFILE_OPENED: 'Profile Opened',
PASSKEY_ADDED: 'Passkey Added', PASSKEY_ADDED: 'Passkey Added',
PASSKEY_REMOVED: 'Passkey Removed', PASSKEY_REMOVED: 'Passkey Removed',
@@ -40,8 +41,13 @@ export const PlausibleEvents = {
NMEA_UPLOADED: 'NMEA Uploaded', NMEA_UPLOADED: 'NMEA Uploaded',
LIVE_LOG_OPENED: 'Live Log Opened', LIVE_LOG_OPENED: 'Live Log Opened',
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged', LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
LIVE_LOG_PHOTO_UPLOADED: 'Live Log Photo Uploaded', VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded',
OWM_WEATHER_FETCHED: 'OWM Weather Fetched' OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
AI_SUMMARY_GENERATED: 'AI Summary Generated',
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 } as const
/** Where a successful OpenWeatherMap API call originated (no coordinates or place names). */ /** Where a successful OpenWeatherMap API call originated (no coordinates or place names). */
@@ -50,6 +56,13 @@ export type OwmAnalyticsSource = 'live_log' | 'entry_editor' | 'entry_editor_gps
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents] export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
export type PlausibleEventProps = Record<string, string | number | boolean> export type PlausibleEventProps = Record<string, string | number | boolean>
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 { export function trackPlausibleEvent(name: PlausibleEventName, props?: PlausibleEventProps): void {
if (typeof window.plausible !== 'function') return if (typeof window.plausible !== 'function') return
@@ -59,3 +72,52 @@ export function trackPlausibleEvent(name: PlausibleEventName, props?: PlausibleE
} }
window.plausible(name) 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( export async function apiFetch(
input: string, input: string,
init: RequestInit = {} init: RequestInit = {},
timeoutMs = 15000
): Promise<Response> { ): Promise<Response> {
const headers = new Headers(init.headers) const headers = new Headers(init.headers)
if (init.body !== undefined && !headers.has('Content-Type')) { if (init.body !== undefined && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json') headers.set('Content-Type', 'application/json')
} }
return fetch(input, { const controller = new AbortController()
...init, const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
headers,
credentials: 'include' 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> { export async function apiJson<T>(
const res = await apiFetch(input, init) input: string,
init: RequestInit = {},
timeoutMs = 15000
): Promise<T> {
const res = await apiFetch(input, init, timeoutMs)
const data = await res.json().catch(() => ({})) const data = await res.json().catch(() => ({}))
if (!res.ok) { if (!res.ok) {
const message = const message =
+7 -1
View File
@@ -556,9 +556,15 @@ export async function deleteAccount(): Promise<boolean> {
db.deviations.clear(), db.deviations.clear(),
db.entries.clear(), db.entries.clear(),
db.photos.clear(), db.photos.clear(),
db.voiceMemos.clear(),
db.gpsTracks.clear(), db.gpsTracks.clear(),
db.syncQueue.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 // Wipe localStorage and session variables
+18 -21
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.') throw new Error('Encryption key not found. User must log in.')
} }
// 1. Fetch Yacht details const { resolveVesselForLogbook } = await import('./resolveVessel.js')
const yachtRecord = await db.yachts.get(logbookId); const yacht = await resolveVesselForLogbook(logbookId)
if (yachtRecord) { if (yacht) {
try { yachtName = yacht.name || ''
const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey); homePort = yacht.homePort || ''
yachtName = yacht.name || ''; owner = yacht.owner || ''
homePort = yacht.port || ''; charter = yacht.charterCompany || ''
owner = yacht.owner || ''; registration = yacht.registrationNumber || ''
charter = yacht.charter || ''; callsign = yacht.callSign || ''
registration = yacht.registration || ''; atis = yacht.atis || ''
callsign = yacht.callsign || ''; mmsi = yacht.mmsi || ''
atis = yacht.atis || '';
mmsi = yacht.mmsi || '';
} catch (e) {
console.error('Failed to decrypt yacht details for CSV:', e);
}
} }
// 2. Fetch logbook entries // 2. Fetch logbook entries
@@ -79,11 +74,11 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
// Headers matching the requested event fields & metadata // Headers matching the requested event fields & metadata
const headers = [ const headers = [
'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'AI Summary',
'Skipper Signature', 'Crew Signature', 'Skipper Signature', 'Crew Signature',
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)', 'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
'Event Time', 'MgK Course', 'RwK Course', '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)', 'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
'Latitude', 'Longitude', 'Remarks', 'Latitude', 'Longitude', 'Remarks',
'Freshwater Morning (L)', 'Freshwater Refilled (L)', 'Freshwater Evening (L)', 'Freshwater Consumption (L)', 'Freshwater Morning (L)', 'Freshwater Refilled (L)', 'Freshwater Evening (L)', 'Freshwater Consumption (L)',
@@ -125,16 +120,17 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const fuelE = entry.fuel?.evening ?? ''; const fuelE = entry.fuel?.evening ?? '';
const fuelCons = entry.fuel?.consumption ?? ''; const fuelCons = entry.fuel?.consumption ?? '';
const greywaterLevel = entry.greywater?.level ?? ''; const greywaterLevel = entry.greywater?.level ?? '';
const aiSummary = entry.aiSummary ?? '';
const eventsList = entry.events || []; const eventsList = entry.events || [];
if (eventsList.length === 0) { if (eventsList.length === 0) {
// Create one row even if there are no events for the day // Create one row even if there are no events for the day
rows.push([ rows.push([
dateVal, travelDay, dep, dest, dateVal, travelDay, dep, dest, aiSummary,
signS, signC, signS, signC,
trackDist, trackMax, trackAvg, motorH, trackDist, trackMax, trackAvg, motorH,
'', '', '', '', '', '',
'', '', '', '', '', '', '', '', '',
'', '', '', '', '', '', '', '', '', '',
'', '', '', '', '', '',
fwM, fwR, fwE, fwCons, fwM, fwR, fwE, fwCons,
@@ -147,11 +143,12 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const sortedEvents = sortLogEventsByTime(eventsList); const sortedEvents = sortLogEventsByTime(eventsList);
for (const ev of sortedEvents) { for (const ev of sortedEvents) {
rows.push([ rows.push([
dateVal, travelDay, dep, dest, dateVal, travelDay, dep, dest, aiSummary,
signS, signC, signS, signC,
trackDist, trackMax, trackAvg, motorH, trackDist, trackMax, trackAvg, motorH,
ev.time || '', ev.mgk || '', ev.rwk || '', ev.time || '', ev.mgk || '', ev.rwk || '',
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '', ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
ev.visibility || '',
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '', ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
ev.gpsLat || '', ev.gpsLng || '', ev.remarks || '', ev.gpsLat || '', ev.gpsLng || '', ev.remarks || '',
fwM, fwR, fwE, fwCons, fwM, fwR, fwE, fwCons,
+88 -2
View File
@@ -35,6 +35,14 @@ export interface LocalDeviation {
updatedAt: string updatedAt: string
} }
export interface EntryListCache {
date: string
dayOfTravel: string
departure: string
destination: string
skipperSignStatus: 'none' | 'valid' | 'invalid'
}
export interface LocalEntry { export interface LocalEntry {
payloadId: string payloadId: string
logbookId: string logbookId: string
@@ -42,6 +50,8 @@ export interface LocalEntry {
iv: string iv: string
tag: string tag: string
updatedAt: string updatedAt: string
/** Plaintext list fields — avoids full decrypt when opening the journal list. */
listCache?: EntryListCache
} }
export interface LocalPhoto { export interface LocalPhoto {
@@ -55,6 +65,16 @@ export interface LocalPhoto {
updatedAt: string updatedAt: string
} }
export interface LocalVoiceMemo {
payloadId: string
entryId: string
logbookId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface LocalGpsTrack { export interface LocalGpsTrack {
entryId: string // one track per daily journal entry entryId: string // one track per daily journal entry
logbookId: string logbookId: string
@@ -88,6 +108,14 @@ export interface LocalPerson {
updatedAt: string updatedAt: string
} }
export interface LocalVessel {
payloadId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface LocalLogbookCrewSelection { export interface LocalLogbookCrewSelection {
logbookId: string logbookId: string
encryptedData: string encryptedData: string
@@ -96,10 +124,28 @@ export interface LocalLogbookCrewSelection {
updatedAt: string updatedAt: string
} }
export interface LocalLogbookVesselSelection {
logbookId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface SyncQueueItem { export interface SyncQueueItem {
id?: number id?: number
action: 'create' | 'update' | 'delete' action: 'create' | 'update' | 'delete'
type: 'yacht' | 'crew' | 'deviation' | 'entry' | 'logbook' | 'photo' | 'gpsTrack' | 'logbookCrew' type:
| 'yacht'
| 'crew'
| 'deviation'
| 'entry'
| 'logbook'
| 'photo'
| 'voiceMemo'
| 'gpsTrack'
| 'logbookCrew'
| 'logbookVessel'
payloadId: string // payloadId or logbookId depending on the type payloadId: string // payloadId or logbookId depending on the type
logbookId: string logbookId: string
data: string // JSON representation of the local record data: string // JSON representation of the local record
@@ -109,7 +155,7 @@ export interface SyncQueueItem {
export interface UserSyncQueueItem { export interface UserSyncQueueItem {
id?: number id?: number
action: 'create' | 'update' | 'delete' action: 'create' | 'update' | 'delete'
type: 'person' type: 'person' | 'vessel'
payloadId: string payloadId: string
data: string data: string
updatedAt: string updatedAt: string
@@ -131,11 +177,14 @@ class DaagboxDatabase extends Dexie {
deviations!: Table<LocalDeviation> deviations!: Table<LocalDeviation>
entries!: Table<LocalEntry> entries!: Table<LocalEntry>
photos!: Table<LocalPhoto> photos!: Table<LocalPhoto>
voiceMemos!: Table<LocalVoiceMemo>
gpsTracks!: Table<LocalGpsTrack> gpsTracks!: Table<LocalGpsTrack>
nmeaArchives!: Table<LocalNmeaArchive> nmeaArchives!: Table<LocalNmeaArchive>
logbookKeys!: Table<LocalLogbookKey> logbookKeys!: Table<LocalLogbookKey>
personPool!: Table<LocalPerson> personPool!: Table<LocalPerson>
vesselPool!: Table<LocalVessel>
logbookCrewSelections!: Table<LocalLogbookCrewSelection> logbookCrewSelections!: Table<LocalLogbookCrewSelection>
logbookVesselSelections!: Table<LocalLogbookVesselSelection>
syncQueue!: Table<SyncQueueItem> syncQueue!: Table<SyncQueueItem>
userSyncQueue!: Table<UserSyncQueueItem> userSyncQueue!: Table<UserSyncQueueItem>
entryDrafts!: Table<EntryDraftRecord, [string, string]> entryDrafts!: Table<EntryDraftRecord, [string, string]>
@@ -234,6 +283,43 @@ class DaagboxDatabase extends Dexie {
userSyncQueue: '++id, action, type, payloadId', userSyncQueue: '++id, action, type, payloadId',
entryDrafts: '[logbookId+entryId], updatedAt' 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'
})
this.version(10).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',
voiceMemos: '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 { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js' import { encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js' import { syncLogbook } from './sync.js'
import { putEntryRecord } from '../utils/entryListCache.js'
import { syncPersonPool } from './personPoolSync.js' import { syncPersonPool } from './personPoolSync.js'
import i18n from '../i18n/index.js' import i18n from '../i18n/index.js'
import type { PersonData } from '../types/person.js' import type { PersonData } from '../types/person.js'
@@ -35,14 +36,17 @@ async function putEncryptedRecord(
const encrypted = await encryptJson(data, key) const encrypted = await encryptJson(data, key)
if (type === 'entry') { if (type === 'entry') {
await db.entries.put({ await putEntryRecord(
payloadId, {
logbookId, payloadId,
encryptedData: encrypted.ciphertext, logbookId,
iv: encrypted.iv, encryptedData: encrypted.ciphertext,
tag: encrypted.tag, iv: encrypted.iv,
updatedAt: now tag: encrypted.tag,
}) updatedAt: now
},
data as Record<string, unknown>
)
} else if (type === 'yacht') { } else if (type === 'yacht') {
await db.yachts.put({ await db.yachts.put({
logbookId, logbookId,
+33
View File
@@ -17,6 +17,7 @@ const PUBLIC_DEMO_ENTRY_IDS = [
] as const ] as const
export const PUBLIC_DEMO_SKIPPER_ID = 'skipper' 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' const PUBLIC_DEMO_CREW_MEMBER_ID = 'a0000001-0000-4000-8000-000000000010'
export interface DemoDaySpec { export interface DemoDaySpec {
@@ -50,9 +51,19 @@ export interface DemoCrewRecord {
} }
} }
export interface DemoVesselRecord {
payloadId: string
data: Record<string, unknown> & { name: string }
}
export interface PublicDemoFixture { export interface PublicDemoFixture {
title: string title: string
yacht: Record<string, unknown> yacht: Record<string, unknown>
vesselPool: DemoVesselRecord[]
logbookVesselSelection: {
activeVesselId: string | null
vesselSnapshot: Record<string, unknown> | null
}
/** @deprecated legacy share payload */ /** @deprecated legacy share payload */
crews: DemoCrewRecord[] crews: DemoCrewRecord[]
personPool: 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[]) { function buildDemoLogbookCrewSelection(pool: DemoCrewRecord[]) {
const skipper = pool.find((p) => p.data.role === 'skipper') const skipper = pool.find((p) => p.data.role === 'skipper')
const crew = pool.filter((p) => p.data.role === 'crew') const crew = pool.filter((p) => p.data.role === 'crew')
@@ -255,6 +284,8 @@ function buildDemoLogbookCrewSelection(pool: DemoCrewRecord[]) {
export function buildPublicDemoFixture(): PublicDemoFixture { export function buildPublicDemoFixture(): PublicDemoFixture {
const title = i18n.t('demo.logbook_title') const title = i18n.t('demo.logbook_title')
const yacht = buildDemoYachtData() const yacht = buildDemoYachtData()
const vesselPool = buildDemoVesselPool(yacht)
const logbookVesselSelection = buildDemoLogbookVesselSelection(yacht)
const personPool = buildDemoPersonPool() const personPool = buildDemoPersonPool()
const crews = personPool const crews = personPool
const logbookCrewSelection = buildDemoLogbookCrewSelection(personPool) const logbookCrewSelection = buildDemoLogbookCrewSelection(personPool)
@@ -310,6 +341,8 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
return { return {
title, title,
yacht, yacht,
vesselPool,
logbookVesselSelection,
crews, crews,
personPool, personPool,
logbookCrewSelection, logbookCrewSelection,
+1
View File
@@ -283,6 +283,7 @@ export async function deleteLocalLogbookCache(id: string): Promise<void> {
await db.deviations.where({ logbookId: id }).delete() await db.deviations.where({ logbookId: id }).delete()
await db.entries.where({ logbookId: id }).delete() await db.entries.where({ logbookId: id }).delete()
await db.photos.where({ logbookId: id }).delete() await db.photos.where({ logbookId: id }).delete()
await db.voiceMemos.where({ logbookId: id }).delete()
await db.gpsTracks.where({ logbookId: id }).delete() await db.gpsTracks.where({ logbookId: id }).delete()
await db.syncQueue.where({ logbookId: id }).delete() await db.syncQueue.where({ logbookId: id }).delete()
await db.logbookKeys.where({ logbookId: id }).delete() await db.logbookKeys.where({ logbookId: id }).delete()
+360 -311
View File
@@ -1,3 +1,4 @@
import { formatAppDecimal } from '../utils/numberFormat.js'
import { db } from './db.js' import { db } from './db.js'
import { getActiveMasterKey } from './auth.js' import { getActiveMasterKey } from './auth.js'
import { import {
@@ -9,89 +10,54 @@ import { decryptLogbookTitle, deleteLocalLogbookCache } from './logbook.js'
import { ensureLogbookKey, getLogbookKey, saveLogbookKey } from './logbookKeys.js' import { ensureLogbookKey, getLogbookKey, saveLogbookKey } from './logbookKeys.js'
import { syncLogbook } from './sync.js' import { syncLogbook } from './sync.js'
import type { SyncQueueItem } from './db.js' import type { SyncQueueItem } from './db.js'
import { getAppVersion } from './pwaVersion.js'
import { dexieFieldsFromEncBytes, encBytesFromDexieFields } from './logbookBackup/encBlob.js'
import {
BACKUP_FORMAT,
BACKUP_VERSION,
type BackupManifestCounts,
type BackupManifestV2,
type LogbookMetaJson
} from './logbookBackup/manifest.js'
import {
buildArchiveFromCollected,
collectLogbookBackupData,
type BackupExportProgress
} from './logbookBackup/collector.js'
import {
isZipArchive,
readBinaryFile,
readManifestFromArchive,
readTextFile,
unzipArchive
} from './logbookBackup/zipArchive.js'
export const BACKUP_FORMAT = 'kapteins-daagbok-backup' as const export { BACKUP_FORMAT, BACKUP_VERSION }
export const BACKUP_VERSION = 1 as const export type { BackupExportProgress, BackupManifestCounts, BackupManifestV2 }
export interface LogbookBackupFile {
format: typeof BACKUP_FORMAT
version: typeof BACKUP_VERSION
exportedAt: string
logbook: {
id: string
encryptedTitle: string
updatedAt: string
isDemo?: boolean
}
logbookKey: {
ciphertext: string
iv: string
tag: string
}
payloads: {
yacht: {
encryptedData: string
iv: string
tag: string
updatedAt: string
} | null
deviation: {
encryptedData: string
iv: string
tag: string
updatedAt: string
} | null
crews: Array<{
payloadId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}>
entries: Array<{
payloadId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}>
photos: Array<{
payloadId: string
entryId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}>
gpsTracks: Array<{
entryId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}>
}
counts: {
entries: number
photos: number
crews: number
gpsTracks: number
hasYacht: boolean
hasDeviation: boolean
}
}
export interface LogbookBackupPreview { export interface LogbookBackupPreview {
title: string title: string
exportedAt: string exportedAt: string
sourceLogbookId: string sourceLogbookId: string
counts: LogbookBackupFile['counts'] counts: BackupManifestCounts
totalUncompressedBytes: number
} }
export interface ParsedLogbookBackup {
manifest: BackupManifestV2
files: Record<string, Uint8Array>
}
export interface ExportLogbookBackupOptions {
onProgress?: (progress: BackupExportProgress) => void
}
const BACKUP_PASSPHRASE_SALT = 'KapteinsDaagbokBackupFileSalt_v1'
async function deriveBackupPassphraseKey(passphrase: string): Promise<CryptoKey> { async function deriveBackupPassphraseKey(passphrase: string): Promise<CryptoKey> {
const encoder = new TextEncoder() const encoder = new TextEncoder()
const passphraseBytes = encoder.encode(passphrase.trim()) const passphraseBytes = encoder.encode(passphrase.trim())
const saltBytes = encoder.encode('KapteinsDaagbokBackupFileSalt_v1') const saltBytes = encoder.encode(BACKUP_PASSPHRASE_SALT)
const baseKey = await window.crypto.subtle.importKey( const baseKey = await window.crypto.subtle.importKey(
'raw', 'raw',
@@ -120,26 +86,17 @@ async function wrapLogbookKey(logbookKey: ArrayBuffer, passphrase: string) {
return encryptBuffer(logbookKey, key) return encryptBuffer(logbookKey, key)
} }
async function unwrapLogbookKey( async function unwrapLogbookKeyFromEnc(
wrapped: LogbookBackupFile['logbookKey'], keyEnc: Uint8Array,
passphrase: string passphrase: string
): Promise<ArrayBuffer> { ): Promise<ArrayBuffer> {
const key = await deriveBackupPassphraseKey(passphrase) try {
return decryptBuffer(wrapped.ciphertext, wrapped.iv, wrapped.tag, key) const fields = dexieFieldsFromEncBytes(keyEnc)
} const cryptoKey = await deriveBackupPassphraseKey(passphrase)
return decryptBuffer(fields.encryptedData, fields.iv, fields.tag, cryptoKey)
function isBackupFile(value: unknown): value is LogbookBackupFile { } catch {
if (!value || typeof value !== 'object') return false throw new Error('BACKUP_WRONG_PASSPHRASE')
const obj = value as Partial<LogbookBackupFile> }
return (
obj.format === BACKUP_FORMAT &&
obj.version === BACKUP_VERSION &&
typeof obj.exportedAt === 'string' &&
!!obj.logbook?.id &&
!!obj.logbook?.encryptedTitle &&
!!obj.logbookKey?.ciphertext &&
!!obj.payloads
)
} }
function encryptedPayloadData( function encryptedPayloadData(
@@ -156,96 +113,12 @@ function encryptedPayloadData(
}) })
} }
async function collectLogbookPayloads(logbookId: string): Promise<LogbookBackupFile['payloads']> {
const [yacht, deviation, crews, entries, photos, gpsTracks] = await Promise.all([
db.yachts.get(logbookId),
db.deviations.get(logbookId),
db.crews.where({ logbookId }).toArray(),
db.entries.where({ logbookId }).toArray(),
db.photos.where({ logbookId }).toArray(),
db.gpsTracks.where({ logbookId }).toArray()
])
return {
yacht: yacht
? {
encryptedData: yacht.encryptedData,
iv: yacht.iv,
tag: yacht.tag,
updatedAt: yacht.updatedAt
}
: null,
deviation: deviation
? {
encryptedData: deviation.encryptedData,
iv: deviation.iv,
tag: deviation.tag,
updatedAt: deviation.updatedAt
}
: null,
crews: crews.map((c) => ({
payloadId: c.payloadId,
encryptedData: c.encryptedData,
iv: c.iv,
tag: c.tag,
updatedAt: c.updatedAt
})),
entries: entries.map((e) => ({
payloadId: e.payloadId,
encryptedData: e.encryptedData,
iv: e.iv,
tag: e.tag,
updatedAt: e.updatedAt
})),
photos: photos.map((p) => ({
payloadId: p.payloadId,
entryId: p.entryId,
encryptedData: p.encryptedData,
iv: p.iv,
tag: p.tag,
updatedAt: p.updatedAt
})),
gpsTracks: gpsTracks.map((t) => ({
entryId: t.entryId,
encryptedData: t.encryptedData,
iv: t.iv,
tag: t.tag,
updatedAt: t.updatedAt
}))
}
}
function remapBackup(
backup: LogbookBackupFile,
newLogbookId: string
): LogbookBackupFile {
return {
...backup,
logbook: {
...backup.logbook,
id: newLogbookId
},
payloads: {
...backup.payloads,
yacht: backup.payloads.yacht
? { ...backup.payloads.yacht, updatedAt: backup.payloads.yacht.updatedAt }
: null,
deviation: backup.payloads.deviation
? { ...backup.payloads.deviation, updatedAt: backup.payloads.deviation.updatedAt }
: null,
crews: backup.payloads.crews.map((c) => ({ ...c })),
entries: backup.payloads.entries.map((e) => ({ ...e })),
photos: backup.payloads.photos.map((p) => ({ ...p })),
gpsTracks: backup.payloads.gpsTracks.map((t) => ({ ...t }))
}
}
}
async function queueRestoredLogbookForSync( async function queueRestoredLogbookForSync(
logbookId: string, logbookId: string,
encryptedTitle: string, encryptedTitle: string,
logbookKey: ArrayBuffer, logbookKey: ArrayBuffer,
payloads: LogbookBackupFile['payloads'] manifest: BackupManifestV2,
files: Record<string, Uint8Array>
): Promise<void> { ): Promise<void> {
const masterKey = getActiveMasterKey() const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found') if (!masterKey) throw new Error('Master key not found')
@@ -276,78 +149,123 @@ async function queueRestoredLogbookForSync(
} }
] ]
if (payloads.yacht) { const readFields = (path: string | null) => {
if (!path) return null
return dexieFieldsFromEncBytes(readBinaryFile(files, path))
}
const yacht = readFields(manifest.files.yacht)
if (yacht) {
items.push({ items.push({
action: 'update', action: 'update',
type: 'yacht', type: 'yacht',
payloadId: logbookId, payloadId: logbookId,
logbookId, logbookId,
data: encryptedPayloadData( data: encryptedPayloadData(yacht.encryptedData, yacht.iv, yacht.tag),
payloads.yacht.encryptedData, updatedAt: now
payloads.yacht.iv,
payloads.yacht.tag
),
updatedAt: payloads.yacht.updatedAt
}) })
} }
if (payloads.deviation) { const deviation = readFields(manifest.files.deviation)
if (deviation) {
items.push({ items.push({
action: 'update', action: 'update',
type: 'deviation', type: 'deviation',
payloadId: logbookId, payloadId: logbookId,
logbookId, logbookId,
data: encryptedPayloadData( data: encryptedPayloadData(deviation.encryptedData, deviation.iv, deviation.tag),
payloads.deviation.encryptedData, updatedAt: now
payloads.deviation.iv,
payloads.deviation.tag
),
updatedAt: payloads.deviation.updatedAt
}) })
} }
for (const crew of payloads.crews) { const logbookCrew = readFields(manifest.files.logbookCrewSelection)
if (logbookCrew) {
items.push({
action: 'update',
type: 'logbookCrew',
payloadId: logbookId,
logbookId,
data: encryptedPayloadData(logbookCrew.encryptedData, logbookCrew.iv, logbookCrew.tag),
updatedAt: now
})
}
const logbookVessel = readFields(manifest.files.logbookVesselSelection)
if (logbookVessel) {
items.push({
action: 'update',
type: 'logbookVessel',
payloadId: logbookId,
logbookId,
data: encryptedPayloadData(
logbookVessel.encryptedData,
logbookVessel.iv,
logbookVessel.tag
),
updatedAt: now
})
}
for (const crew of manifest.files.crews) {
const f = readFields(crew.path)
items.push({ items.push({
action: 'create', action: 'create',
type: 'crew', type: 'crew',
payloadId: crew.payloadId, payloadId: crew.payloadId,
logbookId, logbookId,
data: encryptedPayloadData(crew.encryptedData, crew.iv, crew.tag), data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
updatedAt: crew.updatedAt updatedAt: crew.updatedAt
}) })
} }
for (const entry of payloads.entries) { for (const entry of manifest.files.entries) {
const f = readFields(entry.path)
items.push({ items.push({
action: 'create', action: 'create',
type: 'entry', type: 'entry',
payloadId: entry.payloadId, payloadId: entry.payloadId,
logbookId, logbookId,
data: encryptedPayloadData(entry.encryptedData, entry.iv, entry.tag), data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
updatedAt: entry.updatedAt updatedAt: entry.updatedAt
}) })
} }
for (const photo of payloads.photos) { for (const photo of manifest.files.photos) {
const f = readFields(photo.path)
items.push({ items.push({
action: 'create', action: 'create',
type: 'photo', type: 'photo',
payloadId: photo.payloadId, payloadId: photo.payloadId,
logbookId, logbookId,
data: encryptedPayloadData(photo.encryptedData, photo.iv, photo.tag, { data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag, {
entryId: photo.entryId entryId: photo.entryId
}), }),
updatedAt: photo.updatedAt updatedAt: photo.updatedAt
}) })
} }
for (const track of payloads.gpsTracks) { for (const voice of manifest.files.voiceMemos) {
const f = readFields(voice.path)
items.push({
action: 'create',
type: 'voiceMemo',
payloadId: voice.payloadId,
logbookId,
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag, {
entryId: voice.entryId
}),
updatedAt: voice.updatedAt
})
}
for (const track of manifest.files.gpsTracks) {
const f = readFields(track.path)
items.push({ items.push({
action: 'create', action: 'create',
type: 'gpsTrack', type: 'gpsTrack',
payloadId: track.entryId, payloadId: track.entryId,
logbookId, logbookId,
data: encryptedPayloadData(track.encryptedData, track.iv, track.tag), data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
updatedAt: track.updatedAt updatedAt: track.updatedAt
}) })
} }
@@ -357,101 +275,190 @@ async function queueRestoredLogbookForSync(
async function writeBackupToDexie( async function writeBackupToDexie(
logbookId: string, logbookId: string,
backup: LogbookBackupFile, logbookMeta: LogbookMetaJson,
logbookKey: ArrayBuffer logbookKey: ArrayBuffer,
manifest: BackupManifestV2,
files: Record<string, Uint8Array>
): Promise<void> { ): Promise<void> {
const { logbook, payloads } = backup
await db.logbooks.put({ await db.logbooks.put({
id: logbookId, id: logbookId,
encryptedTitle: logbook.encryptedTitle, encryptedTitle: logbookMeta.encryptedTitle,
updatedAt: logbook.updatedAt, updatedAt: logbookMeta.updatedAt,
isSynced: 0, isSynced: 0,
isShared: 0, isShared: 0,
isDemo: logbook.isDemo ? 1 : 0 isDemo: logbookMeta.isDemo ? 1 : 0
}) })
await saveLogbookKey(logbookId, logbookKey) await saveLogbookKey(logbookId, logbookKey)
if (payloads.yacht) { const readFields = (path: string | null) => {
if (!path) return null
return dexieFieldsFromEncBytes(readBinaryFile(files, path))
}
const yacht = readFields(manifest.files.yacht)
if (yacht) {
await db.yachts.put({ await db.yachts.put({
logbookId, logbookId,
encryptedData: payloads.yacht.encryptedData, encryptedData: yacht.encryptedData,
iv: payloads.yacht.iv, iv: yacht.iv,
tag: payloads.yacht.tag, tag: yacht.tag,
updatedAt: payloads.yacht.updatedAt updatedAt: logbookMeta.updatedAt
}) })
} }
if (payloads.deviation) { const deviation = readFields(manifest.files.deviation)
if (deviation) {
await db.deviations.put({ await db.deviations.put({
logbookId, logbookId,
encryptedData: payloads.deviation.encryptedData, encryptedData: deviation.encryptedData,
iv: payloads.deviation.iv, iv: deviation.iv,
tag: payloads.deviation.tag, tag: deviation.tag,
updatedAt: payloads.deviation.updatedAt updatedAt: logbookMeta.updatedAt
}) })
} }
if (payloads.crews.length > 0) { const logbookCrew = readFields(manifest.files.logbookCrewSelection)
if (logbookCrew) {
await db.logbookCrewSelections.put({
logbookId,
encryptedData: logbookCrew.encryptedData,
iv: logbookCrew.iv,
tag: logbookCrew.tag,
updatedAt: logbookMeta.updatedAt
})
}
const logbookVessel = readFields(manifest.files.logbookVesselSelection)
if (logbookVessel) {
await db.logbookVesselSelections.put({
logbookId,
encryptedData: logbookVessel.encryptedData,
iv: logbookVessel.iv,
tag: logbookVessel.tag,
updatedAt: logbookMeta.updatedAt
})
}
if (manifest.files.crews.length > 0) {
await db.crews.bulkPut( await db.crews.bulkPut(
payloads.crews.map((c) => ({ manifest.files.crews.map((c) => {
payloadId: c.payloadId, const f = dexieFieldsFromEncBytes(readBinaryFile(files, c.path))
logbookId, return {
encryptedData: c.encryptedData, payloadId: c.payloadId,
iv: c.iv, logbookId,
tag: c.tag, encryptedData: f.encryptedData,
updatedAt: c.updatedAt iv: f.iv,
})) tag: f.tag,
updatedAt: c.updatedAt
}
})
) )
} }
if (payloads.entries.length > 0) { if (manifest.files.entries.length > 0) {
await db.entries.bulkPut( await db.entries.bulkPut(
payloads.entries.map((e) => ({ manifest.files.entries.map((e) => {
payloadId: e.payloadId, const f = dexieFieldsFromEncBytes(readBinaryFile(files, e.path))
logbookId, return {
encryptedData: e.encryptedData, payloadId: e.payloadId,
iv: e.iv, logbookId,
tag: e.tag, encryptedData: f.encryptedData,
updatedAt: e.updatedAt iv: f.iv,
})) tag: f.tag,
updatedAt: e.updatedAt
}
})
) )
} }
if (payloads.photos.length > 0) { if (manifest.files.photos.length > 0) {
await db.photos.bulkPut( await db.photos.bulkPut(
payloads.photos.map((p) => ({ manifest.files.photos.map((p) => {
payloadId: p.payloadId, const f = dexieFieldsFromEncBytes(readBinaryFile(files, p.path))
entryId: p.entryId, return {
logbookId, payloadId: p.payloadId,
encryptedData: p.encryptedData, entryId: p.entryId,
iv: p.iv, logbookId,
tag: p.tag, encryptedData: f.encryptedData,
caption: '', iv: f.iv,
updatedAt: p.updatedAt tag: f.tag,
})) caption: '',
updatedAt: p.updatedAt
}
})
) )
} }
if (payloads.gpsTracks.length > 0) { if (manifest.files.voiceMemos.length > 0) {
await db.gpsTracks.bulkPut( await db.voiceMemos.bulkPut(
payloads.gpsTracks.map((t) => ({ manifest.files.voiceMemos.map((v) => {
entryId: t.entryId, const f = dexieFieldsFromEncBytes(readBinaryFile(files, v.path))
logbookId, return {
encryptedData: t.encryptedData, payloadId: v.payloadId,
iv: t.iv, entryId: v.entryId,
tag: t.tag, logbookId,
updatedAt: t.updatedAt encryptedData: f.encryptedData,
})) iv: f.iv,
tag: f.tag,
updatedAt: v.updatedAt
}
})
) )
} }
if (manifest.files.gpsTracks.length > 0) {
await db.gpsTracks.bulkPut(
manifest.files.gpsTracks.map((t) => {
const f = dexieFieldsFromEncBytes(readBinaryFile(files, t.path))
return {
entryId: t.entryId,
logbookId,
encryptedData: f.encryptedData,
iv: f.iv,
tag: f.tag,
updatedAt: t.updatedAt
}
})
)
}
if (manifest.files.nmeaArchives.length > 0) {
await db.nmeaArchives.bulkPut(
manifest.files.nmeaArchives.map((n) => {
const f = dexieFieldsFromEncBytes(readBinaryFile(files, n.path))
return {
entryId: n.entryId,
logbookId,
encryptedData: f.encryptedData,
iv: f.iv,
tag: f.tag,
updatedAt: n.updatedAt
}
})
)
}
}
function remapParsedBackup(
parsed: ParsedLogbookBackup,
newLogbookId: string
): ParsedLogbookBackup {
const logbookMeta = JSON.parse(readTextFile(parsed.files, parsed.manifest.files.logbook)) as LogbookMetaJson
logbookMeta.id = newLogbookId
const newFiles = { ...parsed.files }
newFiles[parsed.manifest.files.logbook] = new TextEncoder().encode(JSON.stringify(logbookMeta))
return {
manifest: { ...parsed.manifest, logbookId: newLogbookId },
files: newFiles
}
} }
export async function exportLogbookBackup( export async function exportLogbookBackup(
logbookId: string, logbookId: string,
passphrase: string passphrase: string,
): Promise<{ blob: Blob; filename: string; backup: LogbookBackupFile }> { options: ExportLogbookBackupOptions = {}
): Promise<{ blob: Blob; filename: string; manifest: BackupManifestV2 }> {
if (!passphrase.trim() || passphrase.length < 8) { if (!passphrase.trim() || passphrase.length < 8) {
throw new Error('BACKUP_PASSPHRASE_TOO_SHORT') throw new Error('BACKUP_PASSPHRASE_TOO_SHORT')
} }
@@ -467,70 +474,84 @@ export async function exportLogbookBackup(
}) })
} }
options.onProgress?.({ phase: 'collect', current: 0, total: 1, bytesPacked: 0 })
const collected = await collectLogbookBackupData(logbookId)
const logbookKey = (await getLogbookKey(logbookId)) ?? (await ensureLogbookKey(logbookId)) const logbookKey = (await getLogbookKey(logbookId)) ?? (await ensureLogbookKey(logbookId))
const payloads = await collectLogbookPayloads(logbookId) const wrapped = await wrapLogbookKey(logbookKey, passphrase)
const wrappedKey = await wrapLogbookKey(logbookKey, passphrase) const keyEnc = encBytesFromDexieFields({
encryptedData: wrapped.ciphertext,
iv: wrapped.iv,
tag: wrapped.tag
})
const backup: LogbookBackupFile = { const { zipBytes, manifest } = buildArchiveFromCollected(collected, keyEnc, {
format: BACKUP_FORMAT,
version: BACKUP_VERSION,
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
logbook: { appVersion: getAppVersion(),
id: logbook.id, onProgress: options.onProgress
encryptedTitle: logbook.encryptedTitle, })
updatedAt: logbook.updatedAt,
isDemo: logbook.isDemo === 1
},
logbookKey: wrappedKey,
payloads,
counts: {
entries: payloads.entries.length,
photos: payloads.photos.length,
crews: payloads.crews.length,
gpsTracks: payloads.gpsTracks.length,
hasYacht: !!payloads.yacht,
hasDeviation: !!payloads.deviation
}
}
const title = await decryptLogbookTitle(logbookId, logbook.encryptedTitle) const title = await decryptLogbookTitle(logbookId, logbook.encryptedTitle)
const safeTitle = title.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook' const safeTitle = title.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook'
const datePart = new Date().toISOString().slice(0, 10) const datePart = new Date().toISOString().slice(0, 10)
const filename = `${safeTitle}-${datePart}.daagbok.json` const filename = `${safeTitle}-${datePart}.daagbok`
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }) const blob = new Blob([zipBytes.slice()], { type: 'application/zip' })
return { blob, filename, backup } return { blob, filename, manifest }
} }
export async function parseLogbookBackupFile(file: File): Promise<LogbookBackupFile> { function detectLegacyJsonV1(text: string): boolean {
const text = await file.text() const trimmed = text.trimStart()
let parsed: unknown if (!trimmed.startsWith('{')) return false
try { try {
parsed = JSON.parse(text) const parsed = JSON.parse(trimmed) as { format?: string; version?: number }
return parsed.format === BACKUP_FORMAT && parsed.version === 1
} catch { } catch {
throw new Error('BACKUP_INVALID_JSON') return false
}
}
export async function parseLogbookBackupFile(file: File): Promise<ParsedLogbookBackup> {
const buffer = await file.arrayBuffer()
const bytes = new Uint8Array(buffer)
if (!isZipArchive(bytes)) {
const text = new TextDecoder().decode(bytes)
if (detectLegacyJsonV1(text)) {
throw new Error('BACKUP_VERSION_UNSUPPORTED')
}
throw new Error('BACKUP_INVALID_ARCHIVE')
} }
if (!isBackupFile(parsed)) { const files = unzipArchive(bytes)
throw new Error('BACKUP_INVALID_FORMAT') const manifest = readManifestFromArchive(files)
} return { manifest, files }
return parsed
} }
export async function previewLogbookBackup( export async function previewLogbookBackup(
backup: LogbookBackupFile, backup: ParsedLogbookBackup,
passphrase: string passphrase: string
): Promise<LogbookBackupPreview> { ): Promise<LogbookBackupPreview> {
const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase) const logbookKey = await unwrapLogbookKeyFromEnc(
const parsed = JSON.parse(backup.logbook.encryptedTitle) readBinaryFile(backup.files, backup.manifest.files.key),
const title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey) passphrase
)
const logbookMeta = JSON.parse(
readTextFile(backup.files, backup.manifest.files.logbook)
) as LogbookMetaJson
const parsed = JSON.parse(logbookMeta.encryptedTitle)
let title: string
try {
title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey)
} catch {
throw new Error('BACKUP_WRONG_PASSPHRASE')
}
return { return {
title, title,
exportedAt: backup.exportedAt, exportedAt: backup.manifest.exportedAt,
sourceLogbookId: backup.logbook.id, sourceLogbookId: backup.manifest.logbookId,
counts: backup.counts counts: backup.manifest.counts,
totalUncompressedBytes: backup.manifest.totalUncompressedBytes
} }
} }
@@ -540,7 +561,7 @@ export interface RestoreLogbookOptions {
} }
export async function restoreLogbookBackup( export async function restoreLogbookBackup(
backup: LogbookBackupFile, backup: ParsedLogbookBackup,
passphrase: string, passphrase: string,
options: RestoreLogbookOptions = {} options: RestoreLogbookOptions = {}
): Promise<{ logbookId: string; title: string }> { ): Promise<{ logbookId: string; title: string }> {
@@ -548,16 +569,22 @@ export async function restoreLogbookBackup(
throw new Error('BACKUP_NOT_AUTHENTICATED') throw new Error('BACKUP_NOT_AUTHENTICATED')
} }
const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase) const logbookKey = await unwrapLogbookKeyFromEnc(
const parsedTitle = JSON.parse(backup.logbook.encryptedTitle) readBinaryFile(backup.files, backup.manifest.files.key),
const title = await decryptJson( passphrase
parsedTitle.ciphertext,
parsedTitle.iv,
parsedTitle.tag,
logbookKey
) )
const logbookMeta = JSON.parse(
readTextFile(backup.files, backup.manifest.files.logbook)
) as LogbookMetaJson
const parsedTitle = JSON.parse(logbookMeta.encryptedTitle)
let title: string
try {
title = await decryptJson(parsedTitle.ciphertext, parsedTitle.iv, parsedTitle.tag, logbookKey)
} catch {
throw new Error('BACKUP_WRONG_PASSPHRASE')
}
let targetId = backup.logbook.id let targetId = backup.manifest.logbookId
const existing = await db.logbooks.get(targetId) const existing = await db.logbooks.get(targetId)
if (existing && !options.overwrite && !options.assignNewId) { if (existing && !options.overwrite && !options.assignNewId) {
@@ -568,18 +595,29 @@ export async function restoreLogbookBackup(
await deleteLocalLogbookCache(targetId) await deleteLocalLogbookCache(targetId)
} }
let prepared = backup
if (options.assignNewId || (existing && !options.overwrite)) { if (options.assignNewId || (existing && !options.overwrite)) {
targetId = crypto.randomUUID() targetId = crypto.randomUUID()
prepared = remapParsedBackup(backup, targetId)
} }
const prepared = targetId === backup.logbook.id ? backup : remapBackup(backup, targetId) const finalMeta = JSON.parse(
readTextFile(prepared.files, prepared.manifest.files.logbook)
) as LogbookMetaJson
await writeBackupToDexie(targetId, prepared, logbookKey) await writeBackupToDexie(
targetId,
finalMeta,
logbookKey,
prepared.manifest,
prepared.files
)
await queueRestoredLogbookForSync( await queueRestoredLogbookForSync(
targetId, targetId,
prepared.logbook.encryptedTitle, finalMeta.encryptedTitle,
logbookKey, logbookKey,
prepared.payloads prepared.manifest,
prepared.files
) )
if (navigator.onLine) { if (navigator.onLine) {
@@ -599,3 +637,14 @@ export function downloadBackupBlob(blob: Blob, filename: string): void {
anchor.click() anchor.click()
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
/** Human-readable size for UI warnings. */
export function formatBackupBytes(bytes: number): string {
const fmt = (n: number) => formatAppDecimal(n, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${fmt(bytes / 1024)} KB`
return `${fmt(bytes / (1024 * 1024))} MB`
}
export const BACKUP_SIZE_WARN_BYTES = 50_000_000
export const BACKUP_SIZE_CONFIRM_BYTES = 150_000_000
@@ -0,0 +1,355 @@
import { db } from '../db.js'
import { encBytesFromDexieFields, type DexieEncFields } from './encBlob.js'
import { buildZipArchive, utf8Bytes } from './zipArchive.js'
import {
BACKUP_FORMAT,
BACKUP_VERSION,
type BackupIndexedEntryFile,
type BackupIndexedPayloadFile,
type BackupIndexedTrackFile,
type BackupManifestCounts,
type BackupManifestFiles,
type BackupManifestV2,
type LogbookMetaJson
} from './manifest.js'
export interface CollectedBackupData {
logbookMeta: LogbookMetaJson
yacht: DexieEncFields | null
deviation: DexieEncFields | null
logbookCrewSelection: DexieEncFields | null
logbookVesselSelection: DexieEncFields | null
crews: Array<DexieEncFields & { payloadId: string; updatedAt: string }>
entries: Array<DexieEncFields & { payloadId: string; updatedAt: string }>
photos: Array<DexieEncFields & { payloadId: string; entryId: string; updatedAt: string }>
voiceMemos: Array<DexieEncFields & { payloadId: string; entryId: string; updatedAt: string }>
gpsTracks: Array<DexieEncFields & { entryId: string; updatedAt: string }>
nmeaArchives: Array<DexieEncFields & { entryId: string; updatedAt: string }>
}
function pickEnc(row: {
encryptedData: string
iv: string
tag: string
}): DexieEncFields {
return {
encryptedData: row.encryptedData,
iv: row.iv,
tag: row.tag
}
}
export async function collectLogbookBackupData(
logbookId: string
): Promise<CollectedBackupData> {
const [
logbook,
yacht,
deviation,
logbookCrewSelection,
logbookVesselSelection,
crews,
entries,
photos,
voiceMemos,
gpsTracks,
nmeaArchives
] = await Promise.all([
db.logbooks.get(logbookId),
db.yachts.get(logbookId),
db.deviations.get(logbookId),
db.logbookCrewSelections.get(logbookId),
db.logbookVesselSelections.get(logbookId),
db.crews.where({ logbookId }).toArray(),
db.entries.where({ logbookId }).toArray(),
db.photos.where({ logbookId }).toArray(),
db.voiceMemos.where({ logbookId }).toArray(),
db.gpsTracks.where({ logbookId }).toArray(),
db.nmeaArchives.where({ logbookId }).toArray()
])
if (!logbook) throw new Error('BACKUP_LOGBOOK_NOT_FOUND')
return {
logbookMeta: {
id: logbook.id,
encryptedTitle: logbook.encryptedTitle,
updatedAt: logbook.updatedAt,
isDemo: logbook.isDemo === 1
},
yacht: yacht ? pickEnc(yacht) : null,
deviation: deviation ? pickEnc(deviation) : null,
logbookCrewSelection: logbookCrewSelection ? pickEnc(logbookCrewSelection) : null,
logbookVesselSelection: logbookVesselSelection ? pickEnc(logbookVesselSelection) : null,
crews: crews.map((c) => ({ ...pickEnc(c), payloadId: c.payloadId, updatedAt: c.updatedAt })),
entries: entries.map((e) => ({
...pickEnc(e),
payloadId: e.payloadId,
updatedAt: e.updatedAt
})),
photos: photos.map((p) => ({
...pickEnc(p),
payloadId: p.payloadId,
entryId: p.entryId,
updatedAt: p.updatedAt
})),
voiceMemos: voiceMemos.map((v) => ({
...pickEnc(v),
payloadId: v.payloadId,
entryId: v.entryId,
updatedAt: v.updatedAt
})),
gpsTracks: gpsTracks.map((t) => ({
...pickEnc(t),
entryId: t.entryId,
updatedAt: t.updatedAt
})),
nmeaArchives: nmeaArchives.map((n) => ({
...pickEnc(n),
entryId: n.entryId,
updatedAt: n.updatedAt
}))
}
}
export type BackupProgressPhase = 'collect' | 'pack' | 'done'
export interface BackupExportProgress {
phase: BackupProgressPhase
current: number
total: number
bytesPacked: number
}
export interface BuiltArchive {
zipBytes: Uint8Array
manifest: BackupManifestV2
counts: BackupManifestCounts
totalUncompressedBytes: number
}
function addEncFile(
zipFiles: Record<string, Uint8Array>,
path: string,
fields: DexieEncFields
): number {
const bytes = encBytesFromDexieFields(fields)
zipFiles[path] = bytes
return bytes.byteLength
}
export function buildArchiveFromCollected(
collected: CollectedBackupData,
keyEnc: Uint8Array,
options: {
exportedAt: string
appVersion?: string
onProgress?: (progress: BackupExportProgress) => void
}
): BuiltArchive {
const zipFiles: Record<string, Uint8Array> = {}
let totalUncompressedBytes = 0
const logbookPath = 'logbook.meta.json'
zipFiles[logbookPath] = utf8Bytes(JSON.stringify(collected.logbookMeta))
totalUncompressedBytes += zipFiles[logbookPath].byteLength
zipFiles['key.enc'] = keyEnc
totalUncompressedBytes += keyEnc.byteLength
const files: BackupManifestFiles = {
key: 'key.enc',
logbook: logbookPath,
yacht: null,
deviation: null,
logbookCrewSelection: null,
logbookVesselSelection: null,
crews: [],
entries: [],
photos: [],
voiceMemos: [],
gpsTracks: [],
nmeaArchives: []
}
const packSteps: Array<() => void> = []
if (collected.yacht) {
packSteps.push(() => {
const path = 'payloads/yacht.enc'
const size = addEncFile(zipFiles, path, collected.yacht!)
files.yacht = path
totalUncompressedBytes += size
})
}
if (collected.deviation) {
packSteps.push(() => {
const path = 'payloads/deviation.enc'
const size = addEncFile(zipFiles, path, collected.deviation!)
files.deviation = path
totalUncompressedBytes += size
})
}
if (collected.logbookCrewSelection) {
packSteps.push(() => {
const path = 'payloads/logbook-crew.enc'
const size = addEncFile(zipFiles, path, collected.logbookCrewSelection!)
files.logbookCrewSelection = path
totalUncompressedBytes += size
})
}
if (collected.logbookVesselSelection) {
packSteps.push(() => {
const path = 'payloads/logbook-vessel.enc'
const size = addEncFile(zipFiles, path, collected.logbookVesselSelection!)
files.logbookVesselSelection = path
totalUncompressedBytes += size
})
}
for (const c of collected.crews) {
packSteps.push(() => {
const path = `payloads/crews/${c.payloadId}.enc`
const size = addEncFile(zipFiles, path, c)
const index: BackupIndexedPayloadFile = {
path,
payloadId: c.payloadId,
updatedAt: c.updatedAt,
bytes: size
}
files.crews.push(index)
totalUncompressedBytes += size
})
}
for (const e of collected.entries) {
packSteps.push(() => {
const path = `payloads/entries/${e.payloadId}.enc`
const size = addEncFile(zipFiles, path, e)
const index: BackupIndexedPayloadFile = {
path,
payloadId: e.payloadId,
updatedAt: e.updatedAt,
bytes: size
}
files.entries.push(index)
totalUncompressedBytes += size
})
}
for (const p of collected.photos) {
packSteps.push(() => {
const path = `payloads/photos/${p.payloadId}.enc`
const size = addEncFile(zipFiles, path, p)
const index: BackupIndexedEntryFile = {
path,
payloadId: p.payloadId,
entryId: p.entryId,
updatedAt: p.updatedAt,
bytes: size
}
files.photos.push(index)
totalUncompressedBytes += size
})
}
for (const v of collected.voiceMemos) {
packSteps.push(() => {
const path = `payloads/voice-memos/${v.payloadId}.enc`
const size = addEncFile(zipFiles, path, v)
const index: BackupIndexedEntryFile = {
path,
payloadId: v.payloadId,
entryId: v.entryId,
updatedAt: v.updatedAt,
bytes: size
}
files.voiceMemos.push(index)
totalUncompressedBytes += size
})
}
for (const t of collected.gpsTracks) {
packSteps.push(() => {
const path = `payloads/gps-tracks/${t.entryId}.enc`
const size = addEncFile(zipFiles, path, t)
const index: BackupIndexedTrackFile = {
path,
entryId: t.entryId,
updatedAt: t.updatedAt,
bytes: size
}
files.gpsTracks.push(index)
totalUncompressedBytes += size
})
}
for (const n of collected.nmeaArchives) {
packSteps.push(() => {
const path = `payloads/nmea-archives/${n.entryId}.enc`
const size = addEncFile(zipFiles, path, n)
const index: BackupIndexedTrackFile = {
path,
entryId: n.entryId,
updatedAt: n.updatedAt,
bytes: size
}
files.nmeaArchives.push(index)
totalUncompressedBytes += size
})
}
const total = packSteps.length
packSteps.forEach((step, i) => {
step()
options.onProgress?.({
phase: 'pack',
current: i + 1,
total,
bytesPacked: totalUncompressedBytes
})
})
const counts: BackupManifestCounts = {
entries: collected.entries.length,
photos: collected.photos.length,
voiceMemos: collected.voiceMemos.length,
crews: collected.crews.length,
gpsTracks: collected.gpsTracks.length,
nmeaArchives: collected.nmeaArchives.length,
hasYacht: !!collected.yacht,
hasDeviation: !!collected.deviation,
hasLogbookCrewSelection: !!collected.logbookCrewSelection,
hasLogbookVesselSelection: !!collected.logbookVesselSelection
}
const manifest: BackupManifestV2 = {
format: BACKUP_FORMAT,
version: BACKUP_VERSION,
exportedAt: options.exportedAt,
appVersion: options.appVersion,
compression: 'zip-deflate-6',
logbookId: collected.logbookMeta.id,
counts,
totalUncompressedBytes,
files
}
zipFiles['manifest.json'] = utf8Bytes(JSON.stringify(manifest))
totalUncompressedBytes += zipFiles['manifest.json'].byteLength
const zipBytes = buildZipArchive(zipFiles)
manifest.totalUncompressedBytes = totalUncompressedBytes
options.onProgress?.({
phase: 'done',
current: total,
total,
bytesPacked: totalUncompressedBytes
})
return { zipBytes, manifest, counts, totalUncompressedBytes }
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import {
dexieFieldsFromEncBytes,
encBytesFromDexieFields,
ENC_HEADER_SIZE
} from './encBlob.js'
function toB64(bytes: number[]): string {
return btoa(String.fromCharCode(...bytes))
}
describe('encBlob', () => {
it('round-trips dexie AES-GCM fields', () => {
const fields = {
encryptedData: toB64([9, 8, 7]),
iv: toB64(Array.from({ length: 12 }, (_, i) => i)),
tag: toB64(Array.from({ length: 16 }, (_, i) => i + 20))
}
const enc = encBytesFromDexieFields(fields)
expect(enc.byteLength).toBe(ENC_HEADER_SIZE + 3)
expect(dexieFieldsFromEncBytes(enc)).toEqual(fields)
})
it('rejects invalid magic', () => {
expect(() => dexieFieldsFromEncBytes(new Uint8Array(40))).toThrow('BACKUP_INVALID_ENC')
})
})
@@ -0,0 +1,45 @@
import { base64ToBuffer, bufferToBase64 } from '../crypto.js'
export const ENC_MAGIC = new Uint8Array([0x4b, 0x44, 0x41, 0x42]) // KDAB
export const ENC_FORMAT_VERSION = 1
export const ENC_HEADER_SIZE = 33 // 4 + 1 + 12 + 16
export interface DexieEncFields {
encryptedData: string
iv: string
tag: string
}
export function encBytesFromDexieFields(fields: DexieEncFields): Uint8Array {
const iv = new Uint8Array(base64ToBuffer(fields.iv))
const tag = new Uint8Array(base64ToBuffer(fields.tag))
const ciphertext = new Uint8Array(base64ToBuffer(fields.encryptedData))
if (iv.length !== 12) throw new Error('BACKUP_INVALID_ENC')
if (tag.length !== 16) throw new Error('BACKUP_INVALID_ENC')
const out = new Uint8Array(ENC_HEADER_SIZE + ciphertext.length)
out.set(ENC_MAGIC, 0)
out[4] = ENC_FORMAT_VERSION
out.set(iv, 5)
out.set(tag, 17)
out.set(ciphertext, 33)
return out
}
export function dexieFieldsFromEncBytes(bytes: Uint8Array): DexieEncFields {
if (bytes.length < ENC_HEADER_SIZE) throw new Error('BACKUP_INVALID_ENC')
for (let i = 0; i < 4; i++) {
if (bytes[i] !== ENC_MAGIC[i]) throw new Error('BACKUP_INVALID_ENC')
}
if (bytes[4] !== ENC_FORMAT_VERSION) throw new Error('BACKUP_INVALID_ENC')
const iv = bufferToBase64(bytes.slice(5, 17).buffer)
const tag = bufferToBase64(bytes.slice(17, 33).buffer)
const ciphertext = bufferToBase64(bytes.slice(33).buffer)
return { encryptedData: ciphertext, iv, tag }
}
export function encByteLength(fields: DexieEncFields): number {
const ct = base64ToBuffer(fields.encryptedData).byteLength
return ENC_HEADER_SIZE + ct
}
@@ -0,0 +1,97 @@
export const BACKUP_FORMAT = 'kapteins-daagbok-backup' as const
export const BACKUP_VERSION = 2 as const
export interface BackupIndexedFile {
path: string
updatedAt: string
bytes: number
}
export interface BackupIndexedPayloadFile extends BackupIndexedFile {
payloadId: string
}
export interface BackupIndexedEntryFile extends BackupIndexedPayloadFile {
entryId: string
}
export interface BackupIndexedTrackFile extends BackupIndexedFile {
entryId: string
}
export interface BackupManifestCounts {
entries: number
photos: number
voiceMemos: number
crews: number
gpsTracks: number
nmeaArchives: number
hasYacht: boolean
hasDeviation: boolean
hasLogbookCrewSelection: boolean
hasLogbookVesselSelection: boolean
}
export interface BackupManifestFiles {
key: string
logbook: string
yacht: string | null
deviation: string | null
logbookCrewSelection: string | null
logbookVesselSelection: string | null
crews: BackupIndexedPayloadFile[]
entries: BackupIndexedPayloadFile[]
photos: BackupIndexedEntryFile[]
voiceMemos: BackupIndexedEntryFile[]
gpsTracks: BackupIndexedTrackFile[]
nmeaArchives: BackupIndexedTrackFile[]
}
export interface BackupManifestV2 {
format: typeof BACKUP_FORMAT
version: typeof BACKUP_VERSION
exportedAt: string
appVersion?: string
compression: 'zip-deflate-6'
logbookId: string
counts: BackupManifestCounts
totalUncompressedBytes: number
files: BackupManifestFiles
}
export interface LogbookMetaJson {
id: string
encryptedTitle: string
updatedAt: string
isDemo?: boolean
}
export function parseManifestJson(text: string): BackupManifestV2 {
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch {
throw new Error('BACKUP_INVALID_FORMAT')
}
if (!isBackupManifestV2(parsed)) {
throw new Error('BACKUP_INVALID_FORMAT')
}
return parsed
}
export function isBackupManifestV2(value: unknown): value is BackupManifestV2 {
if (!value || typeof value !== 'object') return false
const obj = value as Partial<BackupManifestV2>
return (
obj.format === BACKUP_FORMAT &&
obj.version === BACKUP_VERSION &&
typeof obj.exportedAt === 'string' &&
typeof obj.logbookId === 'string' &&
!!obj.counts &&
!!obj.files
)
}
export function serializeManifest(manifest: BackupManifestV2): string {
return JSON.stringify(manifest)
}
@@ -0,0 +1,45 @@
import { strToU8, unzipSync, zipSync } from 'fflate'
import { parseManifestJson, type BackupManifestV2 } from './manifest.js'
const ZIP_LEVEL = 6
export function buildZipArchive(files: Record<string, Uint8Array>): Uint8Array {
return zipSync(files, { level: ZIP_LEVEL })
}
export function unzipArchive(data: Uint8Array): Record<string, Uint8Array> {
try {
return unzipSync(data)
} catch {
throw new Error('BACKUP_INVALID_ARCHIVE')
}
}
export function readManifestFromArchive(
files: Record<string, Uint8Array>
): BackupManifestV2 {
const raw = files['manifest.json']
if (!raw) throw new Error('BACKUP_INVALID_FORMAT')
const text = new TextDecoder().decode(raw)
return parseManifestJson(text)
}
export function readTextFile(files: Record<string, Uint8Array>, path: string): string {
const raw = files[path]
if (!raw) throw new Error('BACKUP_MISSING_BLOB')
return new TextDecoder().decode(raw)
}
export function readBinaryFile(files: Record<string, Uint8Array>, path: string): Uint8Array {
const raw = files[path]
if (!raw) throw new Error('BACKUP_MISSING_BLOB')
return raw
}
export function utf8Bytes(text: string): Uint8Array {
return strToU8(text)
}
export function isZipArchive(bytes: Uint8Array): boolean {
return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b
}
+12
View File
@@ -91,6 +91,7 @@ export function clearLogbookKeysCache() {
export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer> { export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer> {
const localLb = await db.logbooks.get(logbookId) const localLb = await db.logbooks.get(logbookId)
const encryptedTitle = localLb ? localLb.encryptedTitle : '' const encryptedTitle = localLb ? localLb.encryptedTitle : ''
const isShared = localLb?.isShared === 1
const masterKey = getActiveMasterKey() const masterKey = getActiveMasterKey()
let key = await getLogbookKey(logbookId) let key = await getLogbookKey(logbookId)
@@ -103,6 +104,11 @@ export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer>
// Key works, return it // Key works, return it
return key return key
} catch (err) { } catch (err) {
if (isShared) {
throw new Error(
'Shared logbook encryption key is missing or invalid. Please go online and refresh your logbooks.'
)
}
console.warn('Stored logbook key failed to decrypt title. Testing if master key works (legacy migration)...') console.warn('Stored logbook key failed to decrypt title. Testing if master key works (legacy migration)...')
try { try {
const parsed = JSON.parse(encryptedTitle) const parsed = JSON.parse(encryptedTitle)
@@ -145,6 +151,12 @@ export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer>
// If no logbook key exists yet // If no logbook key exists yet
if (!key) { if (!key) {
if (isShared) {
throw new Error(
'Shared logbook encryption key not found. Please go online and refresh your logbooks.'
)
}
if (encryptedTitle && masterKey) { if (encryptedTitle && masterKey) {
try { try {
// Check if title is already decryptable using masterKey (meaning it is a legacy logbook) // Check if title is already decryptable using masterKey (meaning it is a legacy logbook)
+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
}
@@ -1,7 +1,12 @@
import { formatAppDecimal } from '../../utils/numberFormat.js'
import type { NmeaChangeEvent, NmeaDetectionConfig, NmeaTimePoint } from './nmeaTypes.js' import type { NmeaChangeEvent, NmeaDetectionConfig, NmeaTimePoint } from './nmeaTypes.js'
import { DEFAULT_NMEA_DETECTION_CONFIG } from './nmeaTypes.js' import { DEFAULT_NMEA_DETECTION_CONFIG } from './nmeaTypes.js'
import { angularDelta } from './nmeaTimeSeries.js' import { angularDelta } from './nmeaTimeSeries.js'
function formatNmeaDecimal(value: number): string {
return formatAppDecimal(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
}
function pushUnique(events: NmeaChangeEvent[], event: NmeaChangeEvent, minGapMs: number) { function pushUnique(events: NmeaChangeEvent[], event: NmeaChangeEvent, minGapMs: number) {
const last = events[events.length - 1] const last = events[events.length - 1]
if (last && last.type === event.type && event.timestamp - last.timestamp < minGapMs) return if (last && last.type === event.type && event.timestamp - last.timestamp < minGapMs) return
@@ -64,7 +69,7 @@ export function detectNmeaChanges(
timestamp: p.timestamp, timestamp: p.timestamp,
confidence: 'medium', confidence: 'medium',
summaryKey: 'logs.nmea_change_wind_speed', summaryKey: 'logs.nmea_change_wind_speed',
summaryParams: { from: lastWindSpeed.toFixed(1), to: p.windSpeedKnots.toFixed(1) }, summaryParams: { from: formatNmeaDecimal(lastWindSpeed), to: formatNmeaDecimal(p.windSpeedKnots) },
data: p data: p
}, config.dedupeWindowMs) }, config.dedupeWindowMs)
} }
@@ -79,7 +84,7 @@ export function detectNmeaChanges(
timestamp: p.timestamp, timestamp: p.timestamp,
confidence: 'medium', confidence: 'medium',
summaryKey: 'logs.nmea_change_pressure', summaryKey: 'logs.nmea_change_pressure',
summaryParams: { from: lastPressure.toFixed(1), to: p.pressureHpa.toFixed(1) }, summaryParams: { from: formatNmeaDecimal(lastPressure), to: formatNmeaDecimal(p.pressureHpa) },
data: p data: p
}, config.dedupeWindowMs) }, config.dedupeWindowMs)
} }
@@ -95,7 +100,7 @@ export function detectNmeaChanges(
timestamp: p.timestamp, timestamp: p.timestamp,
confidence: 'high', confidence: 'high',
summaryKey: 'logs.nmea_change_depth', summaryKey: 'logs.nmea_change_depth',
summaryParams: { from: lastDepth.toFixed(1), to: p.depthM.toFixed(1) }, summaryParams: { from: formatNmeaDecimal(lastDepth), to: formatNmeaDecimal(p.depthM) },
data: p data: p
}, config.dedupeWindowMs) }, config.dedupeWindowMs)
} }
@@ -156,7 +161,7 @@ export function detectNmeaChanges(
timestamp: p.timestamp, timestamp: p.timestamp,
confidence: 'medium', confidence: 'medium',
summaryKey: 'logs.nmea_change_water_temp', summaryKey: 'logs.nmea_change_water_temp',
summaryParams: { from: lastWaterTemp.toFixed(1), to: p.waterTempC.toFixed(1) }, summaryParams: { from: formatNmeaDecimal(lastWaterTemp), to: formatNmeaDecimal(p.waterTempC) },
data: p data: p
}, config.dedupeWindowMs) }, config.dedupeWindowMs)
} }
@@ -200,7 +205,7 @@ export function detectNmeaChanges(
timestamp: p.timestamp, timestamp: p.timestamp,
confidence: 'low', confidence: 'low',
summaryKey: 'logs.nmea_change_speed', summaryKey: 'logs.nmea_change_speed',
summaryParams: { from: lastSog.toFixed(1), to: sog.toFixed(1) }, summaryParams: { from: formatNmeaDecimal(lastSog), to: formatNmeaDecimal(sog) },
data: p data: p
}, config.dedupeWindowMs) }, config.dedupeWindowMs)
} }
@@ -2,6 +2,7 @@ import type { TFunction } from 'i18next'
import type { LogEventPayload } from '../../utils/logEntryPayload.js' import type { LogEventPayload } from '../../utils/logEntryPayload.js'
import { normalizeLogEvent } from '../../utils/logEntryPayload.js' import { normalizeLogEvent } from '../../utils/logEntryPayload.js'
import { formatCourseAngle } from '../../utils/courseAngle.js' import { formatCourseAngle } from '../../utils/courseAngle.js'
import { formatAppDecimal, formatCanonicalCoordinate } from '../../utils/numberFormat.js'
import { degreesToCardinal } from '../../utils/courseAngle.js' import { degreesToCardinal } from '../../utils/courseAngle.js'
import type { import type {
NmeaChangeEvent, NmeaChangeEvent,
@@ -33,9 +34,12 @@ function pointToLogEvent(
windDirection: windDir, windDirection: windDir,
windStrength: point.windSpeedKnots != null ? String(point.windSpeedKnots) : '', windStrength: point.windSpeedKnots != null ? String(point.windSpeedKnots) : '',
windPressure: point.pressureHpa != null ? String(Math.round(point.pressureHpa)) : '', windPressure: point.pressureHpa != null ? String(Math.round(point.pressureHpa)) : '',
gpsLat: point.lat != null ? point.lat.toFixed(6) : '', gpsLat: point.lat != null ? formatCanonicalCoordinate(point.lat) : '',
gpsLng: point.lng != null ? point.lng.toFixed(6) : '', gpsLng: point.lng != null ? formatCanonicalCoordinate(point.lng) : '',
logReading: point.logDistanceNm != null ? point.logDistanceNm.toFixed(2) : '', logReading:
point.logDistanceNm != null
? formatAppDecimal(point.logDistanceNm, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: '',
sailsOrMotor, sailsOrMotor,
remarks remarks
}) })
@@ -51,7 +55,11 @@ function buildRemarks(change: NmeaChangeEvent, t: TFunction): string {
const parts: string[] = [] const parts: string[] = []
parts.push(t(change.summaryKey, change.summaryParams ?? {})) parts.push(t(change.summaryKey, change.summaryParams ?? {}))
if (change.data?.depthM != null) { if (change.data?.depthM != null) {
parts.push(t('logs.nmea_remark_depth', { depth: change.data.depthM.toFixed(1) })) parts.push(
t('logs.nmea_remark_depth', {
depth: formatAppDecimal(change.data.depthM, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
})
)
} }
if (change.confidence === 'low') { if (change.confidence === 'low') {
parts.push(t('logs.nmea_remark_uncertain')) parts.push(t('logs.nmea_remark_uncertain'))
+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.') throw new Error('Encryption key not found. Please log in.')
} }
// 1. Fetch Yacht details const { resolveVesselForLogbook } = await import('./resolveVessel.js')
const yachtRecord = await db.yachts.get(logbookId); const yacht = await resolveVesselForLogbook(logbookId)
if (yachtRecord) { if (yacht) {
try { yachtName = yacht.name || ''
const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey); homePort = yacht.homePort || ''
yachtName = yacht.name || ''; registration = yacht.registrationNumber || ''
homePort = yacht.port || ''; callsign = yacht.callSign || ''
registration = yacht.registrationNumber || yacht.registration || ''; atis = yacht.atis || ''
callsign = yacht.callSign || ''; mmsi = yacht.mmsi || ''
atis = yacht.atis || '';
mmsi = yacht.mmsi || '';
} catch (e) {
console.error('Failed to decrypt yacht details for PDF:', e);
}
} }
// 2. Fetch active Entry // 2. Fetch active Entry
+1 -1
View File
@@ -16,7 +16,7 @@ export async function syncPersonPool(): Promise<void> {
} }
async function pushPersonPool(): 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 if (pending.length === 0) return
try { try {
-3
View File
@@ -55,9 +55,6 @@ export async function saveEntryPhoto(options: {
}) })
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: analyticsContext }) trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: analyticsContext })
if (analyticsContext === 'live_log') {
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_PHOTO_UPLOADED)
}
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err)) syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
return photoId return photoId
} }
+132 -20
View File
@@ -27,17 +27,62 @@ export function getNotificationPermission(): NotificationPermission | 'unsupport
return Notification.permission 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> { async function fetchVapidPublicKey(): Promise<string | null> {
if (cachedVapidKey) return cachedVapidKey
const envKey = import.meta.env.VITE_VAPID_PUBLIC_KEY const envKey = import.meta.env.VITE_VAPID_PUBLIC_KEY
if (typeof envKey === 'string' && envKey.trim()) { if (typeof envKey === 'string' && envKey.trim()) {
return envKey.trim() cachedVapidKey = envKey.trim()
return cachedVapidKey
} }
try { try {
const res = await fetch(`${API_BASE}/vapid-public-key`) const res = await fetch(`${API_BASE}/vapid-public-key`)
if (!res.ok) return null if (!res.ok) return null
const data = await res.json() 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 { } catch {
return null 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> { async function saveSubscriptionToServer(subscription: PushSubscription): Promise<void> {
if (!localStorage.getItem('active_userid')) throw new Error('Not authenticated') if (!localStorage.getItem('active_userid')) throw new Error('Not authenticated')
const endpoint = subscription.endpoint
const json = subscription.toJSON() 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') throw new Error('Invalid push subscription')
} }
@@ -85,8 +180,8 @@ async function saveSubscriptionToServer(subscription: PushSubscription): Promise
await apiJson(`${API_BASE}/subscription`, { await apiJson(`${API_BASE}/subscription`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
endpoint: json.endpoint, endpoint,
keys: json.keys, keys: { p256dh, auth },
locale, locale,
userAgent: navigator.userAgent userAgent: navigator.userAgent
}) })
@@ -98,35 +193,48 @@ export async function subscribeToPush(): Promise<void> {
throw new Error('Push notifications are not supported on this device') throw new Error('Push notifications are not supported on this device')
} }
const permission = await Notification.requestPermission() // Pre-resolve registration using getRegistrationCompat to prevent ready state hangs
if (permission !== 'granted') { let registration = cachedRegistration
throw new Error('Notification permission denied') if (!registration) {
registration = await getRegistrationCompat()
cachedRegistration = registration
} }
const publicKey = await fetchVapidPublicKey() const publicKey = cachedVapidKey || await fetchVapidPublicKey()
if (!publicKey) { if (!publicKey) {
throw new Error('Push notifications are not configured on this server') throw new Error('Push notifications are not configured on this server')
} }
const registration = await navigator.serviceWorker.ready const permission = await requestNotificationPermission()
let subscription = await registration.pushManager.getSubscription() if (permission !== 'granted') {
throw new Error('Notification permission denied')
if (!subscription) {
const keyBytes = urlBase64ToUint8Array(publicKey)
const applicationServerKey = new Uint8Array(keyBytes)
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey
})
} }
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) await saveSubscriptionToServer(subscription)
} }
export async function unsubscribeFromPush(): Promise<void> { export async function unsubscribeFromPush(): Promise<void> {
if (!isPushSupported()) return 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() const subscription = await registration.pushManager.getSubscription()
if (!subscription) return if (!subscription) return
@@ -164,3 +272,7 @@ export async function disableCollaboratorChangePush(): Promise<void> {
await savePushPrefs(false) await savePushPrefs(false)
await unsubscribeFromPush() await unsubscribeFromPush()
} }
if (isPushSupported()) {
void preloadPushService()
}
+134 -31
View File
@@ -3,11 +3,13 @@ import { getActiveMasterKey } from './auth.js'
import { ensureLogbookKey, getLogbookKey } from './logbookKeys.js' import { ensureLogbookKey, getLogbookKey } from './logbookKeys.js'
import { decryptJson, encryptJson } from './crypto.js' import { decryptJson, encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js' import { syncLogbook } from './sync.js'
import { putEntryRecord } from '../utils/entryListCache.js'
import { import {
buildLogEntryPayload, buildLogEntryPayload,
normalizeLogEvent, normalizeLogEvent,
sortLogEventsByTime, sortLogEventsByTime,
currentLocalTimeHHMM, currentLocalTimeHHMM,
localDateString,
type LogEventPayload type LogEventPayload
} from '../utils/logEntryPayload.js' } from '../utils/logEntryPayload.js'
import { import {
@@ -123,11 +125,22 @@ function buildEncryptedPayload(
}) })
const clear = options.clearSignatures const clear = options.clearSignatures
return { const entryData: Record<string, unknown> = {
...payload, ...payload,
signSkipper: clear ? '' : (data.signSkipper ?? ''), signSkipper: clear ? '' : (data.signSkipper ?? ''),
signCrew: clear ? '' : (data.signCrew ?? '') signCrew: clear ? '' : (data.signCrew ?? '')
} }
const summary = typeof data.aiSummary === 'string' ? data.aiSummary.trim() : ''
if (summary) {
entryData.aiSummary = summary
entryData.aiSummaryGeneratedAt =
typeof data.aiSummaryGeneratedAt === 'string' && data.aiSummaryGeneratedAt
? data.aiSummaryGeneratedAt
: new Date().toISOString()
}
return entryData
} }
export async function loadEntry(logbookId: string, entryId: string): Promise<LoadedEntry | null> { export async function loadEntry(logbookId: string, entryId: string): Promise<LoadedEntry | null> {
@@ -139,18 +152,86 @@ export async function loadEntry(logbookId: string, entryId: string): Promise<Loa
return { payloadId: record.payloadId, updatedAt: record.updatedAt, data } return { payloadId: record.payloadId, updatedAt: record.updatedAt, data }
} }
function scoreTodayEntry(data: Record<string, unknown>): number {
const events = (data.events as unknown[] | undefined)?.length ?? 0
const signed = (data.signSkipper || data.signCrew) ? 1 : 0
const destination = String(data.destination || '').trim() ? 1 : 0
return events * 10 + signed + destination
}
export async function findTodayEntryId(logbookId: string): Promise<string | null> { export async function findTodayEntryId(logbookId: string): Promise<string | null> {
const todayStr = new Date().toISOString().substring(0, 10) const todayStr = localDateString()
const masterKey = await getMasterKey(logbookId) const masterKey = await getMasterKey(logbookId)
const local = sortEntriesNewestFirst(await db.entries.where({ logbookId }).toArray()) const local = sortEntriesNewestFirst(await db.entries.where({ logbookId }).toArray())
let bestId: string | null = null
let bestScore = -1
let bestUpdatedAt = ''
for (const entry of local) { for (const entry of local) {
const decrypted = await tryDecryptEntryPayload(entry, masterKey) const decrypted = await tryDecryptEntryPayload(entry, masterKey)
if (decrypted && String(decrypted.date) === todayStr) { if (!decrypted || String(decrypted.date) !== todayStr) continue
return entry.payloadId
const score = scoreTodayEntry(decrypted)
if (
score > bestScore
|| (score === bestScore && entry.updatedAt > bestUpdatedAt)
) {
bestId = entry.payloadId
bestScore = score
bestUpdatedAt = entry.updatedAt
} }
} }
return null
return bestId
}
async function entryHasAttachments(logbookId: string, entryId: string): Promise<boolean> {
const [photos, voices, track] = await Promise.all([
db.photos.where({ logbookId, entryId }).count(),
db.voiceMemos.where({ logbookId, entryId }).count(),
db.gpsTracks.get(entryId)
])
return photos > 0 || voices > 0 || track != null
}
async function isEmptyTodayEntry(
logbookId: string,
entryId: string,
data: Record<string, unknown>
): Promise<boolean> {
if (((data.events as unknown[] | undefined)?.length ?? 0) > 0) return false
if (data.signSkipper || data.signCrew) return false
if (String(data.destination || '').trim()) return false
return !(await entryHasAttachments(logbookId, entryId))
}
/** Remove duplicate empty travel days for today (e.g. after parallel Live-log init). */
export async function pruneEmptyTodayDuplicates(
logbookId: string,
keepEntryId: string
): Promise<void> {
const todayStr = localDateString()
const masterKey = await getMasterKey(logbookId)
const local = await db.entries.where({ logbookId }).toArray()
const now = new Date().toISOString()
for (const entry of local) {
if (entry.payloadId === keepEntryId) continue
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
if (!decrypted || String(decrypted.date) !== todayStr) continue
if (!(await isEmptyTodayEntry(logbookId, entry.payloadId, decrypted))) continue
await db.entries.delete(entry.payloadId)
await db.syncQueue.put({
action: 'delete',
type: 'entry',
payloadId: entry.payloadId,
logbookId,
data: '',
updatedAt: now
})
}
} }
export async function createTodayEntry(logbookId: string): Promise<string> { export async function createTodayEntry(logbookId: string): Promise<string> {
@@ -173,7 +254,7 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
const localId = window.crypto.randomUUID() const localId = window.crypto.randomUUID()
const nowStr = new Date().toISOString() const nowStr = new Date().toISOString()
const todayStr = nowStr.substring(0, 10) const todayStr = localDateString()
const initialPayload = { const initialPayload = {
date: todayStr, date: todayStr,
@@ -190,14 +271,17 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
const encrypted = await encryptJson(initialPayload, masterKey) const encrypted = await encryptJson(initialPayload, masterKey)
await db.entries.put({ await putEntryRecord(
payloadId: localId, {
logbookId, payloadId: localId,
encryptedData: encrypted.ciphertext, logbookId,
iv: encrypted.iv, encryptedData: encrypted.ciphertext,
tag: encrypted.tag, iv: encrypted.iv,
updatedAt: nowStr tag: encrypted.tag,
}) updatedAt: nowStr
},
initialPayload
)
await db.syncQueue.put({ await db.syncQueue.put({
action: 'create', action: 'create',
@@ -212,20 +296,36 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
return localId return localId
} }
const findOrCreateTodayEntryInflight = new Map<string, Promise<string>>()
async function findOrCreateTodayEntryOnce(logbookId: string): Promise<string> {
await ensureLogbookKey(logbookId)
let entryId = await findTodayEntryId(logbookId)
if (!entryId) {
entryId = await createTodayEntry(logbookId)
}
await pruneEmptyTodayDuplicates(logbookId, entryId)
return entryId
}
/** One travel day per local calendar date; concurrent callers share one in-flight create. */
export async function findOrCreateTodayEntry(logbookId: string): Promise<string> { export async function findOrCreateTodayEntry(logbookId: string): Promise<string> {
const id = logbookId.trim() const id = logbookId.trim()
if (!id) throw new Error('Logbook id required') if (!id) throw new Error('Logbook id required')
await ensureLogbookKey(id) let inflight = findOrCreateTodayEntryInflight.get(id)
if (!inflight) {
const entryCount = await db.entries.where({ logbookId: id }).count() inflight = findOrCreateTodayEntryOnce(id)
if (entryCount === 0) { findOrCreateTodayEntryInflight.set(id, inflight)
return createTodayEntry(id) void inflight.finally(() => {
if (findOrCreateTodayEntryInflight.get(id) === inflight) {
findOrCreateTodayEntryInflight.delete(id)
}
})
} }
return inflight
const existing = await findTodayEntryId(id)
if (existing) return existing
return createTodayEntry(id)
} }
export interface AppendQuickEventResult { export interface AppendQuickEventResult {
@@ -305,14 +405,17 @@ async function persistEntry(
const encrypted = await encryptJson(entryData, masterKey) const encrypted = await encryptJson(entryData, masterKey)
const now = new Date().toISOString() const now = new Date().toISOString()
await db.entries.put({ await putEntryRecord(
payloadId: entryId, {
logbookId, payloadId: entryId,
encryptedData: encrypted.ciphertext, logbookId,
iv: encrypted.iv, encryptedData: encrypted.ciphertext,
tag: encrypted.tag, iv: encrypted.iv,
updatedAt: now tag: encrypted.tag,
}) updatedAt: now
},
entryData
)
await db.syncQueue.put({ await db.syncQueue.put({
action: 'update', 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
}
+1 -11
View File
@@ -258,14 +258,4 @@ export function getTrackColor(index: number): string {
return TRACK_COLORS[index % TRACK_COLORS.length] return TRACK_COLORS[index % TRACK_COLORS.length]
} }
export function formatNm(value: number): string { export { formatHours, formatLiters, formatNm } from '../utils/numberFormat.js'
return value.toFixed(2)
}
export function formatLiters(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1)
}
export function formatHours(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1)
}
+81 -15
View File
@@ -8,6 +8,7 @@ import {
type SyncConflict type SyncConflict
} from './syncConflicts.js' } from './syncConflicts.js'
import { syncPersonPool } from './personPoolSync.js' import { syncPersonPool } from './personPoolSync.js'
import { forEachInBatches, yieldToMain } from '../utils/yieldToMain.js'
const API_BASE = '/api/sync' const API_BASE = '/api/sync'
const syncingLogbooks = new Set<string>() const syncingLogbooks = new Set<string>()
@@ -60,10 +61,14 @@ async function entityExistsLocally(item: SyncQueueItem): Promise<boolean> {
return !!(await db.entries.get(item.payloadId)) return !!(await db.entries.get(item.payloadId))
case 'photo': case 'photo':
return !!(await db.photos.get(item.payloadId)) return !!(await db.photos.get(item.payloadId))
case 'voiceMemo':
return !!(await db.voiceMemos.get(item.payloadId))
case 'gpsTrack': case 'gpsTrack':
return !!(await db.gpsTracks.get(item.payloadId)) return !!(await db.gpsTracks.get(item.payloadId))
case 'logbookCrew': case 'logbookCrew':
return !!(await db.logbookCrewSelections.get(item.logbookId)) return !!(await db.logbookCrewSelections.get(item.logbookId))
case 'logbookVessel':
return !!(await db.logbookVesselSelections.get(item.logbookId))
default: default:
return false return false
} }
@@ -128,12 +133,7 @@ async function coalesceSyncQueue(logbookId: string): Promise<SyncQueueItem[]> {
} }
function scheduleResync(logbookId: string) { function scheduleResync(logbookId: string) {
if (pendingResync.has(logbookId)) return
pendingResync.add(logbookId) pendingResync.add(logbookId)
queueMicrotask(() => {
pendingResync.delete(logbookId)
syncLogbook(logbookId).catch((err) => console.warn('Deferred sync failed:', err))
})
} }
type LogbookPushAccess = 'OWNER' | 'WRITE' | 'READ' | 'UNKNOWN' type LogbookPushAccess = 'OWNER' | 'WRITE' | 'READ' | 'UNKNOWN'
@@ -228,9 +228,11 @@ type PulledServerPayload = {
yacht?: { updatedAt: string } | null yacht?: { updatedAt: string } | null
deviation?: { updatedAt: string } | null deviation?: { updatedAt: string } | null
logbookCrewSelection?: { updatedAt: string } | null logbookCrewSelection?: { updatedAt: string } | null
logbookVesselSelection?: { updatedAt: string } | null
crews?: Array<{ payloadId: string; updatedAt: string }> crews?: Array<{ payloadId: string; updatedAt: string }>
entries?: Array<{ payloadId: string; updatedAt: string }> entries?: Array<{ payloadId: string; updatedAt: string }>
photos?: Array<{ payloadId: string; updatedAt: string }> photos?: Array<{ payloadId: string; updatedAt: string }>
voiceMemos?: Array<{ payloadId: string; updatedAt: string }>
gpsTracks?: Array<{ entryId: string; updatedAt: string }> gpsTracks?: Array<{ entryId: string; updatedAt: string }>
} }
@@ -248,9 +250,13 @@ async function pruneAcknowledgedQueueItems(
if (server.logbookCrewSelection) { if (server.logbookCrewSelection) {
serverTimes.set('logbookCrew:' + logbookId, server.logbookCrewSelection.updatedAt) 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 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 e of server.entries ?? []) serverTimes.set('entry:' + e.payloadId, e.updatedAt)
for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt) for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt)
for (const v of server.voiceMemos ?? []) serverTimes.set('voiceMemo:' + v.payloadId, v.updatedAt)
for (const gt of server.gpsTracks ?? []) serverTimes.set('gpsTrack:' + gt.entryId, gt.updatedAt) for (const gt of server.gpsTracks ?? []) serverTimes.set('gpsTrack:' + gt.entryId, gt.updatedAt)
const localLogbook = await db.logbooks.get(logbookId) const localLogbook = await db.logbooks.get(logbookId)
@@ -269,7 +275,9 @@ async function pruneAcknowledgedQueueItems(
? 'yacht:' + logbookId ? 'yacht:' + logbookId
: item.type === 'logbookCrew' : item.type === 'logbookCrew'
? 'logbookCrew:' + logbookId ? 'logbookCrew:' + logbookId
: `${item.type}:${item.payloadId}` : item.type === 'logbookVessel'
? 'logbookVessel:' + logbookId
: `${item.type}:${item.payloadId}`
const serverUpdatedAt = serverTimes.get(key) const serverUpdatedAt = serverTimes.get(key)
if (serverUpdatedAt && !isNewer(item.updatedAt, serverUpdatedAt)) { if (serverUpdatedAt && !isNewer(item.updatedAt, serverUpdatedAt)) {
if (item.id !== undefined) staleIds.push(item.id) if (item.id !== undefined) staleIds.push(item.id)
@@ -295,15 +303,21 @@ async function pullChanges(logbookId: string): Promise<boolean> {
return false return false
} }
const { yacht, deviation, crews, logbookCrewSelection, entries, photos, gpsTracks } = const { yacht, deviation, crews, logbookCrewSelection, logbookVesselSelection, entries, photos, voiceMemos, gpsTracks } =
await response.json() await response.json()
// Large pull payloads block on JSON.parse — yield before applying to IndexedDB.
await yieldToMain()
const serverSnapshot: PulledServerPayload = { const serverSnapshot: PulledServerPayload = {
yacht, yacht,
deviation, deviation,
logbookCrewSelection, logbookCrewSelection,
logbookVesselSelection,
crews, crews,
entries, entries,
photos, photos,
voiceMemos,
gpsTracks gpsTracks
} }
@@ -349,10 +363,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) // 3. Sync Crew List Payloads (legacy)
const serverCrewMap = new Map<string, any>() const serverCrewMap = new Map<string, any>()
if (crews && Array.isArray(crews)) { if (crews && Array.isArray(crews)) {
for (const c of crews) { await forEachInBatches(crews, 20, async (c) => {
serverCrewMap.set(c.payloadId, c) serverCrewMap.set(c.payloadId, c)
const local = await db.crews.get(c.payloadId) const local = await db.crews.get(c.payloadId)
if (!local || isNewer(c.updatedAt, local.updatedAt)) { if (!local || isNewer(c.updatedAt, local.updatedAt)) {
@@ -365,7 +393,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
updatedAt: c.updatedAt updatedAt: c.updatedAt
}) })
} }
} })
} }
// Deletions for Crew: If present locally but not on server, and not pending creation locally // Deletions for Crew: If present locally but not on server, and not pending creation locally
@@ -385,7 +413,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
// 4. Sync Journal Entry Payloads // 4. Sync Journal Entry Payloads
const serverEntryMap = new Map<string, any>() const serverEntryMap = new Map<string, any>()
if (entries && Array.isArray(entries)) { if (entries && Array.isArray(entries)) {
for (const e of entries) { await forEachInBatches(entries, 15, async (e) => {
serverEntryMap.set(e.payloadId, e) serverEntryMap.set(e.payloadId, e)
const local = await db.entries.get(e.payloadId) const local = await db.entries.get(e.payloadId)
if (!local || isNewer(e.updatedAt, local.updatedAt)) { if (!local || isNewer(e.updatedAt, local.updatedAt)) {
@@ -398,7 +426,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
updatedAt: e.updatedAt updatedAt: e.updatedAt
}) })
} }
} })
} }
// Deletions for Entries // Deletions for Entries
@@ -417,7 +445,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
// 5. Sync Photos // 5. Sync Photos
const serverPhotoMap = new Map<string, any>() const serverPhotoMap = new Map<string, any>()
if (photos && Array.isArray(photos)) { if (photos && Array.isArray(photos)) {
for (const p of photos) { await forEachInBatches(photos, 20, async (p) => {
serverPhotoMap.set(p.payloadId, p) serverPhotoMap.set(p.payloadId, p)
const local = await db.photos.get(p.payloadId) const local = await db.photos.get(p.payloadId)
if (!local || isNewer(p.updatedAt, local.updatedAt)) { if (!local || isNewer(p.updatedAt, local.updatedAt)) {
@@ -432,7 +460,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
updatedAt: p.updatedAt updatedAt: p.updatedAt
}) })
} }
} })
} }
// Deletions for Photos // Deletions for Photos
@@ -448,10 +476,42 @@ async function pullChanges(logbookId: string): Promise<boolean> {
} }
} }
// 5b. Sync Voice Memos
const serverVoiceMap = new Map<string, any>()
if (voiceMemos && Array.isArray(voiceMemos)) {
await forEachInBatches(voiceMemos, 20, async (v) => {
serverVoiceMap.set(v.payloadId, v)
const local = await db.voiceMemos.get(v.payloadId)
if (!local || isNewer(v.updatedAt, local.updatedAt)) {
await db.voiceMemos.put({
payloadId: v.payloadId,
entryId: v.entryId,
logbookId,
encryptedData: v.encryptedData,
iv: v.iv,
tag: v.tag,
updatedAt: v.updatedAt
})
}
})
}
const localVoiceMemos = await db.voiceMemos.where({ logbookId }).toArray()
for (const lv of localVoiceMemos) {
if (!serverVoiceMap.has(lv.payloadId)) {
const pendingCreate = await db.syncQueue
.where({ payloadId: lv.payloadId, action: 'create' })
.first()
if (!pendingCreate) {
await db.voiceMemos.delete(lv.payloadId)
}
}
}
// 6. Sync GPS Tracks // 6. Sync GPS Tracks
const serverGpsTrackMap = new Map<string, any>() const serverGpsTrackMap = new Map<string, any>()
if (gpsTracks && Array.isArray(gpsTracks)) { if (gpsTracks && Array.isArray(gpsTracks)) {
for (const gt of gpsTracks) { await forEachInBatches(gpsTracks, 10, async (gt) => {
serverGpsTrackMap.set(gt.entryId, gt) serverGpsTrackMap.set(gt.entryId, gt)
const local = await db.gpsTracks.get(gt.entryId) const local = await db.gpsTracks.get(gt.entryId)
if (!local || isNewer(gt.updatedAt, local.updatedAt)) { if (!local || isNewer(gt.updatedAt, local.updatedAt)) {
@@ -464,7 +524,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
updatedAt: gt.updatedAt updatedAt: gt.updatedAt
}) })
} }
} })
} }
// Deletions for GPS Tracks // Deletions for GPS Tracks
@@ -512,6 +572,12 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
} finally { } finally {
syncingLogbooks.delete(logbookId) syncingLogbooks.delete(logbookId)
recomputeSyncingState() 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)
}
}
+100
View File
@@ -0,0 +1,100 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
async function getEncryptionKey(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 saveEntryVoiceMemo(options: {
logbookId: string
entryId: string
audioDataUrl: string
mimeType: string
durationSec: number
caption?: string
analyticsContext?: string
}): Promise<string> {
const {
logbookId,
entryId,
audioDataUrl,
mimeType,
durationSec,
caption = '',
analyticsContext = 'logbook'
} = options
const masterKey = await getEncryptionKey(logbookId)
const voiceId = window.crypto.randomUUID()
const voicePayload = {
audio: audioDataUrl,
mimeType,
durationSec,
caption: caption.trim()
}
const encrypted = await encryptJson(voicePayload, masterKey)
const now = new Date().toISOString()
await db.voiceMemos.put({
payloadId: voiceId,
entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'create',
type: 'voiceMemo',
payloadId: voiceId,
logbookId,
data: JSON.stringify({
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
entryId
}),
updatedAt: now
})
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_UPLOADED, { context: analyticsContext })
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
return voiceId
}
export async function deleteEntryVoiceMemo(logbookId: string, voiceId: string): Promise<void> {
const now = new Date().toISOString()
await db.voiceMemos.delete(voiceId)
await db.syncQueue.put({
action: 'delete',
type: 'voiceMemo',
payloadId: voiceId,
logbookId,
data: '',
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
/** Deletes the newest voice memo for an entry; returns its id or null. */
export async function removeLastVoiceMemoForEntry(
logbookId: string,
entryId: string
): Promise<string | null> {
const memos = await db.voiceMemos.where({ entryId }).toArray()
if (memos.length === 0) return null
memos.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
)
const lastId = memos[0].payloadId
await deleteEntryVoiceMemo(logbookId, lastId)
return lastId
}
+11
View File
@@ -44,6 +44,17 @@ describe('fetchOpenWeatherCurrent', () => {
}) })
}) })
it('throws OFFLINE when navigator.onLine is false', async () => {
vi.stubGlobal('navigator', { ...navigator, onLine: false })
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
expect(err).toBeInstanceOf(WeatherApiError)
expect((err as InstanceType<typeof WeatherApiError>).code).toBe('OFFLINE')
expect(apiFetch).not.toHaveBeenCalled()
})
it('does not track when the API request fails', async () => { it('does not track when the API request fails', async () => {
apiFetch.mockResolvedValue({ apiFetch.mockResolvedValue({
ok: false, ok: false,
+6 -2
View File
@@ -7,9 +7,9 @@ import {
} from './analytics.js' } from './analytics.js'
export class WeatherApiError extends Error { export class WeatherApiError extends Error {
code: 'NO_KEY' | 'REQUEST_FAILED' code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED'
constructor(message: string, code: 'NO_KEY' | 'REQUEST_FAILED' = 'REQUEST_FAILED') { constructor(message: string, code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' = 'REQUEST_FAILED') {
super(message) super(message)
this.name = 'WeatherApiError' this.name = 'WeatherApiError'
this.code = code this.code = code
@@ -26,6 +26,10 @@ export async function fetchOpenWeatherCurrent(
}, },
options?: { analyticsSource: OwmAnalyticsSource } options?: { analyticsSource: OwmAnalyticsSource }
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
if (!navigator.onLine) {
throw new WeatherApiError('Offline', 'OFFLINE')
}
const searchParams = new URLSearchParams() const searchParams = new URLSearchParams()
if (params.lat && params.lon) { if (params.lat && params.lon) {
+4 -4
View File
@@ -6,6 +6,10 @@ import { NetworkFirst, NetworkOnly } from 'workbox-strategies'
declare let self: ServiceWorkerGlobalScope declare let self: ServiceWorkerGlobalScope
precacheAndRoute(self.__WB_MANIFEST)
cleanupOutdatedCaches()
clientsClaim()
const appShellFallback = createHandlerBoundToURL('/index.html') const appShellFallback = createHandlerBoundToURL('/index.html')
const navigationStrategy = new NetworkFirst({ const navigationStrategy = new NetworkFirst({
cacheName: 'app-shell', cacheName: 'app-shell',
@@ -20,10 +24,6 @@ registerRoute(({ request }) => request.mode === 'navigate', async (context) => {
} }
}) })
precacheAndRoute(self.__WB_MANIFEST)
cleanupOutdatedCaches()
clientsClaim()
// Always fetch the live deploy version, even under an older precache. // Always fetch the live deploy version, even under an older precache.
registerRoute(({ url }) => url.pathname === '/version.json', new NetworkOnly()) registerRoute(({ url }) => url.pathname === '/version.json', new NetworkOnly())
+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
}
}
+39
View File
@@ -0,0 +1,39 @@
export const VOICE_MEMO_MAX_DURATION_SEC = 60
export const VOICE_MEMO_MAX_BLOB_BYTES = 800_000
const MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus'
]
export function pickMediaRecorderMimeType(): string | undefined {
if (typeof MediaRecorder === 'undefined') return undefined
for (const mime of MIME_CANDIDATES) {
if (MediaRecorder.isTypeSupported(mime)) return mime
}
return undefined
}
export function blobToAudioDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => reject(new Error('audio_read_failed'))
reader.readAsDataURL(blob)
})
}
export function formatVoiceDuration(seconds: number): string {
const s = Math.max(0, Math.floor(seconds))
const m = Math.floor(s / 60)
const r = s % 60
return `${m}:${String(r).padStart(2, '0')}`
}
export function assertVoiceMemoBlobSize(blob: Blob): void {
if (blob.size > VOICE_MEMO_MAX_BLOB_BYTES) {
throw new Error('VOICE_MEMO_TOO_LARGE')
}
}
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from 'vitest'
import {
cameraErrorKeyFromDomException,
isCameraApiSupported,
probeCameraAvailability
} from './cameraAvailability.js'
describe('cameraAvailability', () => {
it('detects missing camera API', () => {
const nav = { mediaDevices: undefined }
vi.stubGlobal('navigator', nav)
expect(isCameraApiSupported()).toBe(false)
vi.unstubAllGlobals()
})
it('returns none when no videoinput devices', async () => {
vi.stubGlobal('navigator', {
mediaDevices: {
getUserMedia: vi.fn(),
enumerateDevices: vi.fn().mockResolvedValue([
{ kind: 'audioinput', deviceId: 'a1', label: '', groupId: '' }
])
}
})
await expect(probeCameraAvailability()).resolves.toBe('none')
vi.unstubAllGlobals()
})
it('returns available when a videoinput exists', async () => {
vi.stubGlobal('navigator', {
mediaDevices: {
getUserMedia: vi.fn(),
enumerateDevices: vi.fn().mockResolvedValue([
{ kind: 'videoinput', deviceId: 'v1', label: '', groupId: '' }
])
}
})
await expect(probeCameraAvailability()).resolves.toBe('available')
vi.unstubAllGlobals()
})
it('maps NotFoundError to no-camera i18n key', () => {
expect(cameraErrorKeyFromDomException(new DOMException('', 'NotFoundError'))).toBe(
'logs.live_photo_no_camera'
)
})
})
+33
View File
@@ -0,0 +1,33 @@
export type CameraAvailability = 'available' | 'none' | 'unsupported'
/** Whether the browser exposes camera APIs at all. */
export function isCameraApiSupported(): boolean {
return typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia
}
/** Best-effort probe for at least one video input device (no permission prompt). */
export async function probeCameraAvailability(): Promise<CameraAvailability> {
if (!isCameraApiSupported()) return 'unsupported'
if (!navigator.mediaDevices?.enumerateDevices) {
// Cannot list devices; defer to getUserMedia attempt in the capture UI.
return 'available'
}
try {
const devices = await navigator.mediaDevices.enumerateDevices()
if (devices.some((d) => d.kind === 'videoinput')) return 'available'
return 'none'
} catch {
return 'none'
}
}
export function cameraErrorKeyFromDomException(err: unknown): string {
const name = err instanceof DOMException ? err.name : ''
if (name === 'NotFoundError' || name === 'OverconstrainedError') {
return 'logs.live_photo_no_camera'
}
if (name === 'NotAllowedError' || name === 'NotReadableError' || name === 'SecurityError') {
return 'logs.live_photo_camera_denied'
}
return 'logs.live_photo_camera_unavailable'
}
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest'
import { hashEntryForSigning } from './entryCanonicalHash.js'
describe('hashEntryForSigning', () => {
it('excludes aiSummary fields from the signing hash', async () => {
const base = {
date: '2026-06-03',
dayOfTravel: '1',
departure: 'A',
destination: 'B',
freshwater: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
fuel: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
events: []
}
const withoutSummary = await hashEntryForSigning(base)
const withSummary = await hashEntryForSigning({
...base,
aiSummary: 'A calm day at sea.',
aiSummaryGeneratedAt: '2026-06-03T12:00:00.000Z'
})
expect(withSummary).toBe(withoutSummary)
})
})
+2 -1
View File
@@ -1,4 +1,5 @@
const SIGNATURE_KEYS = new Set(['signSkipper', 'signCrew']) const SIGNATURE_KEYS = new Set(['signSkipper', 'signCrew'])
const AI_SUMMARY_KEYS = new Set(['aiSummary', 'aiSummaryGeneratedAt'])
function sortEventsByTime(items: unknown[]): unknown[] { function sortEventsByTime(items: unknown[]): unknown[] {
return [...items] return [...items]
@@ -25,7 +26,7 @@ function sortValue(value: unknown, parentKey?: string): unknown {
const obj = value as Record<string, unknown> const obj = value as Record<string, unknown>
const sorted: Record<string, unknown> = {} const sorted: Record<string, unknown> = {}
for (const key of Object.keys(obj).sort()) { for (const key of Object.keys(obj).sort()) {
if (SIGNATURE_KEYS.has(key)) continue if (SIGNATURE_KEYS.has(key) || AI_SUMMARY_KEYS.has(key)) continue
sorted[key] = sortValue(obj[key], key) sorted[key] = sortValue(obj[key], key)
} }
return sorted return sorted
+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))
}
+30 -5
View File
@@ -7,6 +7,8 @@ import {
liveSogRemark, liveSogRemark,
parseLiveCommentRemark, parseLiveCommentRemark,
livePhotoRemark, livePhotoRemark,
liveVoiceRemark,
parseLiveVoiceRemark,
parseLiveSailsRemark parseLiveSailsRemark
} from './liveEventCodes.js' } from './liveEventCodes.js'
import { formatEventSummary } from './formatEventSummary.js' import { formatEventSummary } from './formatEventSummary.js'
@@ -19,14 +21,16 @@ const t = (key: string, opts?: Record<string, unknown>) => {
'logs.live_cast_off': 'Cast off', 'logs.live_cast_off': 'Cast off',
'logs.live_moor': 'Moor', 'logs.live_moor': 'Moor',
'logs.live_sails': `Sails: ${opts?.sails ?? ''}`, 'logs.live_sails': `Sails: ${opts?.sails ?? ''}`,
'logs.live_fix': 'Fix', 'logs.live_position': 'Position',
'logs.live_fix_coords': `Fix ${opts?.lat}, ${opts?.lng}`, 'logs.live_position_coords': `Position ${opts?.lat}, ${opts?.lng}`,
'logs.live_event_generic': 'Event', 'logs.live_event_generic': 'Event',
'logs.live_temp_entry': `Temperature ${opts?.temp} °C`, 'logs.live_temp_entry': `Temperature ${opts?.temp} °C`,
'logs.live_pressure_entry': `Pressure ${opts?.value} hPa`, 'logs.live_pressure_entry': `Pressure ${opts?.value} hPa`,
'logs.live_visibility_entry': `Visibility ${opts?.value}`,
'logs.live_wind_entry': `Wind ${opts?.value}`, 'logs.live_wind_entry': `Wind ${opts?.value}`,
'logs.live_photo_entry': `Photo: ${opts?.caption}`, 'logs.live_photo_entry': `Photo: ${opts?.caption}`,
'logs.live_photo_entry_plain': 'Photo captured', 'logs.live_photo_entry_plain': 'Photo captured',
'logs.live_voice_entry_plain': 'Voice memo',
'logs.live_course_entry': `Course ${opts?.course}`, 'logs.live_course_entry': `Course ${opts?.course}`,
'logs.live_sog_entry': `SOG ${opts?.speed} kn`, 'logs.live_sog_entry': `SOG ${opts?.speed} kn`,
'logs.live_stw_entry': `STW ${opts?.speed} kn`, 'logs.live_stw_entry': `STW ${opts?.speed} kn`,
@@ -58,6 +62,12 @@ describe('liveEventCodes', () => {
expect(parseLiveSailsRemark(liveSailsRemark('Main + Genoa'))).toBe('Main + Genoa') expect(parseLiveSailsRemark(liveSailsRemark('Main + Genoa'))).toBe('Main + Genoa')
expect(parseLiveCommentRemark(liveCommentRemark('Wind dreht'))).toBe('Wind dreht') expect(parseLiveCommentRemark(liveCommentRemark('Wind dreht'))).toBe('Wind dreht')
}) })
it('parses voice remark with uuid', () => {
const id = 'a1b2c3d4-e5f6-4789-a012-3456789abcde'
expect(parseLiveVoiceRemark(liveVoiceRemark(id))).toBe(id)
expect(parseLiveVoiceRemark('__live:voice:not-a-uuid')).toBeNull()
})
}) })
describe('formatEventSummary', () => { describe('formatEventSummary', () => {
@@ -75,14 +85,14 @@ describe('formatEventSummary', () => {
expect(formatEventSummary(event, t)).toBe('Sails: Main + Genoa') expect(formatEventSummary(event, t)).toBe('Sails: Main + Genoa')
}) })
it('formats fix with coordinates', () => { it('formats position with coordinates', () => {
const event = normalizeLogEvent({ const event = normalizeLogEvent({
time: '09:00', time: '09:00',
remarks: LIVE_EVENT_CODES.FIX, remarks: LIVE_EVENT_CODES.POSITION,
gpsLat: '54.323000', gpsLat: '54.323000',
gpsLng: '10.145000' gpsLng: '10.145000'
}) })
expect(formatEventSummary(event, t)).toBe('Fix 54.323000, 10.145000') expect(formatEventSummary(event, t)).toBe('Position 54.323000, 10.145000')
}) })
it('formats pressure entry', () => { it('formats pressure entry', () => {
@@ -94,6 +104,15 @@ describe('formatEventSummary', () => {
expect(formatEventSummary(event, t)).toBe('Pressure 1013 hPa') 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', () => { it('formats SOG entry', () => {
const event = normalizeLogEvent({ const event = normalizeLogEvent({
time: '10:15', time: '10:15',
@@ -120,4 +139,10 @@ describe('formatEventSummary', () => {
}) })
expect(formatEventSummary(captioned, t)).toBe('Photo: Mastbruch') expect(formatEventSummary(captioned, t)).toBe('Photo: Mastbruch')
}) })
it('formats voice memo entry', () => {
const id = 'a1b2c3d4-e5f6-4789-a012-3456789abcde'
const event = normalizeLogEvent({ time: '12:00', remarks: liveVoiceRemark(id) })
expect(formatEventSummary(event, t)).toBe('Voice memo')
})
}) })
+15 -3
View File
@@ -1,10 +1,12 @@
import type { TFunction } from 'i18next' import type { TFunction } from 'i18next'
import type { LogEventPayload } from './logEntryPayload.js' import type { LogEventPayload } from './logEntryPayload.js'
import { import {
isManualPositionEventCode,
LIVE_EVENT_CODES, LIVE_EVENT_CODES,
parseLiveCommentRemark, parseLiveCommentRemark,
parseLiveFuelRemark, parseLiveFuelRemark,
parseLivePhotoRemark, parseLivePhotoRemark,
parseLiveVoiceRemark,
parseLivePrecipRemark, parseLivePrecipRemark,
parseLiveSailsRemark, parseLiveSailsRemark,
parseLiveSogRemark, parseLiveSogRemark,
@@ -34,6 +36,11 @@ export function formatEventSummary(event: LogEventPayload, t: TFunction): string
: t('logs.live_photo_entry_plain') : t('logs.live_photo_entry_plain')
} }
const voiceId = parseLiveVoiceRemark(code)
if (voiceId) {
return t('logs.live_voice_entry_plain')
}
const temp = parseLiveTempRemark(code) const temp = parseLiveTempRemark(code)
if (temp) return t('logs.live_temp_entry', { temp }) if (temp) return t('logs.live_temp_entry', { temp })
@@ -52,16 +59,16 @@ export function formatEventSummary(event: LogEventPayload, t: TFunction): string
const stw = parseLiveStwRemark(code) const stw = parseLiveStwRemark(code)
if (stw) return t('logs.live_stw_entry', { speed: stw }) if (stw) return t('logs.live_stw_entry', { speed: stw })
if (code === LIVE_EVENT_CODES.FIX || code === LIVE_EVENT_CODES.AUTO_POSITION) { if (isManualPositionEventCode(code) || code === LIVE_EVENT_CODES.AUTO_POSITION) {
if (event.gpsLat && event.gpsLng) { if (event.gpsLat && event.gpsLng) {
const label = code === LIVE_EVENT_CODES.AUTO_POSITION const label = code === LIVE_EVENT_CODES.AUTO_POSITION
? t('logs.live_auto_position') ? t('logs.live_auto_position')
: t('logs.live_fix') : t('logs.live_position')
return `${label} ${event.gpsLat}, ${event.gpsLng}` return `${label} ${event.gpsLat}, ${event.gpsLng}`
} }
return code === LIVE_EVENT_CODES.AUTO_POSITION return code === LIVE_EVENT_CODES.AUTO_POSITION
? t('logs.live_auto_position') ? t('logs.live_auto_position')
: t('logs.live_fix') : t('logs.live_position')
} }
if (code === LIVE_EVENT_CODES.COURSE && event.mgk) { if (code === LIVE_EVENT_CODES.COURSE && event.mgk) {
@@ -81,6 +88,10 @@ export function formatEventSummary(event: LogEventPayload, t: TFunction): string
return t('logs.live_sea_state_entry', { value: event.seaState }) 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:')) { if (code && !code.startsWith('__live:')) {
return code return code
} }
@@ -92,6 +103,7 @@ export function formatEventSummary(event: LogEventPayload, t: TFunction): string
parts.push([event.windDirection, event.windStrength].filter(Boolean).join(' ')) parts.push([event.windDirection, event.windStrength].filter(Boolean).join(' '))
} }
if (event.windPressure) parts.push(`${t('logs.event_wind_pressure')}: ${event.windPressure}`) 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) { if (event.gpsLat && event.gpsLng) {
parts.push(`${event.gpsLat}, ${event.gpsLng}`) parts.push(`${event.gpsLat}, ${event.gpsLng}`)
} }
+1 -4
View File
@@ -7,7 +7,4 @@ export function computeFuelPerMotorHour(
return Number((fuelConsumptionL / motorHours).toFixed(2)) return Number((fuelConsumptionL / motorHours).toFixed(2))
} }
export function formatFuelPerMotorHour(value: number | null | undefined): string { export { formatFuelPerMotorHour } from './numberFormat.js'
if (value == null) return '—'
return Number.isInteger(value) ? String(value) : value.toFixed(2)
}
+46 -3
View File
@@ -1,12 +1,29 @@
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { import {
classifyGpsAccuracyMeters,
formatGpsAccuracyMeters,
geolocationErrorI18nKey,
GEOLOCATION_LIVE_INTRO_STORAGE_KEY,
getCurrentPosition, getCurrentPosition,
getGeolocationErrorReason,
hasSeenGeolocationLiveIntro,
markGeolocationLiveIntroSeen,
normalizeGpsCoordinates, normalizeGpsCoordinates,
parseGpsCoordinate, parseGpsCoordinate,
queryGeolocationPermission queryGeolocationPermission
} from './geolocation.js' } from './geolocation.js'
describe('geolocation helpers', () => { describe('geolocation helpers', () => {
beforeEach(() => {
localStorage.removeItem(GEOLOCATION_LIVE_INTRO_STORAGE_KEY)
})
it('tracks Live-Log geolocation intro in localStorage', () => {
expect(hasSeenGeolocationLiveIntro()).toBe(false)
markGeolocationLiveIntroSeen()
expect(hasSeenGeolocationLiveIntro()).toBe(true)
})
it('parses coordinates with comma decimals', () => { it('parses coordinates with comma decimals', () => {
expect(parseGpsCoordinate('54,123')).toBeCloseTo(54.123) expect(parseGpsCoordinate('54,123')).toBeCloseTo(54.123)
}) })
@@ -50,7 +67,7 @@ describe('geolocation helpers', () => {
geolocation: { geolocation: {
getCurrentPosition: (success: PositionCallback) => { getCurrentPosition: (success: PositionCallback) => {
success({ success({
coords: { latitude: 59.91, longitude: 10.75, speed: 2.5 } coords: { latitude: 59.91, longitude: 10.75, speed: 2.5, accuracy: 12 }
} as GeolocationPosition) } as GeolocationPosition)
} }
} }
@@ -59,10 +76,36 @@ describe('geolocation helpers', () => {
await expect(getCurrentPosition({ timeoutMs: 1000, enableHighAccuracy: false })).resolves.toEqual({ await expect(getCurrentPosition({ timeoutMs: 1000, enableHighAccuracy: false })).resolves.toEqual({
lat: '59.910000', lat: '59.910000',
lng: '10.750000', lng: '10.750000',
speedKn: 4.9 speedKn: 4.9,
accuracyM: 12,
signalQuality: 'excellent'
}) })
}) })
it('formats GPS accuracy for display', () => {
expect(formatGpsAccuracyMeters(12.4)).toBe('12')
expect(formatGpsAccuracyMeters(87)).toBe('87')
expect(formatGpsAccuracyMeters(105)).toBe('110')
expect(formatGpsAccuracyMeters(247)).toBe('250')
})
it('classifies GPS accuracy into signal quality', () => {
expect(classifyGpsAccuracyMeters(8)).toBe('excellent')
expect(classifyGpsAccuracyMeters(30)).toBe('good')
expect(classifyGpsAccuracyMeters(80)).toBe('fair')
expect(classifyGpsAccuracyMeters(250)).toBe('poor')
expect(classifyGpsAccuracyMeters(null)).toBe('unknown')
})
it('maps GeolocationPositionError codes to reasons', () => {
expect(getGeolocationErrorReason({ code: 1 } as GeolocationPositionError)).toBe('permission_denied')
expect(getGeolocationErrorReason({ code: 2 } as GeolocationPositionError)).toBe('position_unavailable')
expect(getGeolocationErrorReason({ code: 3 } as GeolocationPositionError)).toBe('timeout')
expect(getGeolocationErrorReason(new Error('geolocation_timeout'))).toBe('timeout')
expect(getGeolocationErrorReason(new Error('geolocation_unavailable'))).toBe('unavailable')
expect(geolocationErrorI18nKey('permission_denied')).toBe('logs.gps_permission_denied')
})
it('reads permission state when supported', async () => { it('reads permission state when supported', async () => {
vi.stubGlobal('navigator', { vi.stubGlobal('navigator', {
geolocation: {}, geolocation: {},
+94 -8
View File
@@ -1,17 +1,80 @@
import {
formatAppCoordinate,
formatCanonicalCoordinate,
formatGpsAccuracyMeters,
parseAppDecimal
} from './numberFormat.js'
const MPS_TO_KNOTS = 1.9438444924406 const MPS_TO_KNOTS = 1.9438444924406
/** Extra ms beyond the native timeout so hung browsers still reject. */ /** Extra ms beyond the native timeout so hung browsers still reject. */
const TIMEOUT_GRACE_MS = 750 const TIMEOUT_GRACE_MS = 750
/** Estimated fix quality from browser accuracy (metres). Real satellite count is not exposed to web apps. */
export type GpsSignalQuality = 'excellent' | 'good' | 'fair' | 'poor' | 'unknown'
export interface GeoCoordinates { export interface GeoCoordinates {
lat: string lat: string
lng: string lng: string
/** SOG from GPS when available (kn), otherwise null. */ /** SOG from GPS when available (kn), otherwise null. */
speedKn: number | null speedKn: number | null
/** Estimated horizontal accuracy in metres, when reported by the browser. */
accuracyM: number | null
/** Derived signal quality indicator for UI hints. */
signalQuality: GpsSignalQuality
}
/** Classifies GPS fix quality from reported accuracy (lower metres = better). */
export function classifyGpsAccuracyMeters(accuracyM: number | null | undefined): GpsSignalQuality {
if (accuracyM == null || !Number.isFinite(accuracyM) || accuracyM < 0) return 'unknown'
if (accuracyM <= 15) return 'excellent'
if (accuracyM <= 40) return 'good'
if (accuracyM <= 100) return 'fair'
return 'poor'
}
export function gpsQualityI18nKey(quality: GpsSignalQuality): string {
return `logs.gps_quality_${quality}`
} }
export type GeolocationPermissionState = PermissionState | 'unsupported' export type GeolocationPermissionState = PermissionState | 'unsupported'
export type GeolocationErrorReason =
| 'unavailable'
| 'timeout'
| 'permission_denied'
| 'position_unavailable'
| 'unknown'
/** Maps browser / wrapper errors to a stable reason for i18n. */
export function getGeolocationErrorReason(error: unknown): GeolocationErrorReason {
if (error instanceof Error) {
if (error.message === 'geolocation_unavailable') return 'unavailable'
if (error.message === 'geolocation_timeout') return 'timeout'
}
const code = (error as GeolocationPositionError | undefined)?.code
if (code === 1) return 'permission_denied'
if (code === 2) return 'position_unavailable'
if (code === 3) return 'timeout'
return 'unknown'
}
/** i18n key (full path, e.g. logs.gps_timeout) for a geolocation failure reason. */
export function geolocationErrorI18nKey(reason: GeolocationErrorReason): string {
switch (reason) {
case 'unavailable':
return 'logs.gps_unavailable'
case 'timeout':
return 'logs.gps_timeout'
case 'permission_denied':
return 'logs.gps_permission_denied'
case 'position_unavailable':
return 'logs.gps_position_unavailable'
default:
return 'logs.gps_failed'
}
}
export interface GetPositionOptions { export interface GetPositionOptions {
timeoutMs?: number timeoutMs?: number
/** Manual fixes may use high accuracy; background auto-position should not. */ /** Manual fixes may use high accuracy; background auto-position should not. */
@@ -19,11 +82,10 @@ export interface GetPositionOptions {
maximumAge?: number maximumAge?: number
} }
export { formatGpsAccuracyMeters }
export function parseGpsCoordinate(value: string): number | null { export function parseGpsCoordinate(value: string): number | null {
const trimmed = value.trim() return parseAppDecimal(value.trim())
if (!trimmed) return null
const n = parseFloat(trimmed.replace(',', '.'))
return Number.isFinite(n) ? n : null
} }
/** Validates lat/lng and returns normalized strings for storage, or null. */ /** Validates lat/lng and returns normalized strings for storage, or null. */
@@ -35,7 +97,26 @@ export function normalizeGpsCoordinates(
const lngN = parseGpsCoordinate(lng) const lngN = parseGpsCoordinate(lng)
if (latN == null || lngN == null) return null if (latN == null || lngN == null) return null
if (latN < -90 || latN > 90 || lngN < -180 || lngN > 180) return null if (latN < -90 || latN > 90 || lngN < -180 || lngN > 180) return null
return { lat: latN.toFixed(6), lng: lngN.toFixed(6) } return { lat: formatCanonicalCoordinate(latN), lng: formatCanonicalCoordinate(lngN) }
}
/** localStorage: user has seen the Live-Log geolocation intro (allow or dismiss). */
export const GEOLOCATION_LIVE_INTRO_STORAGE_KEY = 'kdb_geolocation_live_intro_seen'
export function hasSeenGeolocationLiveIntro(): boolean {
try {
return localStorage.getItem(GEOLOCATION_LIVE_INTRO_STORAGE_KEY) === '1'
} catch {
return false
}
}
export function markGeolocationLiveIntroSeen(): void {
try {
localStorage.setItem(GEOLOCATION_LIVE_INTRO_STORAGE_KEY, '1')
} catch {
// Private mode / quota — non-fatal
}
} }
export async function queryGeolocationPermission(): Promise<GeolocationPermissionState> { export async function queryGeolocationPermission(): Promise<GeolocationPermissionState> {
@@ -65,10 +146,15 @@ function positionFromGeolocationPosition(pos: GeolocationPosition): GeoCoordinat
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed) const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1)) ? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
: null : null
const accuracyM = pos.coords.accuracy != null && Number.isFinite(pos.coords.accuracy)
? pos.coords.accuracy
: null
return { return {
lat: pos.coords.latitude.toFixed(6), lat: formatAppCoordinate(pos.coords.latitude),
lng: pos.coords.longitude.toFixed(6), lng: formatAppCoordinate(pos.coords.longitude),
speedKn speedKn,
accuracyM,
signalQuality: classifyGpsAccuracyMeters(accuracyM)
} }
} }

Some files were not shown because too many files have changed in this diff Show More