Compare commits

...

202 Commits

Author SHA1 Message Date
elpatron fae7b20f90 chore: release v0.1.1.11 2026-06-03 19:39:37 +02:00
elpatron 73e7613a1b feat(logbook): attribute log events to creator and show in exports 2026-06-03 19:39:15 +02:00
elpatron 6c8aa5af4c chore: release v0.1.1.10 2026-06-03 19:17:02 +02:00
elpatron 9554f4b66e style(client): center PWA update and install banners properly 2026-06-03 19:16:56 +02:00
elpatron 5c77bbfdc3 style(client): hide version footer on mobile when bottom navigation is active 2026-06-03 19:15:09 +02:00
elpatron 979b572136 chore: release v0.1.1.9 2026-06-03 19:11:29 +02:00
elpatron f189317dfc chore: remove visual debug logs panel from voice recording modal 2026-06-03 19:11:25 +02:00
elpatron c54f834311 chore: release v0.1.1.8 2026-06-03 19:07:07 +02:00
elpatron 9d05005bb7 fix: allow blob and data urls in Content-Security-Policy media-src directive 2026-06-03 19:07:03 +02:00
elpatron 40c4874156 chore: release v0.1.1.7 2026-06-03 18:56:39 +02:00
elpatron 2de0636608 fix: call load() to force mobile browsers to fetch blob URL metadata and fix player duration 2026-06-03 18:56:32 +02:00
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
elpatron 182ea497d8 chore: release v0.1.0.85 2026-06-01 19:43:24 +02:00
elpatron 837bcfe287 fix(ui): Disclaimer-Modal Scroll und Schließen-Button
Ein Scrollbereich im Hinweis-Dialog, Body-Lock beim Öffnen, X oben rechts an der Karte.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:42:56 +02:00
elpatron d261a1e7ca i18n: Mannschaft und Äquivalente zu Crew umbenennen
Einheitliche Crew-Terminologie in allen App-Sprachen (de, en, nb, sv, da).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:38:46 +02:00
elpatron 2ebc3e8a44 chore: release v0.1.0.84 2026-06-01 19:34:10 +02:00
elpatron 047a5b1bdb fix(crew): keep first legacy skipper when several are present
Prefer canonical skipper id and stop overwriting activeSkipperId during
legacy crew migration and read-only share conversion.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:34:01 +02:00
elpatron 7a7e9d5d28 chore: release v0.1.0.83 2026-06-01 19:31:20 +02:00
elpatron 39cbe707c7 fix(server): Docker build when prisma postinstall runs
Copy Prisma schema before npm ci in the builder image and skip
postinstall in the production stage since the client is copied from builder.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:30:53 +02:00
elpatron bb6e7f5c32 chore: release v0.1.0.82 2026-06-01 19:29:51 +02:00
elpatron ca0daa8f2a fix(logs): repair journal entry cards and avoid duplicate days
Match dashboard card DOM so entry tiles get correct height, remove nested
form-card chrome, and open today’s entry instead of creating a second one.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:27:09 +02:00
elpatron 2304f95ac1 fix(live-log): prevent freeze without GPS and prompt for day-start position
Harden geolocation with watchdog timeouts and permission checks so
desktop browsers without GPS no longer hang Live-Log. Show a hint to
log a position when none exists for the day.

Return 503 when crew-pool Prisma models are missing instead of crashing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:20:34 +02:00
elpatron 98c0ed81d4 Raise Stammcrew pool limit from 5 to 12 crew members.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:07:37 +02:00
elpatron 3504ec97cc Add account-level crew pool with per-logbook and per-day selection.
Move skipper and crew master data to the user profile pool, replace the logbook crew tab with selection from that pool, inherit crew on new travel days, and sync via new PersonPayload and LogbookCrewSelection models. Includes migration from legacy crew records, tour/demo updates, and i18n.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 19:05:50 +02:00
elpatron 4c6c2779f2 chore: release v0.1.0.81 2026-06-01 18:35:35 +02:00
elpatron b6c4e9e7d9 fix(dashboard): allow spaces when renaming logbook title
Move the title input out of the card open button so Space no longer
activates navigation; use an overlay button for opening the logbook.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 18:35:20 +02:00
elpatron 04c6be2b5b chore: release v0.1.0.80 2026-06-01 15:30:57 +02:00
elpatron 9089d017b6 feat(ux): Sprint 3 mobile nav, sync conflicts, and resilience
Improve mobile bottom navigation, accessible dialogs and cards, explicit
sync conflict resolution, i18n error messages, encrypted draft autosave,
and persistent storage hints for offline data safety.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:30:08 +02:00
elpatron f8dc6ace3c chore: release v0.1.0.79 2026-06-01 15:20:55 +02:00
elpatron 18f14d7e0b chore(deploy): run predeploy-check.sh from update-prod.sh
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:18:58 +02:00
elpatron 0edf4a789c feat(quality): Sprint 2 pre-deploy gates and server smoke tests
Extract Express app factory for testability, add Vitest/Supertest API
smoke tests, root npm run check script, and deployment docs. Fix
express-rate-limit IPv6 keyGenerator for feedback limiter.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:17:46 +02:00
elpatron 4ef56aeb8f fix(ops): force-recreate backend after postgres password rotation
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:10:50 +02:00
elpatron 3263fbcec3 chore: restore merge skill accidentally removed
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:09:24 +02:00
elpatron b9ce853059 feat(ops): script to rotate PostgreSQL password safely
Add rotate-postgres-password.sh with optional app role, document the
procedure, and stop defaulting production POSTGRES_PASSWORD to postgres.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:09:15 +02:00
elpatron 3d8a505bd9 fix(nginx): security headers on index.html and PWA asset routes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:04:27 +02:00
elpatron e138752dd3 feat(security): Sprint 1 hardening for production behind NPM
Add trust proxy, WebAuthn challenge TTL, stricter public collaboration
rate limits, generic 500 responses, Docker POSTGRES_PASSWORD from env,
nginx security headers/CSP, and deployment documentation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:02:15 +02:00
elpatron b9c908169b chore: release v0.1.0.78 2026-06-01 13:44:36 +02:00
elpatron e6bde5c525 fix: shorten travel day badge to "Reisetag x"
Use travel_day_number i18n key in journal cards and live log subtitle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 13:41:27 +02:00
elpatron eab7b86c0b chore: release v0.1.0.77 2026-06-01 13:28:24 +02:00
elpatron b86789ae4c fix: show profile button while a logbook is open
Extract ProfileHeaderButton and open UserProfilePage from any screen
without leaving the active logbook.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 13:28:03 +02:00
elpatron 2a8ec2fccf chore: release v0.1.0.76 2026-06-01 11:43:33 +02:00
elpatron 60a8533a44 feat: add Plausible events for live log photos and OWM usage
Track Live Log Photo Uploaded and centralize OWM Weather Fetched with
source props for live log and entry editor call sites.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 11:34:21 +02:00
elpatron c86ac4273c chore: release v0.1.0.75 2026-06-01 10:59:31 +02:00
elpatron 73467f2263 fix: live journal camera save on Android
Use native camera picker on mobile, add preview-and-save step, and
harden canvas capture with toDataURL fallback when toBlob fails.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 10:45:54 +02:00
elpatron e068f083c1 style: add blank line before LiveLogViewProps interface
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 10:01:34 +02:00
elpatron f083294db5 chore: release v0.1.0.74 2026-06-01 09:58:44 +02:00
elpatron 8fc15081e2 Show QR codes for invite and public share links.
Generate scannable QR codes in settings next to collaboration links so crew can open invites on mobile without copying long URLs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 09:58:16 +02:00
elpatron efa0fcf934 Add live journal camera photos and harden OWM button.
Capture photos via getUserMedia in live log, share encrypted save logic with the editor, and disable weather fetch while other quick actions run.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 09:47:56 +02:00
elpatron c1ecdcad9c Fix live journal hang on empty new logbooks.
Fast-path today's entry creation, add init timeout, defer auto-position GPS, and migrate logbook keys when the server returns a different id.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 09:39:51 +02:00
elpatron d6c7952af8 Fix live journal freeze during OpenWeatherMap fetch.
Batch weather events in one persist cycle, avoid global busy state while loading, and add a 20s API timeout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 09:19:03 +02:00
elpatron 3d02f841a0 Add live journal OWM weather and manual GPS fix dialog.
Fetch OpenWeatherMap in live log when a GPS fix is under six hours old, open a fix modal with manual coordinates when geolocation fails, and only show the undo bar after a successful save.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 09:03:37 +02:00
elpatron 0caaf681d8 Fix live journal freeze and passkey login on localhost.
Harden live log init with safe per-entry decrypt, stable loading state, and no parallel list scan in live mode. Improve multi-sail picker UX, stop WebAuthn retry after user cancel, redirect 127.0.0.1 to localhost, and tolerate missing appearance prefs table.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 08:49:45 +02:00
elpatron 43dc994c4f Update beta flyer with NMEA, live log, and route stats features.
Add three feature bullets in all locales, reduce feature list font to 8.5pt for single-page layout, and regenerate PDF/PNG exports.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:52:11 +02:00
elpatron d94502097e chore: release v0.1.0.73 2026-05-31 21:46:44 +02:00
elpatron a36ca2facb Add Plausible analytics for live journal and NMEA upload.
Track Live Log Opened/Event Logged with action types, NMEA Uploaded on parse success, and align NMEA Imported properties with docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:43:30 +02:00
elpatron b7a1085d52 chore: release v0.1.0.72 2026-05-31 21:39:22 +02:00
elpatron 3925c6f822 Add cloc code statistics report for the project.
Documents line counts by language, area, and largest source files for onboarding and size tracking.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:39:00 +02:00
elpatron 0b2c1c22c6 Guard optional event fields before calling trim in live-log paths.
Legacy decrypted events may omit mgk or wind fields; optional chaining prevents runtime crashes in course prefill and stats aggregation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:29:42 +02:00
elpatron aa03573e1f Fix live-log dial modals overlapping journal text.
Portal overlays to document.body and use opaque modal panels so fixed positioning works outside form-card and journal entries stay readable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:28:02 +02:00
elpatron a0b8664e23 Use course dials for live-log wind direction and course entry.
Reuses CourseDialInput from the classic journal editor in the live modals, prefilled from the most recent wind or course values.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:20:19 +02:00
elpatron 74282f50d0 Add SOG and STW live-log actions and capitalize motor labels.
SOG prefills from GPS speed when available; STW is entered manually. Motor journal entries now read “Motor Start” / “Motor Stop”.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:17:51 +02:00
elpatron 5b47415d55 Extend live journal with weather, tanks, undo, and event series stats.
Adds weather and course quick actions, diesel/water refills, five-second undo, foreground auto-position every three hours, and chronological pressure/wind/motor series in the stats tab.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:11:52 +02:00
elpatron 039e4e2736 Add live journal mode for one-tap event logging during travel.
Introduces a parallel Live view alongside the existing travel-day list so skippers can log motor, sail, and position events instantly without navigating the full editor.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:09:02 +02:00
elpatron 35bfbc1043 Update .gitignore to exclude userfeedback directory. 2026-05-31 21:00:43 +02:00
elpatron 6c866dbad5 Add NMEA journal import with wizard and CRC-based duplicate detection.
Enables importing .nmea logs into travel-day events with interval/change modes, optional GPS track, local encrypted archive, and a test fixture for the Kieler Förde route.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 20:41:42 +02:00
elpatron bb667afec8 Document NMEA import research for future backlog evaluation.
Captures PWA constraints, file-import scope, and GPX comparison for a later feature decision.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 17:20:39 +02:00
elpatron beee33f842 Update plausible-events.md to specify that recommended goal chains are for business use only. 2026-05-31 16:41:16 +02:00
elpatron 77a7072b77 chore: release v0.1.0.71 2026-05-31 16:38:32 +02:00
elpatron bd1edd89f3 Track language selection with Plausible Language Changed event.
Centralize UI language switches in cycleAppLanguage and document the event in plausible-events.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 16:38:10 +02:00
elpatron ffe6b19818 chore: release v0.1.0.70 2026-05-31 16:33:35 +02:00
elpatron eb1f87f57e Add translation error category to feedback form.
Lets users report i18n issues via the feedback dropdown in all supported locales.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 16:33:28 +02:00
elpatron 13cb03646b chore: release v0.1.0.69 2026-05-31 16:06:36 +02:00
elpatron 1bc0d7fb2a Fix Scandinavian flyer layout by excluding CSS from DeepL translation.
Translate only visible body/title text, preserve HTML structure, and regenerate all flyer PDF/PNG exports.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 16:04:19 +02:00
elpatron 5f3d76b30f Generate beta flyer PDF and PNG for all locales.
Extend generate-beta-flyer.mjs with --all/--lang support and add da, sv, nb assets alongside refreshed German exports.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 16:00:09 +02:00
elpatron b48545e943 Add root package.json for translate scripts from repo root.
Avoids the client/client path when --prefix client is used from inside client/.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 15:55:25 +02:00
elpatron 3749f87c1d Add Scandinavian i18n (da/sv/nb) via DeepL pipeline.
Integrate new locale bundles, language cycling in the UI, SEO hreflang tags, and localized beta flyer HTML variants with scripts for batch translation and key validation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 15:53:43 +02:00
elpatron 2e656dc6b2 feat(marketing): PNG-Export für Beta-Flyer ergänzen
Erweitert den bestehenden Flyer-Generator um eine PNG-Ausgabe aus der HTML-Vorlage inklusive eigenem npm-Skript und erzeugter PNG-Datei für den direkten Einsatz.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 15:19:34 +02:00
elpatron 484ed66b7b chore: release v0.1.0.68 2026-05-31 15:03:26 +02:00
elpatron 49d77f08a2 fix(pwa): Recovery-Render-Race beim Reload vermeiden
Stoppt den Bootstrap nach einem erfolgreichen Hard-Recovery sofort, damit vor dem asynchronen Reload kein React-Render mehr gestartet wird.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 15:03:00 +02:00
elpatron 951b5b3f1c fix(pwa): Startup-Hänger nach Inaktivität stabilisieren
Verhindert Blank-Screens und Reload-Schleifen beim Wiederöffnen der PWA, indem Recovery nur bei bestätigter SW-Übernahme greift und Navigationen bevorzugt frisch aus dem Netzwerk geladen werden.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 15:00:51 +02:00
elpatron abb708c3d0 fix(dashboard): Sortierbuttons in der Mobile-Ansicht besser ausrichten
Die Sortiersteuerung nutzt auf schmalen Viewports eine horizontale Zeile mit gleichmäßig verteilten Touch-Zielen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:55:42 +02:00
elpatron cc87b0f8e6 chore: release v0.1.0.67 2026-05-31 14:46:45 +02:00
elpatron 58984594b0 fix(pwa): Pending-Refresh bei Suppression nicht sofort verwerfen
Suppression wird vor dem Flush geprüft, damit gepufferte
needRefresh-Werte erhalten bleiben, bis die Unterdrückung endet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:46:02 +02:00
elpatron 61675e1085 fix(pwa): Ausstehendes needRefresh nicht mehr während des Renders setzen
Frühe Service-Worker-Callbacks puffern den Refresh-Status; der Flush
erfolgt jetzt im useEffect statt in der Render-Phase.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:44:12 +02:00
elpatron 2082218f78 chore: release v0.1.0.66 2026-05-31 14:43:25 +02:00
elpatron 5882edcbdf fix(dashboard): Sortierbuttons als kompakte Icon-Buttons ohne Text
Beschriftungen entfernt, Buttons auf 36px verkleinert; aria-label und
title bleiben für Tooltip und Barrierefreiheit erhalten.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:41:46 +02:00
elpatron b7a47a1d90 fix(ui): Vite-Template-CSS entfernen und App-Layout wieder zentrieren
index.css auf App-Shell reduziert, App.css zentral in main.tsx geladen
und #root zentriert Dashboard/Logbuch-Ansichten nach dem Login.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:40:03 +02:00
elpatron 48c408302f fix(pwa): Hintergrund-SW-Updates während Update-Suppression unterbinden
Das periodische Intervall nutzt checkForUpdate() und respektiert damit
„Später“ wie Fokus- und Online-Checks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:28:01 +02:00
elpatron 2b5c5d4a36 feat(dashboard): Filter und Sortierung für die Logbuchliste
Hilft bei vielen Logbüchern: Suche nach Name/Jahr/Datum sowie Sortierung nach Name oder Datum in beide Richtungen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:27:41 +02:00
elpatron 7cf04b3357 fix(pwa): Recovery-Zähler, Update-Suppression und Timer-Leaks beheben
Stale-Recovery zählt nur aufeinanderfolgende Fehler und wird nach Hard Recovery zurückgesetzt; Update-Checks respektieren „Später“, und PWA-Refresh-State sowie Recovery-Timer werden zuverlässig gesetzt bzw. beim Unmount aufgeräumt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:24:25 +02:00
elpatron bbd4281dcb fix(pwa): Updates zuverlässiger erkennen und veraltete Instanzen automatisch reparieren
Unabhängige version.json-Prüfung, häufigere Update-Checks und Hard Recovery
beheben hängende Android-PWAs ohne manuelles Cache-Löschen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:20:54 +02:00
elpatron d2833f7664 chore: release v0.1.0.65 2026-05-31 14:14:25 +02:00
elpatron 2a14080b5b fix(appearance): Theme-Sync an aufgelöste User-ID und Session koppeln
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:13:09 +02:00
elpatron 2457fa41e3 fix(profile): Profil-Statistiken auf Mobilgeräten platzsparender gestalten
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:11:57 +02:00
elpatron 87b0fa7bde chore: release v0.1.0.64 2026-05-31 14:03:27 +02:00
elpatron d90f292a21 fix(appearance): Theme-Einstellungen serverseitig speichern und beim Login wiederherstellen
Nach PWA-Cache-Löschung gingen Theme und Farbschema verloren, weil sie nur in localStorage lagen. Die Präferenzen werden jetzt synchronisiert und nach dem Login erneut angewendet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:03:17 +02:00
elpatron 9e42f828a0 chore: release v0.1.0.63 2026-05-31 14:00:00 +02:00
elpatron 4197e77b1e feat(auth): Passwortmanager für Wiederherstellungsschlüssel aktivieren
Das Eingabefeld nutzt jetzt Passwort-Semantik und Autocomplete-Attribute, damit OS-Passwortmanager gespeicherte Schlüssel vorschlagen können.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:59:50 +02:00
elpatron 1373c11de8 fix(pwa): Kaltstart nach verpassten Updates stabilisieren
Service Worker übernimmt Updates zuverlässig (SKIP_WAITING, clientsClaim),
wartende Versionen werden beim Start angewendet und veraltete Chunks führen
nicht mehr zum Hängenbleiben.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:58:35 +02:00
elpatron 0bae3b29dc feat: Grauwasserstand beim neuen Reisetag vom Vortag übernehmen
Übernimmt den Grauwasser-Füllstand analog zu Frischwasser und Kraftstoff
beim Anlegen eines Reisetags und zeigt ihn im Übernahme-Dialog an.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:54:57 +02:00
elpatron 73e86d28b3 chore: release v0.1.0.62 2026-05-31 13:47:49 +02:00
elpatron ad4721e694 fix: Fehler-Alert nach Push-Aktivierung korrekt awaiten
Stellt sicher, dass die Fehlermeldung angezeigt wird, bevor
promptPushAfterInviteCreated zurückkehrt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:47:36 +02:00
elpatron 8037b3b63e chore: release v0.1.0.61 2026-05-31 13:45:05 +02:00
elpatron c4cd566da0 fix: Tank-Nachfüll-Clamping und Deaktivierung bei vollem Tank
Korrigiert fehlende useEffect-Dependencies beim Auto-Clamping und
deaktiviert Refill-Eingaben, wenn der Morgenstand die Tankkapazität erreicht.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:44:49 +02:00
elpatron 3a267905b0 feat: Push-Hinweis nach Erstellen eines Crew-Einladungslinks
Owner sieht einen Dialog zur Aktivierung von Crew-Push-Benachrichtigungen, sofern diese noch nicht aktiv sind.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:43:52 +02:00
elpatron c856c2e903 chore: release v0.1.0.60 2026-05-31 13:41:29 +02:00
elpatron b3256d1685 fix: Tank-Slider für Touch auf Mobilgeräten vergrößern
Größerer Thumb und sichtbarere Spur mit touch-action, damit Füllstände am Handy leichter einstellbar sind.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:40:59 +02:00
elpatron 23fc940324 feat: dynamische Slider-Obergrenzen für Frischwasser und Treibstoff
Nachgefüllt ist auf Restkapazität nach Morgen begrenzt, Stand abends auf Morgen plus Nachgefüllt; Werte werden bei Änderungen automatisch gekürzt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:39:46 +02:00
elpatron 25e1bdded3 feat: Tankkapazitäten, Grauwasser und Slider im Journal
Schiffsdaten speichern optionale Tankvolumina; Reisetage erfassen Grauwasser-Füllstand und nutzen Slider bei bekannter Kapazität, inkl. Tooltips und CSV/PDF-Export.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:33:40 +02:00
elpatron 6a61c9e06c chore: release v0.1.0.59 2026-05-31 13:25:11 +02:00
elpatron d3683ad6aa fix: DemoViewer an erweiterte TourNavigation-Schnittstelle anpassen
Ergänzt No-Op-Stubs für setLogbookActive und setProfileOpen, damit der
Production-Build nach der Profil-Tour-Erweiterung wieder durchläuft.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:25:04 +02:00
elpatron ef5891ba3f chore: release v0.1.0.58 2026-05-31 13:22:59 +02:00
elpatron d25095bab3 fix: Fehlalarm bei sauberem Working Tree in update-prod.sh vermeiden
Die Clean-Tree-Prüfung verlässt sich nur noch auf git status --porcelain,
da git diff-index nach git reset fälschlich Änderungen melden kann.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:22:52 +02:00
elpatron 0d16782001 fix: Onboarding-Tour bei gelöschtem Demo-Logbuch und GPS-Schritt stabilisieren
Bereinigt veraltete Demo-Referenzen, löst gültiges Logbuch und ersten Eintrag zur Laufzeit auf und scrollt den GPS-Track-Schritt automatisch ins Viewport.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:15:53 +02:00
elpatron b7e2d470a9 fix: Tour-Tooltip auf feste Breite begrenzen
Entfernt left+right-Stretching in CSS, positioniert das Tooltip horizontal
am Spotlight und misst Ziele nach Navigation mit verzögerten Retries.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:08:31 +02:00
elpatron 520ba766a3 feat: Onboarding-Tour um Benutzerprofil erweitern
Profil-Schritte auf dem Dashboard und in den Einstellungen, entry_open
highlightet nur die Karte ohne Editor, Finish verweist auf Benutzerprofil.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 13:04:02 +02:00
elpatron c215cd8b15 docs: Beta-Flyer-Formulierung für Skipper/Crew-Fotos präzisieren
Feature-Text nennt Avatarbilder statt Foto-Anhänge; PDF entsprechend aktualisiert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:57:23 +02:00
elpatron 27c780d2b8 fix: Passkey-Signatur beim Speichern der Logbuchseite erhalten
Leeres Event-Formular (nur Uhrzeit) galt fälschlich als Änderung und
invalidierte frische Signaturen. Speichern-Button und Hash-Sperre folgen
nun echten Entwürfen und synchronisiertem Seiteninhalt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:57:02 +02:00
elpatron aa52948ddc chore: release v0.1.0.57 2026-05-31 12:45:44 +02:00
elpatron 49b4e7b9c3 fix: Code- und Profil-Kontrast an App-Theme binden
Benutzer-ID und Passkey-IDs nutzen jetzt Theme-Token statt System-
prefers-color-scheme, damit Monospace-Text in allen Schemes lesbar bleibt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:43:27 +02:00
236 changed files with 29253 additions and 1697 deletions
+28 -5
View File
@@ -1,13 +1,36 @@
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)
# Free plan keys use api-free.deepl.com automatically (suffix :fx)
DeepLAPIKey=
# Passkey configuration (WebAuthn Relying Party ID and Origin)
# For local dev: localhost and http://localhost
# For production: e.g. kapteins-daagbok.eu and https://kapteins-daagbok.eu
# For local dev: use localhost (NOT 127.0.0.1 — browsers reject IP addresses for Passkeys)
# Production (kapteins-daagbok.eu):
# RP_ID=kapteins-daagbok.eu
# ORIGIN=https://kapteins-daagbok.eu
RP_ID=localhost
# Must match the frontend URL (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
# Optional: comma-separated CORS origins (defaults to ORIGIN; dev also allows 127.0.0.1:5173)
# CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# Behind reverse proxy — see docs/deployment/npm-security.md
# Docker Compose (NPM → frontend nginx → backend): TRUST_PROXY=1
# TRUST_PROXY=1
# Docker Compose database (required for production deploy)
# Generate: openssl rand -hex 24
# Rotate on running server: ./scripts/rotate-postgres-password.sh (see docs/deployment/postgres-password.md)
# POSTGRES_USER=postgres
# POSTGRES_PASSWORD=
# POSTGRES_DB=daagbox
# Optional: comma-separated CORS origins (defaults to ORIGIN; 127.0.0.1 may be allowed for CORS but not for login)
# CORS_ORIGINS=http://localhost:5173
# API session signing (min. 32 chars; required in production)
# Generate: openssl rand -base64 48
+2
View File
@@ -11,3 +11,5 @@ server/dist/
.env.local
.env.*.local
*.log
userfeedback/
+18 -6
View File
@@ -219,13 +219,19 @@ cd server && npx prisma db push && cd ..
| Health Check | http://localhost:5000/api/health |
| Public Demo | http://localhost:5173/demo |
### 5. Tests (Frontend)
### 5. Qualität & Tests
Vor jedem Deploy auf [kapteins-daagbok.eu](https://kapteins-daagbok.eu/) (kein externes CI):
```bash
cd client && npm test
npm run check
# oder: ./scripts/predeploy-check.sh
```
Vitest-Unit-Tests für Utils, i18n und Services (z. B. Kurswinkel, Benutzereinstellungen).
Einzeln: `npm test` (Client + Server) · `npm run build` · optional `npm run lint` (Client, noch nicht in `check`)
- **Client:** Vitest für Utils, i18n, Services
- **Server:** Smoke-Tests (`/api/health`, Auth-Guards) mit Supertest — siehe `server/src/api.smoke.test.ts`
## Docker (produktionsnah)
@@ -237,11 +243,12 @@ Gesamten Stack lokal bauen und starten:
Frontend: http://localhost · API: http://localhost/api/health · Demo: http://localhost/demo
Umgebungsvariablen in `.env` setzen — mindestens `RP_ID`, `ORIGIN` (z. B. `http://localhost`) und `SESSION_SECRET`. Für Push die VAPID-Variablen an den Backend-Container durchreichen (`docker-compose.yml``backend.environment`). Für Feedback `NTFY_*` setzen.
Umgebungsvariablen in `.env` setzen — mindestens `RP_ID`, `ORIGIN` (z. B. `http://localhost`), `SESSION_SECRET` und für Docker Compose `POSTGRES_PASSWORD`. Für Push die VAPID-Variablen an den Backend-Container durchreichen (`docker-compose.yml``backend.environment`). Für Feedback `NTFY_*` setzen.
## Deployment
Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen):
Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen). Führt vor dem SSH-Deploy automatisch [`predeploy-check.sh`](scripts/predeploy-check.sh) aus (`npm run check`):
```bash
./scripts/update-prod.sh
@@ -249,12 +256,17 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen):
Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
Auf dem Server müssen `server/.env` (oder gleichwertige Umgebung) u. a. `DATABASE_URL`, `RP_ID`, `ORIGIN`, `SESSION_SECRET` (≥ 32 Zeichen) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
Auf dem Server müssen `.env` u. a. `POSTGRES_PASSWORD`, `RP_ID`, `ORIGIN` (`https://kapteins-daagbok.eu`), `SESSION_SECRET` (≥ 32 Zeichen), `TRUST_PROXY` (NPM, z. B. `172.16.10.10` oder `1`) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deployment/npm-security.md).
## Dokumentation
| Dokument | Inhalt |
|----------|--------|
| [docs/deployment/npm-security.md](docs/deployment/npm-security.md) | NPM, TLS, `trust proxy`, Security-Header |
| [docs/deployment/predeploy.md](docs/deployment/predeploy.md) | Pre-Deploy-Checks ohne CI |
| [docs/deployment/postgres-password.md](docs/deployment/postgres-password.md) | PostgreSQL-Passwort rotieren / App-Rolle |
| [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics |
| [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan |
| [docs/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan |
+1 -1
View File
@@ -1 +1 @@
0.1.0.57
0.1.1.12
+1 -1
View File
@@ -29,4 +29,4 @@ EXPOSE 80
# Health check to verify Nginx is actively running
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
+4
View File
@@ -12,6 +12,9 @@
<link rel="canonical" href="https://kapteins-daagbok.eu/" />
<link rel="alternate" hreflang="de" href="https://kapteins-daagbok.eu/?lng=de" />
<link rel="alternate" hreflang="en" href="https://kapteins-daagbok.eu/?lng=en" />
<link rel="alternate" hreflang="da" href="https://kapteins-daagbok.eu/?lng=da" />
<link rel="alternate" hreflang="sv" href="https://kapteins-daagbok.eu/?lng=sv" />
<link rel="alternate" hreflang="nb" href="https://kapteins-daagbok.eu/?lng=nb" />
<link rel="alternate" hreflang="x-default" href="https://kapteins-daagbok.eu/" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
@@ -19,6 +22,7 @@
<meta name="apple-mobile-web-app-title" content="Daagbok" />
<meta name="theme-color" content="#0b0c10" />
<script src="/appearance-bootstrap.js"></script>
<script src="/bootstrap-watchdog.js"></script>
<link rel="apple-touch-icon" href="/logo.png" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Kapteins Daagbok" />
+23 -3
View File
@@ -3,15 +3,32 @@ server {
server_name localhost;
client_max_body_size 50M;
# Security headers (TLS/HSTS at NPM — see docs/deployment/npm-security.md)
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
# Service worker and app shell must revalidate so PWA updates are detected
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest)$ {
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location = /index.html {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, must-revalidate";
add_header Cache-Control "no-cache, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location / {
@@ -26,6 +43,9 @@ server {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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;
}
}
+13 -30
View File
@@ -12,11 +12,13 @@
"bip39": "^3.1.0",
"dexie": "^4.4.2",
"dexie-react-hooks": "^4.4.0",
"fflate": "^0.8.3",
"i18next": "^26.3.0",
"i18next-browser-languagedetector": "^8.2.1",
"jspdf": "^4.2.1",
"leaflet": "^1.9.4",
"lucide-react": "^1.16.0",
"qrcode": "^1.5.4",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.8"
@@ -25,6 +27,7 @@
"@eslint/js": "^10.0.1",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.12.3",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0",
@@ -34,7 +37,6 @@
"globals": "^17.6.0",
"happy-dom": "^20.9.0",
"playwright": "^1.51.0",
"qrcode": "^1.5.4",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^6.3.5",
@@ -2970,6 +2972,16 @@
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
@@ -3461,7 +3473,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -3471,7 +3482,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -3777,7 +3787,6 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -3855,7 +3864,6 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
@@ -3867,7 +3875,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -3880,7 +3887,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/commander": {
@@ -4051,7 +4057,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -4140,7 +4145,6 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"dev": true,
"license": "MIT"
},
"node_modules/dompurify": {
@@ -4195,7 +4199,6 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
@@ -4948,7 +4951,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -5498,7 +5500,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -6208,7 +6209,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -6231,7 +6231,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -6376,7 +6375,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.13.0"
@@ -6458,7 +6456,6 @@
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
@@ -6653,7 +6650,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -6673,7 +6669,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"dev": true,
"license": "ISC"
},
"node_modules/resolve": {
@@ -6845,7 +6840,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"dev": true,
"license": "ISC"
},
"node_modules/set-function-length": {
@@ -7113,7 +7107,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -7230,7 +7223,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -8067,7 +8059,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"dev": true,
"license": "ISC"
},
"node_modules/which-typed-array": {
@@ -8343,7 +8334,6 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
@@ -8380,7 +8370,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"dev": true,
"license": "ISC"
},
"node_modules/yallist": {
@@ -8394,7 +8383,6 @@
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
@@ -8417,7 +8405,6 @@
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
@@ -8431,7 +8418,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
@@ -8445,7 +8431,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
@@ -8458,7 +8443,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
@@ -8474,7 +8458,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
+9 -2
View File
@@ -10,18 +10,25 @@
"test": "vitest run",
"preview": "vite preview",
"generate:flyer": "node ../scripts/generate-beta-flyer.mjs",
"generate:flyer:setup": "playwright install chromium"
"generate:flyer:png": "node ../scripts/generate-beta-flyer.mjs --png",
"generate:flyer:all": "node ../scripts/generate-beta-flyer.mjs --all",
"generate:flyer:setup": "playwright install chromium",
"translate:locales": "node ../scripts/translate-locales.mjs",
"translate:flyer": "node ../scripts/translate-flyer.mjs",
"validate:i18n": "node ../scripts/validate-i18n-keys.mjs"
},
"dependencies": {
"@simplewebauthn/browser": "^13.3.0",
"bip39": "^3.1.0",
"dexie": "^4.4.2",
"dexie-react-hooks": "^4.4.0",
"fflate": "^0.8.3",
"i18next": "^26.3.0",
"i18next-browser-languagedetector": "^8.2.1",
"jspdf": "^4.2.1",
"leaflet": "^1.9.4",
"lucide-react": "^1.16.0",
"qrcode": "^1.5.4",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.8"
@@ -30,6 +37,7 @@
"@eslint/js": "^10.0.1",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.12.3",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0",
@@ -39,7 +47,6 @@
"globals": "^17.6.0",
"happy-dom": "^20.9.0",
"playwright": "^1.51.0",
"qrcode": "^1.5.4",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^6.3.5",
+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

+1658 -60
View File
File diff suppressed because it is too large Load Diff
+207 -47
View File
@@ -1,11 +1,14 @@
import { useState, useEffect, useCallback } from 'react'
import './App.css'
import { useState, useEffect, useCallback, useRef } from 'react'
import { DialogProvider } from './components/ModalDialog.tsx'
import AuthOnboarding from './components/AuthOnboarding.tsx'
import UserProfilePage from './components/UserProfilePage.tsx'
import LogbookDashboard from './components/LogbookDashboard.tsx'
import VesselForm from './components/VesselForm.tsx'
import CrewForm from './components/CrewForm.tsx'
import LogbookVesselPicker from './components/LogbookVesselPicker.tsx'
import LogbookCrewPicker from './components/LogbookCrewPicker.tsx'
import { migrateLegacyCrewToPoolIfNeeded } from './services/crewMigration.js'
import { migrateLegacyYachtsToPoolIfNeeded } from './services/vesselMigration.js'
import { syncVesselPool } from './services/vesselPoolSync.js'
import { syncPersonPool } from './services/personPoolSync.js'
// Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert (Komponente bleibt erhalten)
// import DeviationForm from './components/DeviationForm.tsx'
import LogEntriesList from './components/LogEntriesList.tsx'
@@ -29,6 +32,7 @@ import {
resolveColorScheme,
subscribeToSystemColorScheme
} from './services/appearance.js'
import { syncAppearancePrefs } from './services/appearancePrefs.js'
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
import DemoViewer from './components/DemoViewer.tsx'
@@ -44,20 +48,24 @@ import { useLiveQuery } from 'dexie-react-hooks'
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react'
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
import ProfileHeaderButton from './components/ProfileHeaderButton.tsx'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage } from './utils/i18nLanguages.js'
import {
getStoredDemoFirstEntryId,
resolveTourLogbookContext,
seedDemoLogbookIfNeeded
} from './services/demoLogbook.js'
import { fetchLogbooks, parseCollaborationRole } from './services/logbook.js'
import { ensurePushSubscriptionIfEnabled } from './services/pushNotifications.js'
import SyncConflictBanner from './components/SyncConflictBanner.tsx'
import { requestPersistentStorage } from './utils/storagePersist.js'
const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
function App() {
const { t, i18n } = useTranslation()
const { confirmLeave } = useUnsavedChangesContext()
const { registerNavigation, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
const { registerNavigation, registerDemoTourContext, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [activeLogbookId, setActiveLogbookId] = useState<string | null>(null)
const [activeLogbookTitle, setActiveLogbookTitle] = useState<string | null>(null)
@@ -69,6 +77,13 @@ function App() {
const [isSyncing, setIsSyncing] = useState(false)
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
const [showUserProfile, setShowUserProfile] = useState(false)
const [storagePersistHint, setStoragePersistHint] = useState(false)
const tourLogbookRef = useRef<{ id: string; title: string } | null>(null)
const activeLogbookRef = useRef<{ id: string | null; title: string | null }>({
id: activeLogbookId,
title: activeLogbookTitle
})
activeLogbookRef.current = { id: activeLogbookId, title: activeLogbookTitle }
// Viewer mode for read-only shared links
const [isViewerMode, setIsViewerMode] = useState(false)
@@ -88,7 +103,7 @@ function App() {
[activeLogbookId]
)
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>('OWNER')
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>(null)
useEffect(() => {
if (!activeLogbookId) {
@@ -145,6 +160,15 @@ function App() {
})
}, [])
useEffect(() => {
if (!isAuthenticated) return
const userId = localStorage.getItem('active_userid')
if (!userId) return
void syncAppearancePrefs(userId)
void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool())
void migrateLegacyYachtsToPoolIfNeeded().then(() => syncVesselPool())
}, [isAuthenticated])
useEffect(() => {
const handleOnline = () => {
setOnline(true)
@@ -309,28 +333,66 @@ function App() {
setIsAcceptingInvite(false)
}, [])
useEffect(() => {
registerNavigation({
setActiveTab,
setSelectedEntryId: setTourSelectedEntryId,
setFeedbackOpen: setTourFeedbackOpen
})
}, [registerNavigation])
useEffect(() => {
if (isAuthenticated && activeLogbookId) {
setDemoHighlightEntryId(getStoredDemoFirstEntryId())
}
}, [isAuthenticated, activeLogbookId])
const selectLogbook = (id: string, title: string) => {
const selectLogbook = useCallback((id: string, title: string) => {
setActiveLogbookId(id)
setActiveLogbookTitle(title)
setActiveTab('logs')
setTourSelectedEntryId(null)
localStorage.setItem('active_logbook_id', id)
localStorage.setItem('active_logbook_title', title)
}
}, [])
const ensureTourLogbookOpen = useCallback(async () => {
const ctx = await resolveTourLogbookContext(tourLogbookRef.current?.id)
if (!ctx) return
if (activeLogbookRef.current.id !== ctx.logbookId) {
selectLogbook(ctx.logbookId, ctx.title)
}
if (ctx.firstEntryId) {
setDemoHighlightEntryId(ctx.firstEntryId)
registerDemoTourContext({ firstEntryId: ctx.firstEntryId })
}
}, [registerDemoTourContext, selectLogbook])
useEffect(() => {
registerNavigation({
setActiveTab,
setSelectedEntryId: setTourSelectedEntryId,
setFeedbackOpen: setTourFeedbackOpen,
setProfileOpen: setShowUserProfile,
ensureLogbookForTour: ensureTourLogbookOpen,
setLogbookActive: (active) => {
if (active) {
void ensureTourLogbookOpen()
return
}
const { id, title } = activeLogbookRef.current
if (id && title) {
tourLogbookRef.current = { id, title }
}
setActiveLogbookId(null)
setActiveLogbookTitle(null)
setTourSelectedEntryId(null)
localStorage.removeItem('active_logbook_id')
localStorage.removeItem('active_logbook_title')
}
})
}, [ensureTourLogbookOpen, registerNavigation])
useEffect(() => {
if (!isAuthenticated || !activeLogbookId) return
void (async () => {
const ctx = await resolveTourLogbookContext()
if (!ctx || ctx.logbookId !== activeLogbookId) return
if (ctx.firstEntryId) {
setDemoHighlightEntryId(ctx.firstEntryId)
registerDemoTourContext({ firstEntryId: ctx.firstEntryId })
}
})()
}, [isAuthenticated, activeLogbookId, registerDemoTourContext])
const openLogbookById = useCallback(
async (logbookId: string) => {
@@ -346,7 +408,7 @@ function App() {
}
selectLogbook(logbookId, `${logbookId.slice(0, 8)}`)
},
[]
[selectLogbook]
)
const consumePendingPushLogbook = useCallback(() => {
@@ -375,10 +437,19 @@ function App() {
return () => navigator.serviceWorker.removeEventListener('message', onSwMessage)
}, [isAuthenticated, openLogbookById])
useEffect(() => {
if (!isAuthenticated) return
if (sessionStorage.getItem('storage_persist_hint_dismissed')) return
void requestPersistentStorage().then(({ persisted, supported }) => {
if (supported && !persisted) setStoragePersistHint(true)
})
}, [isAuthenticated])
const handleAuthenticated = async () => {
setIsAuthenticated(true)
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
void ensurePushSubscriptionIfEnabled()
void requestPersistentStorage()
try {
const demo = await seedDemoLogbookIfNeeded()
@@ -398,8 +469,20 @@ function App() {
const savedLogbookId = localStorage.getItem('active_logbook_id')
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
if (savedLogbookId && savedLogbookTitle) {
setActiveLogbookId(savedLogbookId)
setActiveLogbookTitle(savedLogbookTitle)
try {
const books = await fetchLogbooks()
const match = books.find((b) => b.id === savedLogbookId)
if (match) {
setActiveLogbookId(match.id)
setActiveLogbookTitle(match.title)
} else {
localStorage.removeItem('active_logbook_id')
localStorage.removeItem('active_logbook_title')
}
} catch {
setActiveLogbookId(savedLogbookId)
setActiveLogbookTitle(savedLogbookTitle)
}
}
consumePendingPushLogbook()
}
@@ -433,8 +516,7 @@ function App() {
}
const toggleLanguage = () => {
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
i18n.changeLanguage(nextLang)
cycleAppLanguage(i18n)
}
const handleExitDemo = () => {
@@ -492,24 +574,30 @@ function App() {
const logbookReadOnly =
activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ'
const isLogbookOwner =
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1
activeAccessRole === 'OWNER' ||
(activeLogbookRecord != null && activeLogbookRecord.isShared !== 1)
if (showUserProfile) {
return (
<div style={{ display: 'contents' }}>
{pwaInstallBanner}
<UserProfilePage
onBack={() => setShowUserProfile(false)}
onLogout={handleLogout}
/>
</div>
)
}
if (!activeLogbookId) {
return (
<div style={{ display: 'contents' }}>
{pwaInstallBanner}
{showUserProfile ? (
<UserProfilePage
onBack={() => setShowUserProfile(false)}
onLogout={handleLogout}
/>
) : (
<LogbookDashboard
onSelectLogbook={selectLogbook}
onLogout={handleLogout}
onOpenProfile={() => setShowUserProfile(true)}
/>
)}
<LogbookDashboard
onSelectLogbook={selectLogbook}
onLogout={handleLogout}
onOpenProfile={() => setShowUserProfile(true)}
/>
</div>
)
}
@@ -537,7 +625,7 @@ function App() {
<p className="app-subtitle">
{activeAccessRole && activeAccessRole !== 'OWNER'
? t('dashboard.section_shared_hint')
: `${t('app.name')} / ${activeLogbookId?.substring(0, 8)}...`}
: t('app.tagline')}
</p>
</div>
</div>
@@ -559,6 +647,8 @@ function App() {
<Languages size={18} />
</button>
<ProfileHeaderButton onClick={() => setShowUserProfile(true)} />
<DisclaimerHeaderButton />
<FeedbackHeaderButton
@@ -575,10 +665,28 @@ function App() {
</div>
</header>
<SyncConflictBanner logbookId={activeLogbookId} />
{storagePersistHint && (
<div className="storage-persist-hint glass" role="status">
<p>{t('pwa.storage_persist_hint')}</p>
<button
type="button"
className="btn secondary"
onClick={() => {
sessionStorage.setItem('storage_persist_hint_dismissed', '1')
setStoragePersistHint(false)
}}
>
{t('pwa.later')}
</button>
</div>
)}
{/* Active Workspace */}
<div className="app-body">
{/* Navigation Sidebar */}
<aside className="app-sidebar">
<aside className="app-sidebar" aria-label={t('nav.dashboard')}>
<button
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
onClick={() => void handleTabChange('logs')}
@@ -600,7 +708,7 @@ function App() {
<button
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => void handleTabChange('crew')}
data-tour="nav-crew"
data-tour="nav-logbook-crew"
>
<Users size={18} />
{t('nav.crew')}
@@ -647,14 +755,19 @@ function App() {
)}
{activeTab === 'vessel' && (
<VesselForm logbookId={activeLogbookId} readOnly={logbookReadOnly || !isLogbookOwner} />
<LogbookVesselPicker
logbookId={activeLogbookId}
readOnly={logbookReadOnly || !isLogbookOwner}
selectionOnly={!isLogbookOwner && activeLogbookRecord?.isShared === 1}
onOpenProfile={isLogbookOwner ? () => setShowUserProfile(true) : undefined}
/>
)}
{activeTab === 'crew' && (
<CrewForm
<LogbookCrewPicker
logbookId={activeLogbookId}
readOnly={logbookReadOnly}
skipperReadOnly={!isLogbookOwner}
selectionOnly={!isLogbookOwner && activeLogbookRecord?.isShared === 1}
/>
)}
@@ -675,6 +788,53 @@ function App() {
/>
)}
</main>
<nav className="app-bottom-nav" aria-label={t('nav.dashboard')}>
<button
type="button"
className={`bottom-nav-btn ${activeTab === 'logs' ? 'active' : ''}`}
onClick={() => void handleTabChange('logs')}
data-tour="nav-logs"
>
<FileText size={20} />
<span>{t('nav.logs')}</span>
</button>
<button
type="button"
className={`bottom-nav-btn ${activeTab === 'vessel' ? 'active' : ''}`}
onClick={() => void handleTabChange('vessel')}
data-tour="nav-vessel"
>
<Ship size={20} />
<span>{t('nav.vessel')}</span>
</button>
<button
type="button"
className={`bottom-nav-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => void handleTabChange('crew')}
data-tour="nav-logbook-crew"
>
<Users size={20} />
<span>{t('nav.crew')}</span>
</button>
<button
type="button"
className={`bottom-nav-btn ${activeTab === 'stats' ? 'active' : ''}`}
onClick={() => void handleTabChange('stats')}
data-tour="nav-stats"
>
<BarChart2 size={20} />
<span>{t('nav.stats')}</span>
</button>
<button
type="button"
className={`bottom-nav-btn ${activeTab === 'settings' ? 'active' : ''}`}
onClick={() => void handleTabChange('settings')}
>
<Settings size={20} />
<span>{t('nav.settings')}</span>
</button>
</nav>
</div>
</div>
</div>
+20
View File
@@ -1,8 +1,13 @@
import { Coffee } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'
const KOFI_URL = 'https://ko-fi.com/kapteinsdaagbok'
export default function AppFooter() {
const { t } = useTranslation()
return (
<footer className="app-version-footer">
<span className="app-version-footer__version">v{APP_VERSION}</span>
@@ -18,6 +23,21 @@ export default function AppFooter() {
Markus F.J. Busche
</a>
</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>
)
}
+71 -17
View File
@@ -4,6 +4,7 @@ import { X, ChevronLeft, ChevronRight } from 'lucide-react'
import {
getTourStepCopy,
getTourTargetSelector,
getTourTargetRetryDelay,
isCenteredTourStep,
useAppTour
} from '../context/AppTourContext.tsx'
@@ -17,6 +18,20 @@ interface SpotlightRect {
const TOOLTIP_EDGE_MARGIN = 16
const TOOLTIP_ESTIMATED_HEIGHT = 240
const TOOLTIP_WIDTH = 420
const TARGET_VIEWPORT_MARGIN = 24
function clampTooltipTop(preferred: number): number {
const maxTop = window.innerHeight - TOOLTIP_EDGE_MARGIN - TOOLTIP_ESTIMATED_HEIGHT
return Math.max(TOOLTIP_EDGE_MARGIN, Math.min(preferred, maxTop))
}
function computeTooltipLeft(spotlight: SpotlightRect): number {
const tooltipWidth = Math.min(TOOLTIP_WIDTH, window.innerWidth - TOOLTIP_EDGE_MARGIN * 2)
const ideal = spotlight.left + spotlight.width / 2 - tooltipWidth / 2
const maxLeft = window.innerWidth - TOOLTIP_EDGE_MARGIN - tooltipWidth
return Math.max(TOOLTIP_EDGE_MARGIN, Math.min(ideal, maxLeft))
}
function buildCutoutClipPath(rect: SpotlightRect): string {
const right = rect.left + rect.width
@@ -28,20 +43,36 @@ function computeTooltipTop(spotlight: SpotlightRect): number {
const viewportBottom = window.innerHeight - TOOLTIP_EDGE_MARGIN
const below = spotlight.top + spotlight.height + 12
if (below + TOOLTIP_ESTIMATED_HEIGHT <= viewportBottom) {
return below
return clampTooltipTop(below)
}
const above = spotlight.top - 12 - TOOLTIP_ESTIMATED_HEIGHT
if (above >= TOOLTIP_EDGE_MARGIN) {
return above
return clampTooltipTop(above)
}
return Math.max(
TOOLTIP_EDGE_MARGIN,
Math.min(below, viewportBottom - TOOLTIP_ESTIMATED_HEIGHT)
return clampTooltipTop(below)
}
function isTargetVisibleInViewport(rect: DOMRect): boolean {
return (
rect.top >= TARGET_VIEWPORT_MARGIN &&
rect.bottom <= window.innerHeight - TARGET_VIEWPORT_MARGIN
)
}
function measureSpotlight(el: Element): SpotlightRect | null {
const rect = el.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return null
const padding = 8
return {
top: Math.max(8, rect.top - padding),
left: Math.max(8, rect.left - padding),
width: rect.width + padding * 2,
height: rect.height + padding * 2
}
}
export default function AppTourOverlay() {
const { t } = useTranslation()
const {
@@ -50,6 +81,7 @@ export default function AppTourOverlay() {
currentStepId,
currentStepIndex,
totalSteps,
layoutTick,
nextStep,
prevStep,
skipTour
@@ -65,7 +97,10 @@ export default function AppTourOverlay() {
return
}
let cancelled = false
const updateSpotlight = () => {
if (cancelled) return
const selector = getTourTargetSelector(currentStepId)
if (!selector) {
setSpotlight(null)
@@ -76,27 +111,38 @@ export default function AppTourOverlay() {
setSpotlight(null)
return
}
const rect = el.getBoundingClientRect()
const padding = 8
setSpotlight({
top: Math.max(8, rect.top - padding),
left: Math.max(8, rect.left - padding),
width: rect.width + padding * 2,
height: rect.height + padding * 2
})
if (!isTargetVisibleInViewport(rect)) {
el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'nearest' })
window.requestAnimationFrame(() => {
if (cancelled) return
const next = measureSpotlight(el)
setSpotlight(next)
})
return
}
setSpotlight(measureSpotlight(el))
}
updateSpotlight()
window.addEventListener('resize', updateSpotlight)
window.addEventListener('scroll', updateSpotlight, true)
const timer = window.setTimeout(updateSpotlight, 120)
const retryDelays =
currentStepId === 'entry_track'
? [400, 700, 1100, 1600]
: [getTourTargetRetryDelay(currentStepId), 120, 280, 480]
const timers = retryDelays.map((delay) => window.setTimeout(updateSpotlight, delay))
return () => {
window.clearTimeout(timer)
cancelled = true
for (const timer of timers) window.clearTimeout(timer)
window.removeEventListener('resize', updateSpotlight)
window.removeEventListener('scroll', updateSpotlight, true)
}
}, [currentStepId, isActive])
}, [currentStepId, isActive, layoutTick])
useEffect(() => {
if (!isActive) return
@@ -132,9 +178,17 @@ export default function AppTourOverlay() {
const tooltipStyle = centered
? undefined
: spotlight
? { top: computeTooltipTop(spotlight) }
? { top: computeTooltipTop(spotlight), left: computeTooltipLeft(spotlight) }
: { top: '20%' }
const tooltipClassName = [
'app-tour-tooltip',
centered ? 'centered' : '',
!centered && spotlight ? 'app-tour-tooltip--anchored' : ''
]
.filter(Boolean)
.join(' ')
const backdropStyle = spotlight && !centered
? { clipPath: buildCutoutClipPath(spotlight) }
: undefined
@@ -159,7 +213,7 @@ export default function AppTourOverlay() {
/>
)}
<div className={`app-tour-tooltip${centered ? ' centered' : ''}`} style={tooltipStyle}>
<div className={tooltipClassName} style={tooltipStyle}>
<button type="button" className="app-tour-close" onClick={skipTour} aria-label={t('tour.skip')}>
<X size={18} />
</button>
+110 -31
View File
@@ -1,20 +1,28 @@
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
registerUser,
loginUser,
completeLoginWithRecovery,
setLocalPin,
hasLocalPin,
decryptWithLocalPin,
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import {
registerUser,
loginUser,
completeLoginWithRecovery,
setLocalPin,
hasLocalPin,
decryptWithLocalPin,
getActiveMasterKey,
getKnownUsernames,
forgetUsername
forgetUsername,
hasUnlockedLocalSession,
logoutUser
} from '../services/auth.js'
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
import DisclaimerModal from './DisclaimerModal.tsx'
import BetaBadge from './BetaBadge.tsx'
import {
isPasskeyCompatibleLocation,
localizeWebAuthnError,
toPasskeyCompatibleUrl
} from '../utils/passkeyHost.ts'
interface AuthOnboardingProps {
onAuthenticated: () => void
@@ -53,6 +61,16 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
const [showDisclaimer, setShowDisclaimer] = useState(false)
const [showHelp, setShowHelp] = useState(false)
const passkeyHostOk = isPasskeyCompatibleLocation()
const passkeyCompatibleUrl = passkeyHostOk ? null : toPasskeyCompatibleUrl(window.location.href)
const formatAuthError = (message: string) =>
localizeWebAuthnError(message, {
invalidHost: t('auth.error_invalid_host'),
cancelled: t('auth.error_passkey_cancelled'),
invalidRpId: t('auth.error_invalid_rp_id')
})
const finishAuth = () => {
if (isNewRegistration) {
setShowDisclaimer(true)
@@ -80,7 +98,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
setRecoveryPhrase(result.recoveryPhrase)
}
} catch (err: any) {
setError(err.message || 'Registration failed')
setError(formatAuthError(err.message || 'Registration failed'))
} finally {
setLoading(false)
}
@@ -120,7 +138,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
}
}
} catch (err: any) {
setError(err.message || 'Login failed')
setError(formatAuthError(err.message || 'Login failed'))
} finally {
setLoading(false)
}
@@ -184,19 +202,33 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
const handlePinLoginSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!pinLoginInput.trim()) return
if (!pinLoginInput.trim() || loading) return
const resolvedUser =
username.trim() ||
encryptedPayloads?.username ||
localStorage.getItem('active_username') ||
''
if (!resolvedUser) {
setError(t('auth.error_session_incomplete'))
return
}
setLoading(true)
setError(null)
try {
const resolvedUser = username.trim() || encryptedPayloads?.username
const key = await decryptWithLocalPin(pinLoginInput.trim(), resolvedUser)
if (key) {
onAuthenticated()
} else {
if (!key) {
setError(t('auth.error_incorrect_pin'))
return
}
} catch (err: any) {
if (!hasUnlockedLocalSession()) {
setError(t('auth.error_session_incomplete'))
return
}
setShowPinLogin(false)
onAuthenticated()
} catch {
setError(t('auth.error_incorrect_pin'))
} finally {
setLoading(false)
@@ -209,8 +241,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
}
const toggleLanguage = () => {
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
i18n.changeLanguage(nextLang)
cycleAppLanguage(i18n)
}
const copyToClipboard = () => {
@@ -361,6 +392,24 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
>
{t('auth.use_recovery_instead')}
</button>
<button
type="button"
className="btn secondary"
onClick={() => {
void (async () => {
setShowPinLogin(false)
setPinLoginInput('')
setEncryptedPayloads(null)
setError(null)
await logoutUser()
})()
}}
disabled={loading}
style={{ width: '100%' }}
>
{t('auth.back')}
</button>
</div>
</form>
</div>
@@ -379,16 +428,37 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
{t('auth.recovery_fallback_warning')}
</p>
<form onSubmit={handleRecoverySubmit} className="auth-form">
<textarea
className="input-textarea"
placeholder={t('auth.recovery_placeholder')}
value={recoveryInput}
onChange={(e) => setRecoveryInput(e.target.value)}
disabled={loading}
rows={3}
required
/>
<form onSubmit={handleRecoverySubmit} className="auth-form" autoComplete="on">
{(username.trim() || encryptedPayloads?.username) && (
<input
type="text"
name="username"
autoComplete="username"
value={username.trim() || encryptedPayloads?.username || ''}
readOnly
tabIndex={-1}
aria-hidden="true"
style={{ position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' }}
/>
)}
<div className="input-group">
<label htmlFor="recovery-key" className="input-label" style={{ display: 'block', marginBottom: '8px', color: '#94a3b8' }}>
{t('auth.enter_recovery')}
</label>
<input
id="recovery-key"
name="recovery-key"
type="password"
className="input-text"
placeholder={t('auth.recovery_placeholder')}
value={recoveryInput}
onChange={(e) => setRecoveryInput(e.target.value)}
disabled={loading}
required
autoComplete="current-password"
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
/>
</div>
{error && <div className="auth-error">{error}</div>}
@@ -424,12 +494,21 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
</div>
<div className="auth-form" style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: '20px' }}>
{!passkeyHostOk && passkeyCompatibleUrl && (
<div className="auth-error" role="alert">
<p style={{ margin: '0 0 8px' }}>{t('auth.error_invalid_host')}</p>
<a href={passkeyCompatibleUrl} className="btn secondary" style={{ display: 'inline-block', textDecoration: 'none' }}>
{t('auth.use_localhost_link')}
</a>
</div>
)}
{/* Prominent Login button */}
<button
type="button"
className="btn primary"
onClick={() => handleLogin()}
disabled={loading}
disabled={loading || !passkeyHostOk}
style={{ width: '100%', padding: '16px' }}
>
{loading
@@ -562,7 +641,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
<button
type="submit"
className="btn secondary"
disabled={loading || !username.trim()}
disabled={loading || !username.trim() || !passkeyHostOk}
style={{ width: '100%' }}
>
{t('auth.register')}
@@ -575,7 +654,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
<div className="auth-footer">
<button type="button" className="btn-icon-text" onClick={toggleLanguage}>
<Languages size={18} />
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
<button
type="button"
+114
View File
@@ -0,0 +1,114 @@
import React from 'react'
interface PersonSnapshot {
name: string
photo?: string | null
role?: string
}
interface CreatorAvatarProps {
creatorId?: string
crewSnapshotsById?: Record<string, PersonSnapshot>
fallbackName?: string
size?: number
}
const colors = [
'#2563eb', // blue
'#059669', // emerald
'#d97706', // amber
'#dc2626', // red
'#7c3aed', // violet
'#db2777', // pink
'#0891b2', // cyan
'#4f46e5', // indigo
'#0f766e', // teal
'#9333ea', // purple
]
function getAvatarColor(name: string): string {
let hash = 0
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash)
}
const index = Math.abs(hash) % colors.length
return colors[index]
}
export default function CreatorAvatar({
creatorId,
crewSnapshotsById,
fallbackName,
size = 28
}: CreatorAvatarProps) {
let name = ''
let photo: string | null = null
let role = ''
if (creatorId && crewSnapshotsById && crewSnapshotsById[creatorId]) {
const snap = crewSnapshotsById[creatorId]
name = snap.name || ''
photo = snap.photo || null
role = snap.role || ''
}
// Fallback to active username if owner or no crew pool matches
if (!name) {
if (creatorId === 'skipper') {
name = fallbackName || localStorage.getItem('active_username') || 'Skipper'
role = 'skipper'
} else if (fallbackName) {
name = fallbackName
} else if (creatorId) {
// If creatorId is a username itself (fallback from LiveLogView)
name = creatorId
} else {
name = '?'
}
}
const initial = name ? name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '?' : '?'
const bgColor = name === '?' ? '#64748b' : getAvatarColor(name)
const style: React.CSSProperties = {
width: `${size}px`,
height: `${size}px`,
borderRadius: '50%',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: `${Math.round(size * 0.45)}px`,
fontWeight: 'bold',
color: '#ffffff',
backgroundColor: bgColor,
flexShrink: 0,
verticalAlign: 'middle',
overflow: 'hidden',
border: '1px solid rgba(255, 255, 255, 0.15)',
boxSizing: 'border-box'
}
const roleText = role ? (role === 'skipper' ? 'Skipper' : 'Crew') : ''
const tooltip = name + (roleText ? ` (${roleText})` : '')
if (photo) {
return (
<img
src={photo}
alt={name}
title={tooltip}
style={{
...style,
objectFit: 'cover',
backgroundColor: 'transparent'
}}
/>
)
}
return (
<div style={style} title={tooltip} className="creator-avatar-fallback">
{initial}
</div>
)
}
+3 -2
View File
@@ -6,6 +6,7 @@ import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { MAX_POOL_CREW_MEMBERS } from '../types/person.js'
import { useDialog } from './ModalDialog.tsx'
import { Users, User, Plus, Trash2, Edit2, Save, X, Check, Camera } from 'lucide-react'
@@ -603,7 +604,7 @@ export default function CrewForm({
<Users size={24} className="form-icon" />
<h2>{t('crew.crew_section')}</h2>
</div>
{!readOnly && crewList.length < 5 && !showMemberForm && (
{!readOnly && crewList.length < MAX_POOL_CREW_MEMBERS && !showMemberForm && (
<button className="btn primary" onClick={openAddMember} style={{ width: 'auto', padding: '8px 16px' }}>
<Plus size={16} />
{t('crew.add_crew')}
@@ -817,7 +818,7 @@ export default function CrewForm({
<button className="btn-icon" onClick={() => openEditMember(m)} title="Edit">
<Edit2 size={14} />
</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} />
</button>
</div>
+52 -10
View File
@@ -1,10 +1,15 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import VesselForm from './VesselForm.tsx'
import CrewForm from './CrewForm.tsx'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
import type { LogbookCrewSelectionData } from '../types/person.js'
import { personToSnapshot } from '../utils/personSnapshots.js'
import LogEntriesList from './LogEntriesList.tsx'
import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react'
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
import type { VesselData } from '../types/vessel.js'
import type { LogbookVesselSelectionData } from '../types/vessel.js'
import { useAppTour, type AppTab } from '../context/AppTourContext.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
@@ -31,7 +36,9 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
registerNavigation({
setActiveTab,
setSelectedEntryId: setTourSelectedEntryId,
setFeedbackOpen: () => {}
setFeedbackOpen: () => {},
setLogbookActive: () => {},
setProfileOpen: () => {}
})
registerDemoTourContext({ firstEntryId: fixture.firstEntryId })
@@ -46,11 +53,32 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
}, [registerNavigation, registerDemoTourContext, startTour, fixture.firstEntryId])
const toggleLanguage = () => {
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
i18n.changeLanguage(nextLang)
cycleAppLanguage(i18n)
}
const { title, yacht, crews, entries, gpsTracks, photos, firstEntryId } = fixture
const {
title,
yacht,
vesselPool,
logbookVesselSelection,
personPool,
logbookCrewSelection,
entries,
gpsTracks,
photos,
firstEntryId
} = fixture
const demoSelection: LogbookCrewSelectionData = {
activeSkipperId: logbookCrewSelection.activeSkipperId,
activeCrewIds: logbookCrewSelection.activeCrewIds,
snapshotsById: Object.fromEntries(
Object.entries(logbookCrewSelection.snapshotsById).map(([id, snap]) => [
id,
personToSnapshot(id, snap)
])
)
}
return (
<div className="app-layout">
@@ -85,7 +113,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
</button>
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}>
<Globe size={14} style={{ marginRight: '4px' }} />
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
</div>
</header>
@@ -113,7 +141,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
<button
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => setActiveTab('crew')}
data-tour="nav-crew"
data-tour="nav-logbook-crew"
>
<Users size={18} />
{t('nav.crew')}
@@ -128,6 +156,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
preloadedYacht={yacht}
preloadedEntries={entries}
preloadedPhotos={photos}
preloadedVoiceMemos={[]}
preloadedGpsTracks={gpsTracks}
controlledSelectedEntryId={tourSelectedEntryId}
onSelectedEntryIdChange={setTourSelectedEntryId}
@@ -136,11 +165,24 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
)}
{activeTab === 'vessel' && (
<VesselForm logbookId="demo" readOnly={true} preloadedData={yacht} />
<LogbookVesselPicker
logbookId="demo"
readOnly={true}
preloadedPool={vesselPool.map((v) => ({
payloadId: v.payloadId,
data: v.data as VesselData
}))}
preloadedSelection={logbookVesselSelection as LogbookVesselSelectionData}
/>
)}
{activeTab === 'crew' && (
<CrewForm logbookId="demo" readOnly={true} preloadedData={crews} />
<LogbookCrewPicker
logbookId="demo"
readOnly={true}
preloadedPool={personPool}
preloadedSelection={demoSelection}
/>
)}
</main>
</div>
+3 -2
View File
@@ -6,6 +6,7 @@ import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { Compass, Save, Check } from 'lucide-react'
import { parseAppDecimalOrZero } from '../utils/numberFormat.js'
interface DeviationFormProps {
logbookId: string
@@ -97,8 +98,8 @@ export default function DeviationForm({ logbookId, readOnly = false, preloadedDa
const sanitizedDeviations: Record<number, number> = {}
headings.forEach((h) => {
const val = deviations[h] || ''
const parsed = parseFloat(val.replace('+', '').trim())
sanitizedDeviations[h] = isNaN(parsed) ? 0 : parsed
const parsed = parseAppDecimalOrZero(val.replace('+', '').trim())
sanitizedDeviations[h] = parsed
})
const dataToSave = {
+6 -1
View File
@@ -13,7 +13,12 @@ export default function DisclaimerModal({ open, onClose }: DisclaimerModalProps)
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
window.removeEventListener('keydown', onKeyDown)
document.body.style.overflow = prevOverflow
}
}, [open, onClose])
if (!open) return null
+168
View File
@@ -0,0 +1,168 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Users } from 'lucide-react'
import type { EntryCrewFields, PersonSnapshot } from '../types/person.js'
import { loadPersonPool } from '../services/personPool.js'
import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js'
import { buildSnapshotsForSelection } from '../utils/personSnapshots.js'
import type { PersonData } from '../types/person.js'
export interface EntryCrewSectionProps {
logbookId: string
readOnly?: boolean
value: EntryCrewFields
onChange: (next: EntryCrewFields) => void
/** Demo: fixed pool */
preloadedPool?: Map<string, PersonData>
}
export default function EntryCrewSection({
logbookId,
readOnly = false,
value,
onChange,
preloadedPool
}: EntryCrewSectionProps) {
const { t } = useTranslation()
const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map())
useEffect(() => {
if (preloadedPool) {
setPool(preloadedPool)
return
}
let cancelled = false
void (async () => {
try {
const people = await loadPersonPool()
if (cancelled) return
setPool(new Map(people.map((p) => [p.payloadId, p.data])))
} catch {
/* use snapshots only */
}
})()
return () => {
cancelled = true
}
}, [preloadedPool])
const displayPool = useMemo(() => {
const merged = new Map(pool)
for (const snap of Object.values(value.crewSnapshotsById)) {
if (!merged.has(snap.id)) {
merged.set(snap.id, {
name: snap.name,
address: snap.address,
birthDate: snap.birthDate,
phone: snap.phone,
nationality: snap.nationality,
passportNumber: snap.passportNumber,
bloodType: snap.bloodType,
allergies: snap.allergies,
diseases: snap.diseases,
role: snap.role,
photo: snap.photo
})
}
}
return merged
}, [pool, value.crewSnapshotsById])
const skippers = [...displayPool.entries()].filter(([, d]) => d.role === 'skipper')
const crewEntries = [...displayPool.entries()].filter(([, d]) => d.role === 'crew')
const applyChange = (skipperId: string | null, crewIds: string[]) => {
const snapshots = buildSnapshotsForSelection(skipperId, crewIds, displayPool)
onChange({
selectedSkipperId: skipperId,
selectedCrewIds: crewIds,
crewSnapshotsById: snapshots
})
}
const toggleCrew = (id: string) => {
if (readOnly) return
const next = value.selectedCrewIds.includes(id)
? value.selectedCrewIds.filter((x) => x !== id)
: [...value.selectedCrewIds, id]
applyChange(value.selectedSkipperId, next)
}
return (
<div className="form-card" data-tour="entry-crew">
<div className="form-header">
<Users size={22} className="form-icon" />
<h3>{t('entry_crew.title')}</h3>
</div>
<p className="help-text mb-3">{t('entry_crew.subtitle')}</p>
<div className="input-group mb-3">
<label>{t('entry_crew.day_skipper')}</label>
{skippers.length === 0 ? (
<p className="help-text">{t('entry_crew.no_skipper')}</p>
) : (
<div className="crew-selection-list">
{skippers.map(([id, data]) => (
<label key={id} className="crew-selection-item">
<input
type="radio"
name={`entry-skipper-${logbookId}`}
checked={value.selectedSkipperId === id}
onChange={() => !readOnly && applyChange(id, value.selectedCrewIds)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)}
</div>
<div className="input-group">
<label>{t('entry_crew.day_crew')}</label>
{crewEntries.length === 0 ? (
<p className="help-text">{t('entry_crew.no_crew')}</p>
) : (
<div className="crew-selection-list">
{crewEntries.map(([id, data]) => (
<label key={id} className="crew-selection-item">
<input
type="checkbox"
checked={value.selectedCrewIds.includes(id)}
onChange={() => toggleCrew(id)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)}
</div>
</div>
)
}
export async function loadDefaultEntryCrewForNewDay(
logbookId: string,
previousEntry: Record<string, unknown> | null
): Promise<EntryCrewFields> {
if (previousEntry) {
const selectedSkipperId =
typeof previousEntry.selectedSkipperId === 'string' ? previousEntry.selectedSkipperId : null
const selectedCrewIds = Array.isArray(previousEntry.selectedCrewIds)
? previousEntry.selectedCrewIds.filter((id): id is string => typeof id === 'string')
: []
const crewSnapshotsById =
previousEntry.crewSnapshotsById && typeof previousEntry.crewSnapshotsById === 'object'
? (previousEntry.crewSnapshotsById as Record<string, PersonSnapshot>)
: {}
return { selectedSkipperId, selectedCrewIds, crewSnapshotsById }
}
const selection = await loadLogbookCrewSelection(logbookId)
return {
selectedSkipperId: selection.activeSkipperId,
selectedCrewIds: [...selection.activeCrewIds],
crewSnapshotsById: { ...selection.snapshotsById }
}
}
@@ -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 { 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 MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'))
@@ -18,7 +19,29 @@ export default function EventTimeInput24h({
'aria-label': ariaLabel
}: EventTimeInput24hProps) {
const baseId = useId()
const useNativePicker = preferNativeCameraPicker()
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 (
<div className="time-input-24h">
+1
View File
@@ -172,6 +172,7 @@ export default function FeedbackModal({
<option value="general">{t('feedback.category_general')}</option>
<option value="bug">{t('feedback.category_bug')}</option>
<option value="feature">{t('feedback.category_feature')}</option>
<option value="translation">{t('feedback.category_translation')}</option>
</select>
</label>
+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>
)
}
+33 -11
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight, KeyRound } from 'lucide-react'
import {
getActiveMasterKey,
@@ -308,7 +309,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
}
const toggleLanguage = () => {
i18n.changeLanguage(i18n.language.startsWith('de') ? 'en' : 'de')
cycleAppLanguage(i18n)
}
if (recoveryPhrase) {
@@ -344,15 +345,36 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
<h2>{t('auth.enter_recovery')}</h2>
</div>
<p className="recovery-warning">{t('auth.recovery_fallback_warning')}</p>
<form onSubmit={handleRecoverySubmit}>
<textarea
className="input-text"
placeholder={t('auth.recovery_placeholder')}
value={recoveryInput}
onChange={(e) => setRecoveryInput(e.target.value)}
rows={3}
required
/>
<form onSubmit={handleRecoverySubmit} autoComplete="on">
{(username.trim() || encryptedPayloads?.username) && (
<input
type="text"
name="username"
autoComplete="username"
value={username.trim() || encryptedPayloads?.username || ''}
readOnly
tabIndex={-1}
aria-hidden="true"
style={{ position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' }}
/>
)}
<div className="input-group">
<label htmlFor="invitation-recovery-key" className="input-label" style={{ display: 'block', marginBottom: '8px', color: '#94a3b8' }}>
{t('auth.enter_recovery')}
</label>
<input
id="invitation-recovery-key"
name="recovery-key"
type="password"
className="input-text"
placeholder={t('auth.recovery_placeholder')}
value={recoveryInput}
onChange={(e) => setRecoveryInput(e.target.value)}
required
autoComplete="current-password"
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
/>
</div>
<div className="auth-actions mt-4">
<button type="button" className="btn secondary" onClick={() => setShowRecoveryFallback(false)}>
{t('auth.back')}
@@ -490,7 +512,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
<div className="auth-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '16px', marginTop: '24px' }}>
<button className="btn-icon-text" onClick={toggleLanguage}>
<Languages size={18} />
{i18n.language.startsWith('de') ? t('invitation.switch_language_en') : t('invitation.switch_language_de')}
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
</div>
</div>
+54
View File
@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import QRCode from 'qrcode'
interface LinkQrCodeProps {
value: string
size?: number
}
export default function LinkQrCode({ value, size = 200 }: LinkQrCodeProps) {
const { t } = useTranslation()
const [dataUrl, setDataUrl] = useState<string | null>(null)
useEffect(() => {
if (!value.trim()) {
setDataUrl(null)
return
}
let cancelled = false
void QRCode.toDataURL(value, {
width: size,
margin: 2,
errorCorrectionLevel: 'M',
color: { dark: '#0f172a', light: '#ffffff' }
})
.then((url) => {
if (!cancelled) setDataUrl(url)
})
.catch((err) => {
console.error('QR code generation failed:', err)
if (!cancelled) setDataUrl(null)
})
return () => {
cancelled = true
}
}, [value, size])
if (!value.trim() || !dataUrl) return null
return (
<div className="link-qr-block">
<p className="link-qr-label">{t('settings.link_qr_hint')}</p>
<img
src={dataUrl}
width={size}
height={size}
className="link-qr-image"
alt={t('settings.link_qr_alt')}
/>
</div>
)
}
+353
View File
@@ -0,0 +1,353 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Camera, X } from 'lucide-react'
import {
cameraErrorKeyFromDomException,
probeCameraAvailability
} from '../utils/cameraAvailability.js'
import {
captureVideoFrame,
preferNativeCameraPicker
} from '../utils/captureVideoFrame.js'
interface LiveCameraCaptureProps {
open: boolean
busy?: boolean
caption?: string
onCaptionChange?: (value: string) => void
onClose: () => void
onCapture: (blob: Blob) => void
}
type Phase = 'checking' | 'live' | 'preview' | 'native'
export default function LiveCameraCapture({
open,
busy = false,
caption = '',
onCaptionChange,
onClose,
onCapture
}: LiveCameraCaptureProps) {
const { t } = useTranslation()
const videoRef = useRef<HTMLVideoElement | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const previewUrlRef = useRef<string | null>(null)
const [cameraError, setCameraError] = useState<string | null>(null)
const [ready, setReady] = useState(false)
const [capturing, setCapturing] = useState(false)
const [phase, setPhase] = useState<Phase>('checking')
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
const [streamGeneration, setStreamGeneration] = useState(0)
const clearPreview = useCallback(() => {
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current)
previewUrlRef.current = null
}
setPreviewUrl(null)
setPreviewBlob(null)
}, [])
const stopStream = useCallback(() => {
for (const track of streamRef.current?.getTracks() ?? []) {
track.stop()
}
streamRef.current = null
if (videoRef.current) {
videoRef.current.srcObject = null
}
setReady(false)
}, [])
const enterPreview = useCallback((blob: Blob) => {
stopStream()
clearPreview()
const url = URL.createObjectURL(blob)
previewUrlRef.current = url
setPreviewBlob(blob)
setPreviewUrl(url)
setPhase('preview')
}, [stopStream, clearPreview])
const resetToLive = useCallback(() => {
clearPreview()
setCameraError(null)
setCapturing(false)
if (preferNativeCameraPicker()) {
setPhase('native')
} else {
setPhase('live')
setStreamGeneration((n) => n + 1)
}
}, [clearPreview])
useEffect(() => {
if (!open) {
stopStream()
clearPreview()
setCameraError(null)
setCapturing(false)
setPhase('checking')
return
}
let cancelled = false
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(() => {
if (!open || phase !== 'live') {
stopStream()
return
}
let cancelled = false
const start = async () => {
setCameraError(null)
setReady(false)
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: { ideal: 'environment' },
width: { ideal: 1280 },
height: { ideal: 720 }
},
audio: false
})
if (cancelled) {
for (const track of stream.getTracks()) track.stop()
return
}
streamRef.current = stream
const video = videoRef.current
if (!video) return
const markReady = () => {
if (cancelled) return
if (video.videoWidth > 0 && video.videoHeight > 0) {
setReady(true)
}
}
video.onloadedmetadata = markReady
video.srcObject = stream
await video.play()
markReady()
} catch (err) {
console.error('Camera access failed:', err)
if (!cancelled) {
setCameraError(t(cameraErrorKeyFromDomException(err)))
}
}
}
void start()
return () => {
cancelled = true
stopStream()
}
}, [open, phase, streamGeneration, stopStream, t])
const handleCapture = async () => {
const video = videoRef.current
if (!video || !ready || busy || capturing) return
setCapturing(true)
setCameraError(null)
try {
const blob = await captureVideoFrame(video)
enterPreview(blob)
} catch (err) {
console.error('Live camera capture failed:', err)
setCameraError(t('logs.live_photo_capture_failed'))
} finally {
setCapturing(false)
}
}
const handleNativeFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ''
if (!file || busy) return
setCameraError(null)
try {
enterPreview(file)
} catch (err) {
console.error('Live camera file pick failed:', err)
setCameraError(t('logs.live_photo_capture_failed'))
}
}
const handleSave = () => {
if (!previewBlob || busy) return
onCapture(previewBlob)
}
const handleRetake = () => {
if (busy) return
resetToLive()
}
const openNativePicker = () => {
if (busy) return
fileInputRef.current?.click()
}
if (!open) return null
const showPreview = phase === 'preview' && previewUrl
return (
<div
className="live-log-modal-backdrop live-camera-backdrop"
onClick={(e) => { if (e.target === e.currentTarget && !busy) onClose() }}
>
<div className="live-log-modal live-camera-modal" onClick={(e) => e.stopPropagation()}>
<div className="live-camera-header">
<h3>{t('logs.live_photo_btn')}</h3>
<button
type="button"
className="btn secondary live-camera-close"
onClick={onClose}
disabled={busy}
aria-label={t('logs.live_cancel')}
>
<X size={18} />
</button>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
capture="environment"
className="live-camera-file-input"
onChange={(e) => void handleNativeFile(e)}
/>
{cameraError && (
<p className="live-log-modal-hint auth-error">{cameraError}</p>
)}
{showPreview ? (
<div className="live-camera-preview-wrap">
<img
src={previewUrl}
alt=""
className="live-camera-preview live-camera-preview-still"
/>
</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' ? (
<div className="live-camera-native-prompt">
<p className="live-log-modal-hint">{t('logs.live_photo_native_hint')}</p>
<button
type="button"
className="btn primary live-camera-open-native"
onClick={openNativePicker}
disabled={busy}
>
<Camera size={18} />
{t('logs.live_photo_open_camera_btn')}
</button>
</div>
) : phase === 'live' ? (
<div className="live-camera-preview-wrap">
<video
ref={videoRef}
className="live-camera-preview"
playsInline
muted
autoPlay
/>
{!ready && (
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
)}
</div>
) : null}
{onCaptionChange && (
<div className="input-group live-camera-caption">
<label>{t('logs.photo_caption_label')}</label>
<input
type="text"
className="input-text"
placeholder={t('logs.photo_caption_placeholder')}
value={caption}
onChange={(e) => onCaptionChange(e.target.value)}
disabled={busy}
/>
</div>
)}
<div className="live-log-modal-actions live-camera-actions">
<button type="button" className="btn secondary" onClick={onClose} disabled={busy}>
{t('logs.live_cancel')}
</button>
{showPreview ? (
<>
<button type="button" className="btn secondary" onClick={handleRetake} disabled={busy}>
{t('logs.live_photo_retake_btn')}
</button>
<button
type="button"
className="btn primary live-camera-shutter"
onClick={handleSave}
disabled={busy || !previewBlob}
>
<Camera size={18} />
{busy ? t('logs.photo_processing') : t('logs.live_photo_save_btn')}
</button>
</>
) : phase === 'native' ? null : (
<button
type="button"
className="btn primary live-camera-shutter"
onClick={() => void handleCapture()}
disabled={busy || capturing || !ready || !!cameraError}
>
<Camera size={18} />
{capturing ? t('logs.photo_processing') : t('logs.live_photo_capture_btn')}
</button>
)}
</div>
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
+379
View File
@@ -0,0 +1,379 @@
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 log = useCallback((msg: string) => {
console.log(`[VoiceDebug] ${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)
}
log('Calling el.load() to force loading of the media resource...')
el.load()
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>
</>
)}
</div>
</div>
)
}
+159 -44
View File
@@ -3,16 +3,26 @@ 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, encryptJson } from '../services/crypto.js'
import { encryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { downloadCsv, shareCsv } from '../services/csvExport.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js'
import { findTodayEntryId, pruneEmptyTodayDuplicates, tryDecryptEntryPayload } from '../services/quickEventLog.js'
import { localDateString } from '../utils/logEntryPayload.js'
import LogEntryEditor from './LogEntryEditor.tsx'
import LiveLogView from './LiveLogView.tsx'
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
import { useDialog } from './ModalDialog.tsx'
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react'
import {
buildEntryListCache,
entryListItemFromLocal,
putEntryRecord
} from '../utils/entryListCache.js'
import { forEachInBatches } from '../utils/yieldToMain.js'
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react'
import {
carryOverFromPreviousDay,
compareTravelDaysChronological,
@@ -30,12 +40,15 @@ interface LogEntriesListProps {
preloadedYacht?: any
preloadedEntries?: any[]
preloadedPhotos?: any[]
preloadedVoiceMemos?: import('./VoiceMemoPlayer.tsx').PreloadedVoiceMemo[]
preloadedGpsTracks?: any[]
controlledSelectedEntryId?: string | null
onSelectedEntryIdChange?: (id: string | null) => void
highlightEntryId?: string | null
}
type LogsViewMode = 'list' | 'live'
interface DecryptedEntryItem {
id: string
date: string
@@ -52,6 +65,7 @@ export default function LogEntriesList({
preloadedYacht,
preloadedEntries,
preloadedPhotos,
preloadedVoiceMemos,
preloadedGpsTracks,
controlledSelectedEntryId,
onSelectedEntryIdChange,
@@ -75,6 +89,8 @@ export default function LogEntriesList({
const [loading, setLoading] = useState(false)
const [exporting, setExporting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<LogsViewMode>('list')
const [returnToLiveAfterEditor, setReturnToLiveAfterEditor] = useState(false)
const prevSelectedEntryIdRef = useRef<string | null | undefined>(undefined)
const loadEntries = useCallback(async () => {
@@ -108,25 +124,40 @@ export default function LogEntriesList({
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const todayEntryId = await findTodayEntryId(logbookId)
if (todayEntryId) {
await pruneEmptyTodayDuplicates(logbookId, todayEntryId)
}
const local = await db.entries.where({ logbookId }).toArray()
const list: DecryptedEntryItem[] = []
const needsDecrypt: typeof local = []
for (const entry of local) {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
if (decrypted) {
list.push({
id: entry.payloadId,
date: decrypted.date || '',
dayOfTravel: decrypted.dayOfTravel || '',
departure: decrypted.departure || '',
destination: decrypted.destination || '',
updatedAt: entry.updatedAt,
skipperSignStatus: await getSkipperSignStatus(decrypted as Record<string, unknown>)
})
const cached = entryListItemFromLocal(entry)
if (cached) {
list.push(cached)
} else {
needsDecrypt.push(entry)
}
}
await forEachInBatches(needsDecrypt, 8, async (entry) => {
const decrypted = await 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)
list.sort((a, b) => {
const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime()
@@ -137,24 +168,26 @@ export default function LogEntriesList({
setEntries(list)
} catch (err: any) {
console.error('Failed to load log entries:', err)
setError(err.message || 'Decryption failed. Could not load journal list.')
setError(getErrorMessage(err, t('errors.load_failed')))
} finally {
setLoading(false)
}
}, [logbookId, readOnly, preloadedEntries])
useEffect(() => {
if (viewMode === 'live') return
loadEntries()
}, [loadEntries])
}, [loadEntries, viewMode])
useEffect(() => {
if (viewMode === 'live') return
const prevSelectedEntryId = prevSelectedEntryIdRef.current
prevSelectedEntryIdRef.current = selectedEntryId
if (prevSelectedEntryId !== undefined && prevSelectedEntryId !== null && selectedEntryId === null) {
loadEntries()
}
}, [selectedEntryId, loadEntries])
}, [selectedEntryId, loadEntries, viewMode])
const handleDownloadCsv = async () => {
setExporting(true)
@@ -169,7 +202,7 @@ export default function LogEntriesList({
trackPlausibleEvent(PlausibleEvents.CSV_EXPORTED)
} catch (err: any) {
console.error('Failed to download CSV:', err)
setError(err.message || 'Failed to generate CSV export.')
setError(getErrorMessage(err, t('errors.export_failed')))
} finally {
setExporting(false)
}
@@ -197,7 +230,7 @@ export default function LogEntriesList({
setError(t('logs.share_unsupported'))
} else {
console.error('Failed to share CSV:', err)
setError(err.message || 'Failed to share CSV export.')
setError(getErrorMessage(err, t('errors.export_failed')))
}
} finally {
setExporting(false)
@@ -218,7 +251,7 @@ export default function LogEntriesList({
trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
} catch (err: any) {
console.error('Failed to download PDF:', err)
setError(err.message || 'Failed to generate PDF export.')
setError(getErrorMessage(err, t('errors.export_failed')))
} finally {
setExporting(false)
}
@@ -231,24 +264,31 @@ export default function LogEntriesList({
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const existingTodayId = await findTodayEntryId(logbookId)
if (existingTodayId) {
setSelectedEntryId(existingTodayId)
return
}
const localEntries = await db.entries.where({ logbookId }).toArray()
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
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)
}
decryptedEntries.sort(compareTravelDaysChronological)
const previousEntry = decryptedEntries.at(-1) ?? null
let { freshwater, fuel, departure } = carryOverFromPreviousDay(previousEntry)
let { freshwater, fuel, greywaterLevel, departure } = carryOverFromPreviousDay(previousEntry)
if (previousEntry && hasCarryOverFromPreviousDay({ freshwater, fuel, departure })) {
if (previousEntry && hasCarryOverFromPreviousDay({ freshwater, fuel, greywaterLevel, departure })) {
const confirmed = await showConfirm(
t('logs.carry_over_tanks_confirm', {
departure: departure || '—',
fw: formatTankLiters(freshwater.morning),
fuel: formatTankLiters(fuel.morning)
fuel: formatTankLiters(fuel.morning),
greywater: formatTankLiters(greywaterLevel)
}),
t('logs.carry_over_tanks_title'),
t('logs.carry_over_tanks_yes'),
@@ -257,6 +297,7 @@ export default function LogEntriesList({
if (!confirmed) {
freshwater = emptyTankLevels()
fuel = emptyTankLevels()
greywaterLevel = 0
departure = ''
}
}
@@ -265,7 +306,13 @@ export default function LogEntriesList({
const localId = window.crypto.randomUUID()
const nowStr = new Date().toISOString()
const todayStr = nowStr.substring(0, 10)
const todayStr = localDateString()
const { loadDefaultEntryCrewForNewDay } = await import('./EntryCrewSection.js')
const entryCrew = await loadDefaultEntryCrewForNewDay(
logbookId,
previousEntry as Record<string, unknown> | null
)
const initialPayload = {
date: todayStr,
@@ -274,6 +321,10 @@ export default function LogEntriesList({
destination: '',
freshwater,
fuel,
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
selectedSkipperId: entryCrew.selectedSkipperId,
selectedCrewIds: entryCrew.selectedCrewIds,
crewSnapshotsById: entryCrew.crewSnapshotsById,
signSkipper: '',
signCrew: '',
events: []
@@ -282,14 +333,17 @@ export default function LogEntriesList({
const encrypted = await encryptJson(initialPayload, masterKey)
// Save locally
await db.entries.put({
payloadId: localId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: nowStr
})
await putEntryRecord(
{
payloadId: localId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: nowStr
},
initialPayload
)
// Queue for background sync
await db.syncQueue.put({
@@ -307,7 +361,7 @@ export default function LogEntriesList({
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
console.error('Failed to create entry:', err)
setError(err.message || 'Failed to create new log entry.')
setError(getErrorMessage(err, t('errors.save_failed')))
} finally {
setLoading(false)
}
@@ -337,7 +391,7 @@ export default function LogEntriesList({
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
console.error('Failed to delete log entry:', err)
setError(err.message || 'Failed to delete log entry.')
setError(getErrorMessage(err, t('errors.delete_failed')))
}
}
}
@@ -347,15 +401,38 @@ export default function LogEntriesList({
<LogEntryEditor
entryId={selectedEntryId}
logbookId={logbookId}
onBack={() => setSelectedEntryId(null)}
onBack={() => {
setSelectedEntryId(null)
if (returnToLiveAfterEditor) {
setViewMode('live')
setReturnToLiveAfterEditor(false)
}
}}
readOnly={readOnly}
preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)}
preloadedPhotos={preloadedPhotos}
preloadedVoiceMemos={preloadedVoiceMemos}
preloadedTrack={preloadedGpsTracks?.find(track => track.entryId === selectedEntryId)}
/>
)
}
if (viewMode === 'live' && !readOnly) {
return (
<LiveLogView
logbookId={logbookId}
onOpenEditor={(entryId) => {
setReturnToLiveAfterEditor(true)
setSelectedEntryId(entryId)
}}
onSwitchToList={() => {
setViewMode('list')
void loadEntries()
}}
/>
)
}
if (loading) {
return (
<div className="tab-placeholder">
@@ -365,14 +442,42 @@ export default function LogEntriesList({
)
}
const tourFirstEntryId =
highlightEntryId && entries.some((e) => e.id === highlightEntryId)
? highlightEntryId
: entries[0]?.id ?? null
return (
<div className="form-card">
<div className="logs-journal">
<div className="section-title-bar mb-6">
<div className="form-header" style={{ margin: 0 }}>
<Calendar size={24} className="form-icon" />
<h2>{t('logs.title')}</h2>
</div>
<div className="section-toolbar">
{!readOnly && (
<div className="logs-view-toggle" role="group" aria-label={t('logs.view_mode_label')}>
<button
type="button"
className={`btn secondary logs-view-toggle-btn ${viewMode === 'list' ? 'is-active' : ''}`}
onClick={() => setViewMode('list')}
title={t('logs.view_list')}
>
<List size={16} />
<span className="hide-mobile">{t('logs.view_list')}</span>
</button>
<button
type="button"
className={`btn secondary logs-view-toggle-btn ${viewMode === 'live' ? 'is-active' : ''}`}
onClick={() => setViewMode('live')}
title={t('logs.live_mode')}
>
<Radio size={16} />
<span className="hide-mobile">{t('logs.live_mode')}</span>
</button>
</div>
)}
<button className="btn secondary" onClick={handleDownloadCsv} disabled={loading || exporting || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.export_csv')}>
<Download size={16} />
<span className="hide-mobile">{exporting ? t('logs.exporting') : t('logs.export_csv')}</span>
@@ -402,10 +507,20 @@ export default function LogEntriesList({
<div
key={item.id}
className="logbook-card glass"
data-tour={highlightEntryId === item.id ? 'entry-first' : undefined}
onClick={() => setSelectedEntryId(item.id)}
data-tour={tourFirstEntryId === item.id ? 'entry-first' : undefined}
>
<div className="card-icon">
<button
type="button"
className="logbook-card-select"
onClick={() => setSelectedEntryId(item.id)}
aria-label={
item.departure && item.destination
? `${item.departure}${item.destination}, ${t('logs.travel_day_number', { number: item.dayOfTravel })}`
: `${t('logs.new_entry')}, ${t('logs.travel_day_number', { number: item.dayOfTravel })}`
}
/>
<div className="card-icon" aria-hidden>
<FileText size={24} />
</div>
@@ -417,7 +532,7 @@ export default function LogEntriesList({
</h3>
<div className="card-meta">
<span className="sync-badge synced">
{t('logs.day_of_travel')} {item.dayOfTravel}
{t('logs.travel_day_number', { number: item.dayOfTravel })}
</span>
<EntrySkipperSignBadge status={item.skipperSignStatus} />
<span className="date-badge">
@@ -426,6 +541,8 @@ export default function LogEntriesList({
</div>
</div>
<ChevronRight size={18} className="logbook-card-chevron" aria-hidden />
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
<Download size={18} />
</button>
@@ -435,8 +552,6 @@ export default function LogEntriesList({
<Trash2 size={18} />
</button>
)}
<ChevronRight size={18} style={{ color: '#475569', marginLeft: 'auto' }} />
</div>
))}
</div>
File diff suppressed because it is too large Load Diff
+62 -10
View File
@@ -5,10 +5,12 @@ import { useDialog } from './ModalDialog.tsx'
import {
downloadBackupBlob,
exportLogbookBackup,
formatBackupBytes,
parseLogbookBackupFile,
previewLogbookBackup,
restoreLogbookBackup,
type LogbookBackupFile,
BACKUP_SIZE_CONFIRM_BYTES,
type ParsedLogbookBackup,
type LogbookBackupPreview
} from '../services/logbookBackup.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')
case 'BACKUP_INVALID_JSON':
return t('settings.backup_invalid_json')
case 'BACKUP_INVALID_ARCHIVE':
return t('settings.backup_invalid_archive')
case 'BACKUP_VERSION_UNSUPPORTED':
return t('settings.backup_version_unsupported')
case 'BACKUP_WRONG_PASSPHRASE':
return t('settings.backup_wrong_passphrase')
case 'BACKUP_INVALID_FORMAT':
return t('settings.backup_invalid_format')
case 'BACKUP_NOT_AUTHENTICATED':
@@ -53,12 +61,16 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
const [importPassphrase, setImportPassphrase] = useState('')
const [importFile, setImportFile] = useState<File | 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 [previewing, setPreviewing] = useState(false)
const [exportProgress, setExportProgress] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const exportPassphrasesMatch =
exportPassphrase.length >= 8 && exportPassphrase === exportConfirm
const handleExportSubmit = async (e: React.FormEvent) => {
e.preventDefault()
await handleExport()
@@ -83,21 +95,36 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
}
setExporting(true)
setExportProgress(null)
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)
setSuccess(t('settings.backup_export_success', { count: backup.counts.entries }))
setSuccess(t('settings.backup_export_success', { count: manifest.counts.entries }))
setExportPassphrase('')
setExportConfirm('')
trackPlausibleEvent(PlausibleEvents.BACKUP_EXPORTED, {
entries: backup.counts.entries,
photos: backup.counts.photos
entries: manifest.counts.entries,
photos: manifest.counts.photos,
voiceMemos: manifest.counts.voiceMemos,
bytes: manifest.totalUncompressedBytes
})
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
setError(mapBackupError(message, t))
} finally {
setExporting(false)
setExportProgress(null)
}
}
@@ -138,6 +165,18 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => {
if (!parsedBackup || !importPassphrase) return
if (parsedBackup.manifest.totalUncompressedBytes > BACKUP_SIZE_CONFIRM_BYTES) {
const ok = await showConfirm(
t('settings.backup_import_size_confirm', {
size: formatBackupBytes(parsedBackup.manifest.totalUncompressedBytes)
}),
t('settings.backup_restore_title'),
t('logs.confirm_yes'),
t('logs.confirm_no')
)
if (!ok) return
}
setImporting(true)
setError(null)
try {
@@ -149,8 +188,10 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
setParsedBackup(null)
if (fileInputRef.current) fileInputRef.current.value = ''
trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, {
entries: parsedBackup.counts.entries,
photos: parsedBackup.counts.photos,
entries: parsedBackup.manifest.counts.entries,
photos: parsedBackup.manifest.counts.photos,
voiceMemos: parsedBackup.manifest.counts.voiceMemos,
bytes: parsedBackup.manifest.totalUncompressedBytes,
mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id'
})
onRestored?.(result.logbookId, result.title)
@@ -253,11 +294,16 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
<button
type="submit"
className="btn primary"
disabled={exporting || !exportPassphrase || !exportConfirm}
disabled={exporting || !exportPassphrasesMatch}
>
<Download size={16} />
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')}
</button>
{exportProgress && (
<p className="text-muted backup-export-progress" role="status">
{exportProgress}
</p>
)}
</form>
</section>
@@ -275,7 +321,7 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
id="backup-import-file"
ref={fileInputRef}
type="file"
accept=".daagbok.json,application/json"
accept=".daagbok,application/zip"
className="input-text"
onChange={handleFileChange}
disabled={importing}
@@ -330,8 +376,14 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
<ul className="backup-preview-stats">
<li>{t('settings.backup_stat_entries', { count: importPreview.counts.entries })}</li>
<li>{t('settings.backup_stat_photos', { count: importPreview.counts.photos })}</li>
<li>{t('settings.backup_stat_voice', { count: importPreview.counts.voiceMemos })}</li>
<li>{t('settings.backup_stat_crew', { count: importPreview.counts.crews })}</li>
<li>{t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}</li>
<li className="text-muted">
{t('settings.backup_stat_size', {
size: formatBackupBytes(importPreview.totalUncompressedBytes)
})}
</li>
</ul>
<p className="text-muted backup-preview-date">
{t('settings.backup_exported_at', {
+224
View File
@@ -0,0 +1,224 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Users, User, Save, Check } from 'lucide-react'
import type { LogbookCrewSelectionData, PersonSnapshot } from '../types/person.js'
import type { DecryptedPerson } from '../services/personPool.js'
import { loadPersonPool, filterSkippers, filterCrew } from '../services/personPool.js'
import { loadLogbookCrewSelection, saveLogbookCrewSelectionFromIds } from '../services/logbookCrewSelection.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
export interface LogbookCrewPickerProps {
logbookId: string
readOnly?: boolean
/** Demo / share: in-memory pool */
preloadedPool?: Array<{ payloadId: string; data: DecryptedPerson['data'] }>
preloadedSelection?: LogbookCrewSelectionData
/** Shared logbook: only people from selection snapshots */
selectionOnly?: boolean
}
function snapshotsToPoolList(
selection: LogbookCrewSelectionData
): Array<{ payloadId: string; data: DecryptedPerson['data'] }> {
return Object.values(selection.snapshotsById).map((snap) => ({
payloadId: snap.id,
data: {
name: snap.name,
address: snap.address,
birthDate: snap.birthDate,
phone: snap.phone,
nationality: snap.nationality,
passportNumber: snap.passportNumber,
bloodType: snap.bloodType,
allergies: snap.allergies,
diseases: snap.diseases,
role: snap.role,
photo: snap.photo
}
}))
}
export default function LogbookCrewPicker({
logbookId,
readOnly = false,
preloadedPool,
preloadedSelection,
selectionOnly = false
}: LogbookCrewPickerProps) {
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<DecryptedPerson[]>([])
const [activeSkipperId, setActiveSkipperId] = useState<string | null>(null)
const [activeCrewIds, setActiveCrewIds] = useState<string[]>([])
const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const selection =
preloadedSelection ??
(logbookId === 'demo' ? null : await loadLogbookCrewSelection(logbookId))
if (selection) {
setActiveSkipperId(selection.activeSkipperId)
setActiveCrewIds([...selection.activeCrewIds])
}
if (preloadedPool) {
setPool(
preloadedPool.map((p) => ({
payloadId: p.payloadId,
data: p.data
}))
)
} else if (selectionOnly && selection) {
setPool(snapshotsToPoolList(selection))
} else {
setPool(await loadPersonPool())
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to load crew selection')
} finally {
setLoading(false)
}
}, [logbookId, preloadedPool, preloadedSelection, selectionOnly])
useEffect(() => {
void loadData()
}, [loadData])
const skippers = useMemo(() => filterSkippers(pool), [pool])
const crewMembers = useMemo(() => filterCrew(pool), [pool])
const toggleCrew = (id: string) => {
if (readOnly) return
setActiveCrewIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
)
}
const handleSave = async () => {
if (readOnly || logbookId === 'demo') return
setSaving(true)
setError(null)
setSaved(false)
try {
await saveLogbookCrewSelectionFromIds(logbookId, activeSkipperId, activeCrewIds)
setSaved(true)
trackPlausibleEvent(PlausibleEvents.CREW_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">
<Users className="header-logo spin" size={48} />
<p>{t('person_pool.loading')}</p>
</div>
)
}
return (
<div className="crew-dashboard-layout" data-tour="logbook-crew-picker">
<div className="form-card">
<div className="form-header">
<Users size={24} className="form-icon" />
<h2>{t('logbook_crew.title')}</h2>
</div>
<p className="help-text mb-4">{t('logbook_crew.subtitle')}</p>
{selectionOnly && <p className="help-text mb-4">{t('logbook_crew.selection_only_hint')}</p>}
{error && <div className="auth-error mb-4">{error}</div>}
<div className="input-group mb-4">
<label>{t('logbook_crew.active_skipper')}</label>
{skippers.length === 0 ? (
<p className="help-text">{t('logbook_crew.no_skippers_in_pool')}</p>
) : (
<div className="crew-selection-list">
{skippers.map((s) => (
<label key={s.payloadId} className="crew-selection-item">
<input
type="radio"
name={`skipper-${logbookId}`}
checked={activeSkipperId === s.payloadId}
onChange={() => !readOnly && setActiveSkipperId(s.payloadId)}
disabled={readOnly}
/>
<User size={16} aria-hidden="true" />
<span>{s.data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
{!readOnly && (
<label className="crew-selection-item">
<input
type="radio"
name={`skipper-${logbookId}`}
checked={activeSkipperId === null}
onChange={() => setActiveSkipperId(null)}
/>
<span>{t('logbook_crew.no_skipper')}</span>
</label>
)}
</div>
)}
</div>
<div className="input-group mb-4">
<label>{t('logbook_crew.active_crew')}</label>
{crewMembers.length === 0 ? (
<p className="help-text">{t('logbook_crew.no_crew_in_pool')}</p>
) : (
<div className="crew-selection-list">
{crewMembers.map((c) => (
<label key={c.payloadId} className="crew-selection-item">
<input
type="checkbox"
checked={activeCrewIds.includes(c.payloadId)}
onChange={() => toggleCrew(c.payloadId)}
disabled={readOnly}
/>
<span>{c.data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)}
</div>
{!readOnly && logbookId !== 'demo' && (
<div className="form-actions">
{saved && (
<div className="success-toast">
<Check size={16} />
<span>{t('logbook_crew.saved')}</span>
</div>
)}
<button type="button" className="btn primary" onClick={() => void handleSave()} disabled={saving}>
<Save size={18} />
{t('logbook_crew.save')}
</button>
</div>
)}
</div>
</div>
)
}
export function selectionFromSnapshots(
snapshotsById: Record<string, PersonSnapshot>
): LogbookCrewSelectionData {
const snapshots = Object.values(snapshotsById)
const skipper = snapshots.find((s) => s.role === 'skipper')
return {
activeSkipperId: skipper?.id ?? null,
activeCrewIds: snapshots.filter((s) => s.role === 'crew').map((s) => s.id),
snapshotsById
}
}
+203 -36
View File
@@ -1,15 +1,20 @@
import React, { useState, useEffect, useRef } from 'react'
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage } from '../utils/i18nLanguages.js'
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
import { loadLogbookSearchFieldsBatch } from '../services/logbookSearchIndex.js'
import { logbookMatchesFilter, type LogbookSearchFields } from '../utils/logbookFilter.js'
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
import BetaBadge from './BetaBadge.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js'
import { logoutUser } from '../services/auth.js'
import { useDialog } from './ModalDialog.tsx'
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
interface LogbookDashboardProps {
onSelectLogbook: (id: string, title: string) => void
@@ -17,6 +22,26 @@ interface LogbookDashboardProps {
onOpenProfile: () => void
}
type LogbookSortKey = 'name' | 'date'
type LogbookSortDirection = 'asc' | 'desc'
function sortLogbooks(
items: DecryptedLogbook[],
sortBy: LogbookSortKey,
direction: LogbookSortDirection,
locale: string
): DecryptedLogbook[] {
const sorted = [...items]
sorted.sort((a, b) => {
const cmp =
sortBy === 'name'
? a.title.localeCompare(b.title, locale, { sensitivity: 'base' })
: new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
return direction === 'asc' ? cmp : -cmp
})
return sorted
}
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile }: LogbookDashboardProps) {
const { t, i18n } = useTranslation()
const { showConfirm } = useDialog()
@@ -28,8 +53,14 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
const [loading, setLoading] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [filterQuery, setFilterQuery] = useState('')
const [searchFieldsByLogbookId, setSearchFieldsByLogbookId] = useState<Map<string, LogbookSearchFields>>(
() => new Map()
)
const [sortBy, setSortBy] = useState<LogbookSortKey>('date')
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
const filterInputRef = useRef<HTMLInputElement>(null)
const [online, setOnline] = useState(navigator.onLine)
const [username] = useState(localStorage.getItem('active_username') || 'Skipper')
const { pendingCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator()
@@ -50,6 +81,23 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
loadLogbooks()
}, [])
useEffect(() => {
const ids = logbooks.map((lb) => lb.id)
if (ids.length === 0) {
setSearchFieldsByLogbookId(new Map())
return
}
let cancelled = false
void loadLogbookSearchFieldsBatch(ids).then((index) => {
if (!cancelled) setSearchFieldsByLogbookId(index)
})
return () => {
cancelled = true
}
}, [logbooks])
const loadLogbooks = async (isRefresh = false) => {
if (isRefresh) setRefreshing(true)
else setLoading(true)
@@ -57,8 +105,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
try {
const data = await fetchLogbooks()
setLogbooks(data)
} catch (err: any) {
setError(err.message || 'Failed to load logbooks')
} catch (err: unknown) {
setError(getErrorMessage(err, t('errors.load_failed')))
} finally {
setLoading(false)
setRefreshing(false)
@@ -76,8 +124,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
setLogbooks((prev) => [created, ...prev])
setNewTitle('')
trackPlausibleEvent(PlausibleEvents.LOGBOOK_CREATED)
} catch (err: any) {
setError(err.message || 'Failed to create logbook')
} catch (err: unknown) {
setError(getErrorMessage(err, t('errors.save_failed')))
} finally {
setLoading(false)
}
@@ -93,7 +141,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
await deleteLogbook(id)
setLogbooks((prev) => prev.filter((lb) => lb.id !== id))
} catch (err: any) {
setError(err.message || 'Failed to delete logbook')
setError(getErrorMessage(err, t('errors.delete_failed')))
} finally {
setLoading(false)
}
@@ -137,7 +185,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
)
)
} catch (err: any) {
setError(err.message || 'Failed to update logbook title')
setError(getErrorMessage(err, t('errors.save_failed')))
} finally {
setLoading(false)
}
@@ -149,23 +197,55 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
}
const toggleLanguage = () => {
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
i18n.changeLanguage(nextLang)
cycleAppLanguage(i18n)
}
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
const sharedLogbooks = logbooks.filter((lb) => lb.isShared)
const filterActive = filterQuery.trim().length > 0
const filteredOwnedLogbooks = useMemo(
() =>
ownedLogbooks.filter((lb) =>
logbookMatchesFilter(lb, filterQuery, i18n.language, searchFieldsByLogbookId.get(lb.id))
),
[ownedLogbooks, filterQuery, i18n.language, searchFieldsByLogbookId]
)
const filteredSharedLogbooks = useMemo(
() =>
sharedLogbooks.filter((lb) =>
logbookMatchesFilter(lb, filterQuery, i18n.language, searchFieldsByLogbookId.get(lb.id))
),
[sharedLogbooks, filterQuery, i18n.language, searchFieldsByLogbookId]
)
const sortedOwnedLogbooks = useMemo(
() => sortLogbooks(filteredOwnedLogbooks, sortBy, sortDirection, i18n.language),
[filteredOwnedLogbooks, sortBy, sortDirection, i18n.language]
)
const sortedSharedLogbooks = useMemo(
() => sortLogbooks(filteredSharedLogbooks, sortBy, sortDirection, i18n.language),
[filteredSharedLogbooks, sortBy, sortDirection, i18n.language]
)
const filteredLogbookCount = sortedOwnedLogbooks.length + sortedSharedLogbooks.length
const renderLogbookCard = (lb: DecryptedLogbook) => {
const isEditingTitle = editingLogbookId === lb.id
return (
<div
key={lb.id}
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}`}
onClick={() => onSelectLogbook(lb.id, lb.title)}
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}${isEditingTitle ? ' logbook-card--editing-title' : ''}`}
>
<div className="card-icon">
{!isEditingTitle && (
<button
type="button"
className="logbook-card-select"
onClick={() => onSelectLogbook(lb.id, lb.title)}
aria-label={t('dashboard.open_logbook', { title: lb.title })}
/>
)}
<div className="card-icon" aria-hidden>
<BookOpen size={24} />
</div>
@@ -178,7 +258,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
className="logbook-title-inline-edit input-text"
value={editingTitleDraft}
onChange={(e) => setEditingTitleDraft(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
@@ -307,17 +386,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
)}
</div>
{/* Skipper profile */}
<button
type="button"
className="btn-icon skipper-badge"
onClick={onOpenProfile}
title={t('dashboard.open_profile', { name: username })}
aria-label={t('dashboard.open_profile', { name: username })}
>
<User size={18} aria-hidden="true" />
<span className="skipper-badge__name">{username}</span>
</button>
<ProfileHeaderButton onClick={onOpenProfile} />
{/* Lang toggle */}
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
@@ -375,17 +444,115 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
) : logbooks.length === 0 ? (
<div className="dashboard-status-msg glass">{t('dashboard.no_logbooks')}</div>
) : (
<div className="logbook-sections">
{ownedLogbooks.length > 0 && renderLogbookSection(
sharedLogbooks.length > 0 ? t('dashboard.section_owned') : t('dashboard.title'),
ownedLogbooks
<>
<div className="dashboard-list-controls">
<div className="dashboard-filter-bar">
<label className="dashboard-filter-label" htmlFor="logbook-list-filter">
{t('dashboard.filter_label')}
</label>
<div className="dashboard-filter-input-wrap">
<Search size={18} className="dashboard-filter-icon" aria-hidden="true" />
<input
ref={filterInputRef}
id="logbook-list-filter"
type="search"
className="input-text dashboard-filter-input"
placeholder={t('dashboard.filter_placeholder')}
value={filterQuery}
onChange={(e) => setFilterQuery(e.target.value)}
autoComplete="off"
spellCheck={false}
aria-describedby={filterActive ? 'logbook-filter-status' : undefined}
/>
{filterActive && (
<button
type="button"
className="dashboard-filter-clear"
onClick={() => {
setFilterQuery('')
filterInputRef.current?.focus()
}}
title={t('dashboard.filter_clear')}
aria-label={t('dashboard.filter_clear')}
>
<X size={16} aria-hidden="true" />
</button>
)}
</div>
{filterActive && (
<p id="logbook-filter-status" className="dashboard-filter-meta" role="status">
{t('dashboard.filter_results', { count: filteredLogbookCount })}
</p>
)}
</div>
<div className="dashboard-sort-bar">
<span className="dashboard-sort-label">{t('dashboard.sort_label')}</span>
<div className="dashboard-sort-row">
<div className="dashboard-sort-group" role="group" aria-label={t('dashboard.sort_by_label')}>
<button
type="button"
className={`dashboard-sort-btn${sortBy === 'name' ? ' is-active' : ''}`}
onClick={() => setSortBy('name')}
aria-pressed={sortBy === 'name'}
aria-label={t('dashboard.sort_by_name')}
title={t('dashboard.sort_by_name')}
>
<CaseSensitive size={16} aria-hidden="true" />
</button>
<button
type="button"
className={`dashboard-sort-btn${sortBy === 'date' ? ' is-active' : ''}`}
onClick={() => setSortBy('date')}
aria-pressed={sortBy === 'date'}
aria-label={t('dashboard.sort_by_date')}
title={t('dashboard.sort_by_date')}
>
<CalendarDays size={16} aria-hidden="true" />
</button>
</div>
<div className="dashboard-sort-group" role="group" aria-label={t('dashboard.sort_dir_label')}>
<button
type="button"
className={`dashboard-sort-btn${sortDirection === 'asc' ? ' is-active' : ''}`}
onClick={() => setSortDirection('asc')}
aria-pressed={sortDirection === 'asc'}
aria-label={sortBy === 'name' ? t('dashboard.sort_name_asc') : t('dashboard.sort_date_asc')}
title={sortBy === 'name' ? t('dashboard.sort_name_asc') : t('dashboard.sort_date_asc')}
>
<ArrowUp size={16} aria-hidden="true" />
</button>
<button
type="button"
className={`dashboard-sort-btn${sortDirection === 'desc' ? ' is-active' : ''}`}
onClick={() => setSortDirection('desc')}
aria-pressed={sortDirection === 'desc'}
aria-label={sortBy === 'name' ? t('dashboard.sort_name_desc') : t('dashboard.sort_date_desc')}
title={sortBy === 'name' ? t('dashboard.sort_name_desc') : t('dashboard.sort_date_desc')}
>
<ArrowDown size={16} aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
{filterActive && filteredLogbookCount === 0 ? (
<div className="dashboard-status-msg glass">{t('dashboard.filter_no_results')}</div>
) : (
<div className="logbook-sections">
{sortedOwnedLogbooks.length > 0 && renderLogbookSection(
sortedSharedLogbooks.length > 0 ? t('dashboard.section_owned') : t('dashboard.title'),
sortedOwnedLogbooks
)}
{sortedSharedLogbooks.length > 0 && renderLogbookSection(
t('dashboard.section_shared'),
sortedSharedLogbooks,
t('dashboard.section_shared_hint')
)}
</div>
)}
{sharedLogbooks.length > 0 && renderLogbookSection(
t('dashboard.section_shared'),
sharedLogbooks,
t('dashboard.section_shared_hint')
)}
</div>
</>
)}
</section>
</main>
@@ -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>
)
}
+171 -30
View File
@@ -1,8 +1,28 @@
import React, { createContext, useContext, useState, useRef, useCallback, useMemo } from 'react'
import React, {
createContext,
useContext,
useState,
useRef,
useCallback,
useMemo,
useEffect,
useId
} from 'react'
import { useTranslation } from 'react-i18next'
export type ConfirmLeaveChoice = 'stay' | 'save' | 'discard'
interface DialogContextType {
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
showConfirm: (message: string, title?: string, confirmText?: string, cancelText?: string) => Promise<boolean>
showConfirmLeave: (
message: string,
title?: string,
stayLabel?: string,
saveLabel?: string,
discardLabel?: string,
options?: { showSave?: boolean }
) => Promise<ConfirmLeaveChoice>
}
const DialogContext = createContext<DialogContextType | undefined>(undefined)
@@ -16,26 +36,36 @@ export function useDialog() {
}
export function DialogProvider({ children }: { children: React.ReactNode }) {
const { t } = useTranslation()
const titleId = useId()
const messageId = useId()
const confirmRef = useRef<HTMLButtonElement>(null)
const [isOpen, setIsOpen] = useState(false)
const [title, setTitle] = useState('')
const [message, setMessage] = useState('')
const [type, setType] = useState<'alert' | 'confirm'>('alert')
const [type, setType] = useState<'alert' | 'confirm' | 'confirm-leave'>('alert')
const [confirmLabel, setConfirmLabel] = useState('OK')
const [cancelLabel, setCancelLabel] = useState('Cancel')
const [saveLabel, setSaveLabel] = useState('')
const [discardLabel, setDiscardLabel] = useState('')
const [showSaveOption, setShowSaveOption] = useState(false)
const resolveRef = useRef<((val: any) => void) | null>(null)
const alertResolveRef = useRef<(() => void) | null>(null)
const confirmResolveRef = useRef<((val: boolean) => void) | null>(null)
const confirmLeaveResolveRef = useRef<((val: ConfirmLeaveChoice) => void) | null>(null)
const showAlert = useCallback((msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
setMessage(msg)
setTitle(headerTitle || '')
setType('alert')
setConfirmLabel(btnText || 'OK')
setConfirmLabel(btnText || t('dialog.ok'))
setIsOpen(true)
return new Promise<void>((resolve) => {
resolveRef.current = resolve
alertResolveRef.current = resolve
})
}, [])
}, [t])
const showConfirm = useCallback((
msg: string,
@@ -46,53 +76,164 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
setMessage(msg)
setTitle(headerTitle || '')
setType('confirm')
setConfirmLabel(btnConfirm || 'Yes')
setCancelLabel(btnCancel || 'No')
setConfirmLabel(btnConfirm || t('dialog.yes'))
setCancelLabel(btnCancel || t('dialog.no'))
setIsOpen(true)
return new Promise<boolean>((resolve) => {
resolveRef.current = resolve
confirmResolveRef.current = resolve
})
}, [t])
const showConfirmLeave = useCallback((
msg: string,
headerTitle?: string,
btnStay?: string,
btnSave?: string,
btnDiscard?: string,
options?: { showSave?: boolean }
): Promise<ConfirmLeaveChoice> => {
setMessage(msg)
setTitle(headerTitle || '')
setType('confirm-leave')
setCancelLabel(btnStay || t('common.unsaved_changes_stay'))
setSaveLabel(btnSave || t('common.unsaved_changes_save_leave'))
setDiscardLabel(btnDiscard || t('common.unsaved_changes_discard'))
setShowSaveOption(options?.showSave !== false)
setIsOpen(true)
return new Promise<ConfirmLeaveChoice>((resolve) => {
confirmLeaveResolveRef.current = resolve
})
}, [t])
const closeConfirmLeave = useCallback((choice: ConfirmLeaveChoice) => {
setIsOpen(false)
if (confirmLeaveResolveRef.current) {
confirmLeaveResolveRef.current(choice)
confirmLeaveResolveRef.current = null
}
}, [])
const handleConfirm = useCallback(() => {
setIsOpen(false)
if (resolveRef.current) {
resolveRef.current(type === 'confirm' ? true : undefined)
resolveRef.current = null
if (type === 'confirm' && confirmResolveRef.current) {
confirmResolveRef.current(true)
confirmResolveRef.current = null
} else if (alertResolveRef.current) {
alertResolveRef.current()
alertResolveRef.current = null
}
}, [type])
const handleCancel = useCallback(() => {
setIsOpen(false)
if (resolveRef.current) {
resolveRef.current(false)
resolveRef.current = null
if (type === 'confirm-leave') {
closeConfirmLeave('stay')
return
}
}, [])
setIsOpen(false)
if (confirmResolveRef.current) {
confirmResolveRef.current(false)
confirmResolveRef.current = null
}
}, [type, closeConfirmLeave])
useEffect(() => {
if (!isOpen) return
confirmRef.current?.focus()
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (type === 'confirm' || type === 'confirm-leave') handleCancel()
else handleConfirm()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [isOpen, type, handleCancel, handleConfirm])
const contextValue = useMemo(
() => ({ showAlert, showConfirm }),
[showAlert, showConfirm]
() => ({ showAlert, showConfirm, showConfirmLeave }),
[showAlert, showConfirm, showConfirmLeave]
)
return (
<DialogContext.Provider value={contextValue}>
{children}
{isOpen && (
<div className="custom-dialog-overlay" onClick={type === 'alert' ? handleConfirm : undefined}>
<div className="custom-dialog-card glass scale-in" onClick={(e) => e.stopPropagation()}>
{title && <h3 className="custom-dialog-title">{title}</h3>}
<p className="custom-dialog-message">{message}</p>
<div
className="custom-dialog-overlay"
onClick={type === 'confirm' || type === 'confirm-leave' ? handleCancel : handleConfirm}
>
<div
className="custom-dialog-card glass scale-in"
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
aria-describedby={messageId}
onClick={(e) => e.stopPropagation()}
>
{title && (
<h3 id={titleId} className="custom-dialog-title">
{title}
</h3>
)}
<p id={messageId} className="custom-dialog-message">
{message}
</p>
<div className="custom-dialog-actions">
{type === 'confirm' && (
<button type="button" className="btn secondary" onClick={handleCancel} style={{ width: 'auto', padding: '8px 20px', margin: 0 }}>
{cancelLabel}
</button>
{type === 'confirm-leave' ? (
<>
<button
ref={confirmRef}
type="button"
className="btn secondary"
onClick={handleCancel}
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
>
{cancelLabel}
</button>
{showSaveOption && (
<button
type="button"
className="btn primary"
onClick={() => closeConfirmLeave('save')}
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
>
{saveLabel}
</button>
)}
<button
type="button"
className="btn danger"
onClick={() => closeConfirmLeave('discard')}
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
>
{discardLabel}
</button>
</>
) : (
<>
{type === 'confirm' && (
<button
type="button"
className="btn secondary"
onClick={handleCancel}
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
>
{cancelLabel}
</button>
)}
<button
ref={confirmRef}
type="button"
className="btn primary"
onClick={handleConfirm}
style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}
>
{confirmLabel}
</button>
</>
)}
<button type="button" className="btn primary" onClick={handleConfirm} style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}>
{confirmLabel}
</button>
</div>
</div>
</div>
+333
View File
@@ -0,0 +1,333 @@
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { FileText, X } from 'lucide-react'
import type { LogEventPayload } from '../utils/logEntryPayload.js'
import { sortLogEventsByTime } from '../utils/logEntryPayload.js'
import { parseNmeaFile, nmeaPointsToWaypoints } from '../services/nmea/nmeaParse.js'
import { filterPointsForDate } from '../services/nmea/nmeaTimeSeries.js'
import { generateNmeaJournalCandidates } from '../services/nmea/nmeaJournalGenerator.js'
import type { NmeaImportMode, NmeaParseResult } from '../services/nmea/nmeaTypes.js'
import { saveNmeaArchive, recordNmeaFileImport, type NmeaArchiveRecord } from '../services/nmeaArchive.js'
import { nmeaFileCrc32 } from '../utils/crc32.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import type { TrackWaypoint } from '../services/trackUpload.js'
interface NmeaImportWizardProps {
open: boolean
onClose: () => void
logbookId: string
entryId: string
entryDate: string
nmeaArchive: NmeaArchiveRecord | null
onImport: (events: LogEventPayload[], waypoints?: TrackWaypoint[]) => void
}
type WizardStep = 'config' | 'preview' | 'archive'
export default function NmeaImportWizard({
open,
onClose,
logbookId,
entryId,
entryDate,
nmeaArchive,
onImport
}: NmeaImportWizardProps) {
const { t } = useTranslation()
const [step, setStep] = useState<WizardStep>('config')
const [parseResult, setParseResult] = useState<NmeaParseResult | null>(null)
const [mode, setMode] = useState<NmeaImportMode>('both')
const [intervalMinutes, setIntervalMinutes] = useState(60)
const [importTrack, setImportTrack] = useState(true)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [error, setError] = useState<string | null>(null)
const [pendingRaw, setPendingRaw] = useState<{ filename: string; text: string } | null>(null)
const [duplicateFile, setDuplicateFile] = useState(false)
const filteredPoints = useMemo(() => {
if (!parseResult) return []
return filterPointsForDate(parseResult.points, entryDate)
}, [parseResult, entryDate])
const candidates = useMemo(() => {
if (!parseResult || filteredPoints.length === 0) return []
return generateNmeaJournalCandidates({
points: filteredPoints,
mode,
intervalMinutes,
t
}).candidates
}, [parseResult, filteredPoints, mode, intervalMinutes, t])
const reset = () => {
setStep('config')
setParseResult(null)
setMode('both')
setIntervalMinutes(60)
setImportTrack(true)
setSelectedIds(new Set())
setError(null)
setDuplicateFile(false)
setPendingRaw(null)
}
const handleClose = () => {
reset()
onClose()
}
const handleFile = (file: File) => {
setError(null)
setDuplicateFile(false)
const reader = new FileReader()
reader.onload = () => {
try {
const text = String(reader.result ?? '')
const crc32 = nmeaFileCrc32(text)
const alreadyImported = nmeaArchive?.importedFiles.some((item) => item.crc32 === crc32) ?? false
setDuplicateFile(alreadyImported)
const result = parseNmeaFile(text, file.name)
if (result.points.length === 0) {
setError(t('logs.nmea_error_no_samples'))
return
}
setParseResult(result)
setPendingRaw({ filename: file.name, text })
const generated = generateNmeaJournalCandidates({
points: filterPointsForDate(result.points, entryDate),
mode,
intervalMinutes,
t
}).candidates
setSelectedIds(new Set(generated.map((c) => c.id)))
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, {
duplicate: alreadyImported,
lines: result.stats.parsedLines,
candidates: generated.length,
has_position: !result.warnings.includes('no_position')
})
} catch (err) {
setError(err instanceof Error ? err.message : t('logs.nmea_error_parse'))
}
}
reader.onerror = () => setError(t('logs.nmea_error_read'))
reader.readAsText(file)
}
const toggleAll = (checked: boolean) => {
setSelectedIds(checked ? new Set(candidates.map((c) => c.id)) : new Set())
}
const toggleOne = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const goPreview = () => {
if (!parseResult) {
setError(t('logs.nmea_error_no_file'))
return
}
const generated = generateNmeaJournalCandidates({
points: filteredPoints,
mode,
intervalMinutes,
t
}).candidates
setSelectedIds(new Set(generated.map((c) => c.id)))
setStep('preview')
}
const applyImport = async () => {
const picked = candidates.filter((c) => selectedIds.has(c.id)).map((c) => c.event)
if (picked.length === 0) {
setError(t('logs.nmea_error_no_selection'))
return
}
const waypoints = importTrack ? nmeaPointsToWaypoints(filteredPoints) : undefined
onImport(sortLogEventsByTime(picked), waypoints)
if (pendingRaw) {
try {
await recordNmeaFileImport(logbookId, entryId, pendingRaw.filename, pendingRaw.text)
} catch (err) {
console.warn('NMEA import CRC record failed:', err)
}
}
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, {
mode,
events: picked.length,
track: importTrack && (waypoints?.length ?? 0) > 0
})
setStep('archive')
}
const finishArchive = async (archive: boolean) => {
try {
if (archive && pendingRaw) {
await saveNmeaArchive(logbookId, entryId, pendingRaw.filename, pendingRaw.text)
}
} catch (err) {
console.warn('NMEA archive save failed:', err)
}
handleClose()
}
useEffect(() => {
if (!open) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') handleClose()
}
window.addEventListener('keydown', onKeyDown)
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
window.removeEventListener('keydown', onKeyDown)
document.body.style.overflow = prevOverflow
}
}, [open])
if (!open) return null
return createPortal(
<div className="disclaimer-modal-overlay" onClick={handleClose}>
<div className="disclaimer-modal-panel" onClick={(e) => e.stopPropagation()}>
<div className="auth-card glass registration-disclaimer registration-disclaimer--modal feedback-modal">
<button
type="button"
className="registration-disclaimer__close feedback-modal__close"
onClick={handleClose}
aria-label={t('logs.nmea_cancel')}
>
<X size={18} />
</button>
<div className="auth-header">
<FileText className="auth-icon accent" size={40} />
<h2>{t('logs.nmea_import_title')}</h2>
</div>
{error && <div className="track-error-msg">{error}</div>}
{duplicateFile && (
<div className="nmea-import-warning" role="status">
{t('logs.nmea_warn_duplicate_file')}
</div>
)}
{step === 'config' && (
<>
<p className="registration-disclaimer__intro">{t('logs.nmea_import_intro')}</p>
<label className="feedback-form__field">
<span>{t('logs.nmea_file_label')}</span>
<input
type="file"
accept=".nmea,.log,.txt"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFile(file)
}}
/>
</label>
{parseResult && (
<div className="nmea-import-summary">
<p>{t('logs.nmea_stats', {
lines: parseResult.stats.parsedLines,
types: parseResult.stats.sentenceTypes.join(', ')
})}</p>
{parseResult.warnings.includes('no_position') && (
<p>{t('logs.nmea_warn_no_position')}</p>
)}
</div>
)}
<fieldset className="nmea-import-mode">
<legend>{t('logs.nmea_mode_label')}</legend>
<label><input type="radio" name="nmea-mode" checked={mode === 'interval'} onChange={() => setMode('interval')} /> {t('logs.nmea_mode_interval')}</label>
<label><input type="radio" name="nmea-mode" checked={mode === 'change'} onChange={() => setMode('change')} /> {t('logs.nmea_mode_change')}</label>
<label><input type="radio" name="nmea-mode" checked={mode === 'both'} onChange={() => setMode('both')} /> {t('logs.nmea_mode_both')}</label>
</fieldset>
{(mode === 'interval' || mode === 'both') && (
<label className="feedback-form__field">
<span>{t('logs.nmea_interval_label')}</span>
<select value={intervalMinutes} onChange={(e) => setIntervalMinutes(Number(e.target.value))}>
<option value={30}>30 min</option>
<option value={60}>60 min</option>
<option value={90}>90 min</option>
<option value={120}>120 min</option>
</select>
</label>
)}
<label className="nmea-import-checkbox">
<input type="checkbox" checked={importTrack} onChange={(e) => setImportTrack(e.target.checked)} />
{t('logs.nmea_import_track')}
</label>
<div className="auth-actions feedback-form__actions">
<button type="button" className="btn secondary" onClick={handleClose}>{t('logs.nmea_cancel')}</button>
<button type="button" className="btn primary" onClick={goPreview} disabled={!parseResult}>
{t('logs.nmea_preview')}
</button>
</div>
</>
)}
{step === 'preview' && (
<>
<p>{t('logs.nmea_preview_hint', { count: candidates.length })}</p>
<div className="nmea-preview-actions">
<button type="button" className="btn secondary" onClick={() => toggleAll(true)}>{t('logs.nmea_select_all')}</button>
<button type="button" className="btn secondary" onClick={() => toggleAll(false)}>{t('logs.nmea_select_none')}</button>
</div>
<div className="nmea-preview-list">
{candidates.map((c) => (
<label key={c.id} className="nmea-preview-row">
<input
type="checkbox"
className="nmea-preview-row__check"
checked={selectedIds.has(c.id)}
onChange={() => toggleOne(c.id)}
/>
<div className="nmea-preview-row__body">
<div className="nmea-preview-row__meta">
<span className="nmea-preview-time">{c.event.time}</span>
<span className="nmea-preview-source">{t(`logs.nmea_source_${c.source}`)}</span>
</div>
<span className="nmea-preview-remarks">{c.event.remarks || c.event.mgk || '—'}</span>
</div>
</label>
))}
</div>
<div className="auth-actions feedback-form__actions">
<button type="button" className="btn secondary" onClick={() => setStep('config')}>{t('logs.nmea_back')}</button>
<button type="button" className="btn primary" onClick={applyImport}>{t('logs.nmea_apply')}</button>
</div>
</>
)}
{step === 'archive' && (
<>
<p>{t('logs.nmea_archive_question')}</p>
<div className="auth-actions feedback-form__actions">
<button type="button" className="btn secondary" onClick={() => finishArchive(false)}>
{t('logs.nmea_archive_discard')}
</button>
<button type="button" className="btn primary" onClick={() => finishArchive(true)}>
{t('logs.nmea_archive_keep')}
</button>
</div>
</>
)}
</div>
</div>
</div>,
document.body
)
}
+313
View File
@@ -0,0 +1,313 @@
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Users, User, Plus, Trash2, Edit2, X, Camera, Save } from 'lucide-react'
import { useDialog } from './ModalDialog.tsx'
import { resizeImageFile } from '../utils/resizeImageFile.js'
import type { PersonData, PersonRole } from '../types/person.js'
import { MAX_POOL_CREW_MEMBERS } from '../types/person.js'
import {
loadPersonPool,
savePerson,
deletePerson,
filterSkippers,
filterCrew,
type DecryptedPerson
} from '../services/personPool.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
const emptyPerson = (role: PersonRole): PersonData => ({
name: '',
address: '',
birthDate: '',
phone: '',
nationality: '',
passportNumber: '',
bloodType: '',
allergies: '',
diseases: '',
role,
photo: null
})
export default function PersonPoolForm() {
const { t } = useTranslation()
const { showConfirm } = useDialog()
const [people, setPeople] = useState<DecryptedPerson[]>([])
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 [formRole, setFormRole] = useState<PersonRole>('crew')
const [form, setForm] = useState<PersonData>(emptyPerson('crew'))
const [saving, setSaving] = useState(false)
const [photoError, setPhotoError] = useState<string | null>(null)
const fileRef = React.useRef<HTMLInputElement>(null)
const reload = useCallback(async () => {
setLoading(true)
setError(null)
try {
setPeople(await loadPersonPool())
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to load')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void reload()
}, [reload])
const openAdd = (role: PersonRole) => {
setEditingId(null)
setFormRole(role)
setForm(emptyPerson(role))
setPhotoError(null)
setShowForm(true)
}
const openEdit = (person: DecryptedPerson) => {
setEditingId(person.payloadId)
setFormRole(person.data.role)
setForm({ ...person.data })
setPhotoError(null)
setShowForm(true)
}
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.name.trim()) return
setSaving(true)
setError(null)
try {
const id = editingId ?? window.crypto.randomUUID()
await savePerson(id, { ...form, role: formRole }, !editingId)
setShowForm(false)
trackPlausibleEvent(PlausibleEvents.CREW_SAVED, { role: formRole, context: 'person_pool' })
await reload()
} catch (err: unknown) {
if (err instanceof Error && err.message === 'MAX_CREW') {
setError(t('crew.max_crew'))
} else {
setError(err instanceof Error ? err.message : 'Failed to save')
}
} finally {
setSaving(false)
}
}
const handleDelete = async (id: string) => {
if (
!(await showConfirm(
t('person_pool.delete_confirm'),
t('person_pool.title'),
t('logs.confirm_yes'),
t('logs.confirm_no')
))
) {
return
}
try {
await deletePerson(id)
await reload()
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to delete')
}
}
const skippers = filterSkippers(people)
const crewList = filterCrew(people)
if (loading) {
return (
<div className="tab-placeholder">
<Users className="header-logo spin" size={48} />
<p>{t('person_pool.loading')}</p>
</div>
)
}
const renderCard = (person: DecryptedPerson) => (
<div key={person.payloadId} className="crew-member-card glass">
<div className="crew-card-header">
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{person.data.photo ? (
<img src={person.data.photo} alt="" className="crew-card-avatar" />
) : (
<div className="crew-card-avatar-placeholder">
<User size={18} />
</div>
)}
<h4>{person.data.name}</h4>
</div>
<div className="card-actions">
<button type="button" className="btn-icon" onClick={() => openEdit(person)} title="Edit">
<Edit2 size={14} />
</button>
<button
type="button"
className="btn-icon danger"
onClick={() => void handleDelete(person.payloadId)}
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
</div>
{person.data.phone && (
<p className="help-text">
<strong>{t('crew.phone')}:</strong> {person.data.phone}
</p>
)}
</div>
)
return (
<section className="form-card" data-tour="profile-crew-pool">
<div className="form-header">
<Users size={24} className="form-icon" />
<h2>{t('person_pool.title')}</h2>
</div>
<p className="help-text mb-4">{t('person_pool.subtitle')}</p>
{error && <div className="auth-error mb-4">{error}</div>}
<div className="section-title-bar mb-4">
<h3>{t('person_pool.skippers_section')}</h3>
{!showForm && (
<button type="button" className="btn primary" style={{ width: 'auto', padding: '8px 16px' }} onClick={() => openAdd('skipper')}>
<Plus size={16} />
{t('person_pool.add_skipper')}
</button>
)}
</div>
{skippers.length === 0 ? (
<p className="help-text mb-4">{t('person_pool.no_skippers')}</p>
) : (
<div className="crew-grid mb-6">{skippers.map(renderCard)}</div>
)}
<div className="section-title-bar mb-4">
<h3>{t('person_pool.crew_section')}</h3>
{!showForm && crewList.length < MAX_POOL_CREW_MEMBERS && (
<button type="button" className="btn primary" style={{ width: 'auto', padding: '8px 16px' }} onClick={() => openAdd('crew')}>
<Plus size={16} />
{t('person_pool.add_crew')}
</button>
)}
</div>
{crewList.length === 0 ? (
<p className="help-text">{t('person_pool.no_crew')}</p>
) : (
<div className="crew-grid">{crewList.map(renderCard)}</div>
)}
{showForm && (
<form onSubmit={(e) => void handleSave(e)} className="member-editor-card glass mt-6">
<div className="editor-header mb-4">
<h3>
{editingId
? formRole === 'skipper'
? t('person_pool.edit_skipper')
: t('crew.edit_crew')
: formRole === 'skipper'
? t('person_pool.add_skipper')
: t('crew.add_crew')}
</h3>
<button type="button" className="btn-icon" onClick={() => setShowForm(false)}>
<X size={16} />
</button>
</div>
<div className="form-grid">
<div className="vessel-photo-wrapper">
<div className="vessel-photo-preview" onClick={() => fileRef.current?.click()}>
{form.photo ? (
<img src={form.photo} alt="" className="vessel-photo" />
) : (
<div className="vessel-photo-placeholder">
<User size={48} />
</div>
)}
<div className="vessel-photo-overlay">
<Camera size={24} />
</div>
</div>
<input
ref={fileRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0]
if (!file) return
void resizeImageFile(file)
.then((photo) => setForm((f) => ({ ...f, photo })))
.catch((err: unknown) => {
setPhotoError(err instanceof Error ? err.message : 'Image error')
})
}}
/>
{photoError && <div className="auth-error mt-2">{photoError}</div>}
</div>
<div className="input-group">
<label>{t('crew.name')} *</label>
<input
className="input-text"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
required
/>
</div>
<div className="input-group">
<label>{t('crew.address')}</label>
<input
className="input-text"
value={form.address}
onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))}
/>
</div>
<div className="input-group">
<label>{t('crew.birthdate')}</label>
<input
type="date"
className="input-text"
value={form.birthDate}
onChange={(e) => setForm((f) => ({ ...f, birthDate: e.target.value }))}
/>
</div>
<div className="input-group">
<label>{t('crew.phone')}</label>
<input
className="input-text"
value={form.phone}
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
/>
</div>
<div className="input-group">
<label>{t('crew.nationality')}</label>
<input
className="input-text"
value={form.nationality}
onChange={(e) => setForm((f) => ({ ...f, nationality: e.target.value }))}
/>
</div>
<div className="input-group">
<label>{t('crew.passport')}</label>
<input
className="input-text"
value={form.passportNumber}
onChange={(e) => setForm((f) => ({ ...f, passportNumber: e.target.value }))}
/>
</div>
</div>
<div className="editor-actions mt-4">
<button type="submit" className="btn primary" disabled={saving || !form.name.trim()}>
<Save size={18} />
{t('crew.save_member')}
</button>
</div>
</form>
)}
</section>
)
}
+21 -100
View File
@@ -3,9 +3,9 @@ import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { decryptJson } from '../services/crypto.js'
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
import { useLiveQuery } from 'dexie-react-hooks'
import { useDialog } from './ModalDialog.tsx'
import { Camera, Trash2 } from 'lucide-react'
@@ -90,109 +90,30 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
setUploading(true)
setError(null)
const reader = new FileReader()
reader.onload = (event) => {
const img = new Image()
img.onload = async () => {
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 = 1280
const MAX_HEIGHT = 720
// Calculate resizing conserving aspect ratio
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)
// Compress to JPEG, 70% quality
const compressedBase64 = canvas.toDataURL('image/jpeg', 0.7)
// Encrypt
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const photoId = window.crypto.randomUUID()
const photoPayload = {
image: compressedBase64,
caption: caption.trim()
}
const encrypted = await encryptJson(photoPayload, masterKey)
const now = new Date().toISOString()
// Store locally
await db.photos.put({
payloadId: photoId,
entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
caption: '', // stored encrypted inside payload
updatedAt: now
})
// Queue for background sync
await db.syncQueue.put({
action: 'create',
type: 'photo',
payloadId: photoId,
logbookId,
data: JSON.stringify({
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
entryId
}),
updatedAt: now
})
setCaption('')
if (fileInputRef.current) fileInputRef.current.value = ''
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'logbook' })
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
console.error('Failed to process image:', err)
setError(err.message || 'Failed to process image')
} finally {
setUploading(false)
}
}
img.src = event.target?.result as string
try {
const compressedBase64 = await fileToCompressedJpegDataUrl(file)
await saveEntryPhoto({
logbookId,
entryId,
imageDataUrl: compressedBase64,
caption: caption.trim(),
analyticsContext: 'logbook'
})
setCaption('')
if (fileInputRef.current) fileInputRef.current.value = ''
} catch (err: unknown) {
console.error('Failed to process image:', err)
setError(err instanceof Error ? err.message : 'Failed to process image')
} finally {
setUploading(false)
}
reader.readAsDataURL(file)
}
const handleDelete = async (photoId: string) => {
if (await showConfirm(t('logs.photo_delete_confirm'), t('logs.photos_title'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
try {
const now = new Date().toISOString()
await db.photos.delete(photoId)
await db.syncQueue.put({
action: 'delete',
type: 'photo',
payloadId: photoId,
logbookId,
data: '',
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
await deleteEntryPhoto(logbookId, photoId)
} catch (err: unknown) {
console.error('Failed to delete photo:', err)
}
}
@@ -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>
)
}
@@ -0,0 +1,25 @@
import { useTranslation } from 'react-i18next'
import { User } from 'lucide-react'
interface ProfileHeaderButtonProps {
onClick: () => void
}
export default function ProfileHeaderButton({ onClick }: ProfileHeaderButtonProps) {
const { t } = useTranslation()
const username = localStorage.getItem('active_username') || 'Skipper'
return (
<button
type="button"
className="btn-icon skipper-badge"
onClick={onClick}
title={t('dashboard.open_profile', { name: username })}
aria-label={t('dashboard.open_profile', { name: username })}
data-tour="nav-profile"
>
<User size={18} aria-hidden="true" />
<span className="skipper-badge__name">{username}</span>
</button>
)
}
@@ -6,7 +6,8 @@ import {
enableCollaboratorChangePush,
fetchPushPrefs,
getNotificationPermission,
isPushSupported
isPushSupported,
preloadPushService
} from '../services/pushNotifications.js'
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
@@ -28,6 +29,7 @@ export default function PushNotificationSettings() {
setLoading(false)
return
}
void preloadPushService()
try {
const prefs = await fetchPushPrefs()
setEnabled(prefs.collaboratorChangesEnabled)
@@ -56,7 +58,8 @@ export default function PushNotificationSettings() {
trackPlausibleEvent(PlausibleEvents.PUSH_DISABLED)
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('profile.push_error')
console.error('Failed to toggle push notifications:', err)
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
showAlert(message)
void loadPrefs()
} finally {
+108 -25
View File
@@ -1,9 +1,16 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18nLanguages.js'
import { decryptJson } from '../services/crypto.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import VesselForm from './VesselForm.tsx'
import CrewForm from './CrewForm.tsx'
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
import type { LogbookVesselSelectionData } from '../types/vessel.js'
import { emptyLogbookVesselSelection } from '../types/vessel.js'
import type { LogbookCrewSelectionData } from '../types/person.js'
import { emptyLogbookCrewSelection } from '../types/person.js'
import { legacyCrewRecordsToLogbookSelection } from '../utils/personSnapshots.js'
import type { PersonData } from '../types/person.js'
import LogEntriesList from './LogEntriesList.tsx'
import { Ship, Users, FileText, Lock, AlertCircle, Globe } from 'lucide-react'
@@ -30,9 +37,16 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
// Logbook data states
const [logbookTitle, setLogbookTitle] = useState('Logbook')
const [yacht, setYacht] = useState<any>(null)
const [crews, setCrews] = useState<any[]>([])
const [logbookCrewSelection, setLogbookCrewSelection] = useState<LogbookCrewSelectionData>(
emptyLogbookCrewSelection()
)
const [logbookVesselSelection, setLogbookVesselSelection] = useState<LogbookVesselSelectionData>(
emptyLogbookVesselSelection()
)
const [legacyCrews, setLegacyCrews] = useState<any[]>([])
const [entries, setEntries] = useState<any[]>([])
const [photos, setPhotos] = useState<any[]>([])
const [voiceMemos, setVoiceMemos] = useState<any[]>([])
const [gpsTracks, setGpsTracks] = useState<any[]>([])
useEffect(() => {
@@ -48,9 +62,9 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
const res = await fetch(`/api/collaboration/share-pull?token=${token}`)
if (!res.ok) {
if (res.status === 410) {
throw new Error(i18n.language.startsWith('de') ? 'Dieser Freigabelink ist abgelaufen.' : 'This share link has expired.')
throw new Error(isGermanLocale(i18n.language) ? 'Dieser Freigabelink ist abgelaufen.' : 'This share link has expired.')
}
throw new Error(i18n.language.startsWith('de') ? 'Fehler beim Laden des freigegebenen Logbuchs.' : 'Failed to fetch shared logbook data.')
throw new Error(isGermanLocale(i18n.language) ? 'Fehler beim Laden des freigegebenen Logbuchs.' : 'Failed to fetch shared logbook data.')
}
const data = await res.json()
@@ -70,18 +84,67 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
}
setYacht(decYacht)
// Decrypt Crews
const decCrews = []
if (data.crews) {
for (const c of data.crews) {
const dec = await decryptJson(c.encryptedData, c.iv, c.tag, keyBuffer)
decCrews.push({
payloadId: c.payloadId,
data: dec
if (data.logbookCrewSelection) {
const decSel = await decryptJson(
data.logbookCrewSelection.encryptedData,
data.logbookCrewSelection.iv,
data.logbookCrewSelection.tag,
keyBuffer
)
if (decSel) {
setLogbookCrewSelection({
activeSkipperId: decSel.activeSkipperId ?? null,
activeCrewIds: Array.isArray(decSel.activeCrewIds) ? decSel.activeCrewIds : [],
snapshotsById:
decSel.snapshotsById && typeof decSel.snapshotsById === 'object'
? decSel.snapshotsById
: {}
})
}
}
setCrews(decCrews)
if (data.logbookVesselSelection) {
const decVessel = await decryptJson(
data.logbookVesselSelection.encryptedData,
data.logbookVesselSelection.iv,
data.logbookVesselSelection.tag,
keyBuffer
)
if (decVessel) {
setLogbookVesselSelection({
activeVesselId: decVessel.activeVesselId ?? null,
vesselSnapshot: decVessel.vesselSnapshot ?? null
})
}
} else if (decYacht) {
const legacy = decYacht as Record<string, unknown>
setLogbookVesselSelection({
activeVesselId: 'legacy-yacht',
vesselSnapshot: {
id: 'legacy-yacht',
name: typeof legacy.name === 'string' ? legacy.name : '',
...legacy
} as import('../types/vessel.js').VesselSnapshot
})
}
const decCrews: Array<{ payloadId: string; data: PersonData }> = []
if (data.crews) {
for (const c of data.crews) {
const dec = await decryptJson(c.encryptedData, c.iv, c.tag, keyBuffer)
if (dec) {
decCrews.push({
payloadId: c.payloadId,
data: dec as PersonData
})
}
}
}
setLegacyCrews(decCrews)
if (!data.logbookCrewSelection && decCrews.length > 0) {
setLogbookCrewSelection(legacyCrewRecordsToLogbookSelection(decCrews))
}
// Decrypt Entries
const decEntries = []
@@ -112,6 +175,23 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
}
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
const decGpsTracks = []
if (data.gpsTracks) {
@@ -136,15 +216,14 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
}
const toggleLanguage = () => {
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
i18n.changeLanguage(nextLang)
cycleAppLanguage(i18n)
}
if (loading) {
return (
<div className="tab-placeholder" style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
<Ship className="header-logo spin" size={48} />
<p>{i18n.language.startsWith('de') ? 'Lade freigegebenes Logbuch...' : 'Loading shared logbook...'}</p>
<p>{isGermanLocale(i18n.language) ? 'Lade freigegebenes Logbuch...' : 'Loading shared logbook...'}</p>
</div>
)
}
@@ -153,10 +232,10 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
return (
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', padding: '20px', textAlign: 'center' }}>
<AlertCircle size={48} style={{ color: '#ef4444', marginBottom: '16px' }} />
<h2 style={{ color: '#f1f5f9', marginBottom: '8px' }}>{i18n.language.startsWith('de') ? 'Verbindungsfehler' : 'Access Error'}</h2>
<h2 style={{ color: '#f1f5f9', marginBottom: '8px' }}>{isGermanLocale(i18n.language) ? 'Verbindungsfehler' : 'Access Error'}</h2>
<p style={{ color: '#94a3b8', maxWidth: '400px', marginBottom: '24px' }}>{error}</p>
<button className="btn primary" onClick={loadData} style={{ width: 'auto' }}>
{i18n.language.startsWith('de') ? 'Erneut versuchen' : 'Retry'}
{isGermanLocale(i18n.language) ? 'Erneut versuchen' : 'Retry'}
</button>
</div>
)
@@ -173,7 +252,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
<h2>{logbookTitle}</h2>
<p className="app-subtitle" style={{ color: '#10b981', display: 'flex', alignItems: 'center', gap: '4px' }}>
<Lock size={12} />
<span>{i18n.language.startsWith('de') ? 'Schreibgeschützte Ansicht (Ende-zu-Ende verschlüsselt)' : 'Read-Only View (End-to-End Encrypted)'}</span>
<span>{isGermanLocale(i18n.language) ? 'Schreibgeschützte Ansicht (Ende-zu-Ende verschlüsselt)' : 'Read-Only View (End-to-End Encrypted)'}</span>
</p>
</div>
</div>
@@ -181,7 +260,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
<div className="header-actions">
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}>
<Globe size={14} style={{ marginRight: '4px' }} />
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
</div>
</header>
@@ -221,23 +300,27 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
preloadedYacht={yacht}
preloadedEntries={entries}
preloadedPhotos={photos}
preloadedVoiceMemos={voiceMemos}
preloadedGpsTracks={gpsTracks}
/>
)}
{activeTab === 'vessel' && (
<VesselForm
<LogbookVesselPicker
logbookId="shared"
readOnly={true}
preloadedData={yacht}
selectionOnly={true}
preloadedSelection={logbookVesselSelection}
/>
)}
{activeTab === 'crew' && (
<CrewForm
<LogbookCrewPicker
logbookId="shared"
readOnly={true}
preloadedData={crews}
selectionOnly={true}
preloadedPool={legacyCrews.length > 0 ? legacyCrews : undefined}
preloadedSelection={logbookCrewSelection}
/>
)}
</main>
@@ -28,19 +28,19 @@ export default function RegistrationDisclaimer({
className={`auth-card glass registration-disclaimer${variant === 'view' ? ' registration-disclaimer--modal' : ''}`}
role="document"
>
{variant === 'view' && (
<button
type="button"
className="registration-disclaimer__close"
onClick={onDismiss}
aria-label={t('disclaimer.close')}
>
<X size={18} />
</button>
)}
<div className="auth-header">
<ScrollText className="auth-icon accent" size={48} />
<h2>{t('disclaimer.title')}</h2>
{variant === 'view' && (
<button
type="button"
className="registration-disclaimer__close"
onClick={onDismiss}
aria-label={t('disclaimer.close')}
>
<X size={18} />
</button>
)}
</div>
<p className="registration-disclaimer__intro">{t('disclaimer.intro')}</p>
+91 -35
View File
@@ -3,9 +3,17 @@ import { useTranslation } from 'react-i18next'
import { Settings as SettingsIcon, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
import { ensureLogbookKey } from '../services/logbookKeys.js'
import LogbookBackupPanel from './LogbookBackupPanel.tsx'
import LinkQrCode from './LinkQrCode.tsx'
import { useDialog } from './ModalDialog.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { apiFetch } from '../services/api.js'
import {
enableCollaboratorChangePush,
isCollaboratorPushActive,
isPushSupported,
preloadPushService
} from '../services/pushNotifications.js'
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
interface SettingsFormProps {
logbookId?: string | null
@@ -48,6 +56,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
loadCollaborators()
loadShareLink()
}
void preloadPushService()
}, [logbookId])
const loadShareLink = async () => {
@@ -151,6 +160,44 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
}
}
const promptPushAfterInviteCreated = async () => {
if (!isPushSupported()) return
if (await isCollaboratorPushActive()) return
const iosNeedsInstall = isIosDevice() && !isRunningStandalone()
if (iosNeedsInstall) {
await showAlert(
t('settings.invite_push_prompt_ios_message'),
t('settings.invite_push_prompt_title'),
t('settings.invite_push_prompt_later')
)
return
}
const enable = await showConfirm(
t('settings.invite_push_prompt_message'),
t('settings.invite_push_prompt_title'),
t('settings.invite_push_prompt_enable'),
t('settings.invite_push_prompt_later')
)
if (!enable) return
try {
await enableCollaboratorChangePush()
await showAlert(
t('settings.invite_push_prompt_success'),
t('settings.invite_push_prompt_title')
)
trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED)
} catch (err: unknown) {
console.error('Failed to enable push after invite:', err)
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
await showAlert(message)
}
}
const handleGenerateInvite = async () => {
if (!logbookId) return
setGeneratingInvite(true)
@@ -175,6 +222,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
setInviteLink(link)
trackPlausibleEvent(PlausibleEvents.INVITE_GENERATED)
await promptPushAfterInviteCreated()
} catch (err: unknown) {
console.error('Failed to generate invite:', err)
showAlert(err instanceof Error ? err.message : 'Failed to generate invite link.')
@@ -270,23 +318,27 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</div>
{shareEnabled && shareLink && (
<div className="input-group mb-4 copy-link-row">
<input
type="text"
readOnly
value={shareLink}
className="input-text font-mono text-xs"
style={{ flex: 1, padding: '10px' }}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
className="btn secondary"
onClick={handleCopyShareLink}
style={{ width: 'auto', padding: '10px' }}
>
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
<div className="link-with-qr mb-4">
<div className="input-group copy-link-row">
<input
type="text"
readOnly
value={shareLink}
className="input-text font-mono text-xs"
style={{ flex: 1, padding: '10px' }}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
className="btn secondary"
onClick={handleCopyShareLink}
style={{ width: 'auto', padding: '10px' }}
title={t('settings.share_copy_btn')}
>
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
<LinkQrCode value={shareLink} />
</div>
)}
</div>
@@ -323,23 +375,27 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</div>
{inviteLink && (
<div className="input-group mb-6 copy-link-row">
<input
type="text"
readOnly
value={inviteLink}
className="input-text font-mono text-xs"
style={{ flex: 1, padding: '10px' }}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
className="btn secondary"
onClick={handleCopyInvite}
style={{ width: 'auto', padding: '10px' }}
>
{inviteCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
<div className="link-with-qr mb-6">
<div className="input-group copy-link-row">
<input
type="text"
readOnly
value={inviteLink}
className="input-text font-mono text-xs"
style={{ flex: 1, padding: '10px' }}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
className="btn secondary"
onClick={handleCopyInvite}
style={{ width: 'auto', padding: '10px' }}
title={t('settings.share_copy_btn')}
>
{inviteCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
<LinkQrCode value={inviteLink} />
</div>
)}
@@ -373,7 +429,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
<td>
<button
type="button"
className="btn-icon logout"
className="btn-icon danger"
onClick={() => handleRevoke(c.id, c.username)}
title="Revoke access"
>
+72 -6
View File
@@ -14,6 +14,12 @@ import {
} from '../services/statsAggregation.js'
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
import { formatAppDecimal } from '../utils/numberFormat.js'
import {
loadLogbookEventSeries,
type EventSeriesPoint,
type EventSeriesSummary
} from '../services/eventSeriesAggregation.js'
interface StatsDashboardProps {
logbookId: string
@@ -206,8 +212,8 @@ function PropulsionBreakdown({ totals }: { totals: StatsTotals }) {
)}
</div>
<div className="stats-propulsion-labels">
<span>{t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({sailPct.toFixed(0)}%)</span>
<span>{t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({motorPct.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')} ({formatAppDecimal(motorPct, { maximumFractionDigits: 0 })}%)</span>
{totals.unknownPropulsionNm > 0 && (
<span>{t('stats.unknown_propulsion')}: {formatNm(totals.unknownPropulsionNm)} {t('stats.unit_nm')}</span>
)}
@@ -217,7 +223,62 @@ function PropulsionBreakdown({ totals }: { totals: StatsTotals }) {
)
}
function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
function EventSeriesList({ title, points, emptyLabel }: { title: string; points: EventSeriesPoint[]; emptyLabel: string }) {
if (points.length === 0) {
return (
<div className="stats-event-series-block">
<h4 className="stats-section-subtitle">{title}</h4>
<p className="stats-section-sub">{emptyLabel}</p>
</div>
)
}
return (
<div className="stats-event-series-block">
<h4 className="stats-section-subtitle">{title}</h4>
<ul className="stats-event-series-list">
{points.map((point, idx) => (
<li key={`${point.entryId}-${point.time}-${idx}`} className="stats-event-series-item">
<span className="stats-event-series-when">
{new Date(point.date).toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })}
{' · '}
{point.time}
</span>
<span className="stats-event-series-value">{point.summary}</span>
</li>
))}
</ul>
</div>
)
}
function EventSeriesPanel({ series }: { series: EventSeriesSummary }) {
const { t } = useTranslation()
const motorPoints = series.motor.map((point) => ({
...point,
summary: point.summary === 'start'
? t('logs.live_motor_start')
: t('logs.live_motor_stop')
}))
return (
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.event_series_title')}</h3>
<p className="stats-section-sub">{t('stats.event_series_hint')}</p>
<EventSeriesList title={t('stats.event_series_pressure')} points={series.pressure} emptyLabel={t('stats.event_series_empty')} />
<EventSeriesList title={t('stats.event_series_wind')} points={series.wind} emptyLabel={t('stats.event_series_empty')} />
<EventSeriesList title={t('stats.event_series_motor')} points={motorPoints} emptyLabel={t('stats.event_series_empty')} />
</div>
)
}
function LogbookScopeView({
summary,
eventSeries
}: {
summary: LogbookStatsSummary
eventSeries: EventSeriesSummary | null
}) {
const { t } = useTranslation()
const { travelDays, routePorts, trackSegments, totals } = summary
@@ -313,6 +374,8 @@ function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
<h3 className="stats-section-title">{t('stats.propulsion_title')}</h3>
<PropulsionBreakdown totals={totals} />
</div>
{eventSeries && <EventSeriesPanel series={eventSeries} />}
</>
)
}
@@ -323,18 +386,21 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [logbookStats, setLogbookStats] = useState<LogbookStatsSummary | null>(null)
const [eventSeries, setEventSeries] = useState<EventSeriesSummary | null>(null)
const [accountStats, setAccountStats] = useState<Awaited<ReturnType<typeof loadAccountStats>> | null>(null)
const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [lb, acc] = await Promise.all([
const [lb, acc, series] = await Promise.all([
loadLogbookStats(logbookId, logbookTitle, true),
loadAccountStats(false)
loadAccountStats(false),
loadLogbookEventSeries(logbookId)
])
setLogbookStats(lb)
setAccountStats(acc)
setEventSeries(series)
} catch (err: unknown) {
console.error('Failed to load statistics:', err)
setError(err instanceof Error ? err.message : 'Failed to load statistics.')
@@ -397,7 +463,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
<p>{t('stats.loading')}</p>
</div>
) : scope === 'logbook' && logbookStats ? (
<LogbookScopeView summary={logbookStats} />
<LogbookScopeView summary={logbookStats} eventSeries={eventSeries} />
) : scope === 'account' && accountStats ? (
<>
<TotalsGrid totals={accountStats.totals} />
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AlertTriangle } from 'lucide-react'
import {
getSyncConflicts,
subscribeSyncConflicts,
type SyncConflict
} from '../services/syncConflicts.js'
import {
resolveSyncConflictKeepLocal,
resolveSyncConflictUseServer
} from '../services/sync.js'
interface SyncConflictBannerProps {
logbookId: string | null
}
export default function SyncConflictBanner({ logbookId }: SyncConflictBannerProps) {
const { t } = useTranslation()
const [items, setItems] = useState<SyncConflict[]>([])
useEffect(() => {
const refresh = () => {
setItems(logbookId ? getSyncConflicts(logbookId) : getSyncConflicts())
}
refresh()
return subscribeSyncConflicts(refresh)
}, [logbookId])
if (items.length === 0) return null
const first = items[0]
return (
<div className="sync-conflict-banner" role="alert">
<AlertTriangle size={20} aria-hidden />
<div className="sync-conflict-banner__body">
<strong>{t('sync.conflict_title')}</strong>
<p>
{t('sync.conflict_message', {
count: items.length,
id: first.payloadId.slice(0, 8)
})}
</p>
<div className="sync-conflict-banner__actions">
<button
type="button"
className="btn secondary"
onClick={() => void resolveSyncConflictUseServer(first)}
>
{t('sync.conflict_use_server')}
</button>
<button
type="button"
className="btn primary"
onClick={() => void resolveSyncConflictKeepLocal(first)}
>
{t('sync.conflict_keep_local')}
</button>
</div>
</div>
</div>
)
}
+101
View File
@@ -0,0 +1,101 @@
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { clampTankLiters } from '../utils/tankCapacity.js'
import { formatTankLiters, parseAppDecimalOrZero } from '../utils/numberFormat.js'
interface TankLiterInputProps {
id?: string
label: string
value: string
onChange: (value: string) => void
maxLiters?: number
disabled?: boolean
titleTooltip?: string
}
function parseInputLiters(value: string): number {
if (!value.trim()) return 0
return parseAppDecimalOrZero(value)
}
export default function TankLiterInput({
id,
label,
value,
onChange,
maxLiters,
disabled = false,
titleTooltip
}: TankLiterInputProps) {
const { t } = useTranslation()
const useSlider = maxLiters != null && maxLiters > 0
const emitValue = useCallback(
(liters: number) => {
const clamped = clampTankLiters(liters, useSlider ? maxLiters : undefined)
const str = formatTankLiters(clamped)
onChange(str)
},
[onChange, maxLiters, useSlider]
)
const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value)
}
const handleNumberBlur = () => {
if (!useSlider) return
emitValue(parseInputLiters(value))
}
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
emitValue(Number(e.target.value))
}
const numericValue = parseInputLiters(value)
const sliderValue = useSlider ? clampTankLiters(numericValue, maxLiters) : 0
return (
<div className="input-group tank-liter-input">
<label htmlFor={id} title={titleTooltip}>{label}</label>
{useSlider && (
<>
<input
type="range"
className="tank-liter-slider"
min={0}
max={maxLiters}
step={1}
value={sliderValue}
onChange={handleSliderChange}
disabled={disabled}
title={titleTooltip}
aria-valuemin={0}
aria-valuemax={maxLiters}
aria-valuenow={sliderValue}
aria-label={label}
/>
<div className="tank-liter-slider-hint" aria-hidden="true">
{t('logs.tank_slider_of_max', {
current: sliderValue,
max: maxLiters
})}
</div>
</>
)}
<input
id={id}
type="number"
className="input-text"
value={value}
onChange={handleNumberChange}
onBlur={handleNumberBlur}
disabled={disabled}
min={0}
max={useSlider ? maxLiters : undefined}
step="any"
title={titleTooltip}
/>
</div>
)
}
+53 -5
View File
@@ -15,6 +15,7 @@ import {
Anchor,
Gauge,
Sailboat,
Ship,
Timer,
Share2,
Calendar,
@@ -30,6 +31,10 @@ import {
} from 'lucide-react'
import AccountDangerZone from './AccountDangerZone.tsx'
import UserProfilePreferences from './UserProfilePreferences.tsx'
import PersonPoolForm from './PersonPoolForm.tsx'
import VesselPoolForm from './VesselPoolForm.tsx'
import ProfileAccordionSection from './ProfileAccordionSection.tsx'
import { useAppTour } from '../context/AppTourContext.tsx'
import BetaBadge from './BetaBadge.tsx'
import { useDialog } from './ModalDialog.tsx'
import {
@@ -136,6 +141,11 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
connStatusClassName
} = useSyncIndicator()
const { isActive: tourActive, currentStepId: tourStepId } = useAppTour()
const fleetSectionTourOpen =
tourActive &&
(tourStepId === 'profile_vessel_pool' || tourStepId === 'profile_crew_pool')
const sharedLogbookCount = useLiveQuery(
() => db.logbooks.filter((lb) => lb.isShared === 1).count(),
[]
@@ -443,7 +453,14 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
</section>
) : profile ? (
<>
<section className="form-card">
<ProfileAccordionSection
id="account"
title={t('profile.sections.account')}
icon={<User size={20} aria-hidden="true" />}
defaultOpen
>
<div data-tour="profile-preferences">
<section className="form-card profile-accordion-inner-card">
<div className="form-header">
<User size={24} className="form-icon" />
<h2>{t('profile.identity_title')}</h2>
@@ -484,8 +501,26 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
</section>
<UserProfilePreferences userId={profile.userId} />
</div>
</ProfileAccordionSection>
<section className="member-editor-card glass">
<ProfileAccordionSection
id="fleet"
title={t('profile.sections.fleet')}
icon={<Ship size={20} aria-hidden="true" />}
defaultOpen
forceOpen={fleetSectionTourOpen ? true : undefined}
>
<VesselPoolForm />
<PersonPoolForm />
</ProfileAccordionSection>
<ProfileAccordionSection
id="security"
title={t('profile.sections.security')}
icon={<Shield size={20} aria-hidden="true" />}
>
<section className="member-editor-card glass profile-accordion-inner-card">
<div className="profile-section-header">
<Shield size={20} />
<h3>{t('profile.security_title')}</h3>
@@ -724,7 +759,14 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
</div>
</section>
<section className="form-card">
</ProfileAccordionSection>
<ProfileAccordionSection
id="stats"
title={t('profile.sections.stats')}
icon={<BarChart2 size={20} aria-hidden="true" />}
>
<section className="form-card profile-stats-section profile-accordion-inner-card">
<div className="form-header">
<BarChart2 size={24} className="form-icon" />
<div>
@@ -734,7 +776,7 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
</div>
{(statsTotals || profile) && (
<div className="stats-kpi-grid">
<div className="stats-kpi-grid profile-stats-kpi-grid">
<KpiCard
icon={<BookOpen size={20} />}
label={t('profile.stats_logbooks')}
@@ -786,8 +828,14 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
</div>
)}
</section>
</ProfileAccordionSection>
<AccountDangerZone className="mt-6" />
<ProfileAccordionSection
id="danger"
title={t('profile.sections.danger')}
>
<AccountDangerZone className="profile-accordion-inner-card" />
</ProfileAccordionSection>
</>
) : null}
</main>
@@ -5,6 +5,7 @@ import ThemedSelect from './ThemedSelect.tsx'
import PushNotificationSettings from './PushNotificationSettings.tsx'
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
import { notifyAppearanceChanged } from '../services/appearance.js'
import { saveAppearancePrefsToServer } from '../services/appearancePrefs.js'
import { useAppTour } from '../context/AppTourContext.tsx'
import {
getColorSchemePreference,
@@ -32,6 +33,9 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
setThemePreference(userId, nextTheme)
setColorSchemePreference(userId, nextColorScheme)
notifyAppearanceChanged()
void saveAppearancePrefsToServer(nextTheme, nextColorScheme).catch((err) => {
console.warn('Failed to save appearance prefs to server:', err)
})
}
const handleThemeChange = (nextTheme: string) => {
+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>
)
}
+65 -2
View File
@@ -7,6 +7,7 @@ import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { Ship, Save, Check, Plus, X, Camera, Trash2 } from 'lucide-react'
import { parseOptionalTankLiters, tankCapacityInputFromStored } from '../utils/tankCapacity.js'
interface VesselFormProps {
logbookId: string
@@ -47,6 +48,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
const [mmsi, setMmsi] = useState('')
const [sails, setSails] = useState<string[]>([])
const [newSailName, setNewSailName] = useState('')
const [freshwaterCapacityL, setFreshwaterCapacityL] = useState('')
const [fuelCapacityL, setFuelCapacityL] = useState('')
const [greywaterCapacityL, setGreywaterCapacityL] = useState('')
const fileInputRef = React.useRef<HTMLInputElement>(null)
const [photo, setPhoto] = useState<string | null>(null)
@@ -78,6 +82,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
setMmsi(preloadedData.mmsi || '')
setSails(preloadedData.sails || [])
setPhoto(preloadedData.photo || null)
setFreshwaterCapacityL(tankCapacityInputFromStored(preloadedData.freshwaterCapacityL))
setFuelCapacityL(tankCapacityInputFromStored(preloadedData.fuelCapacityL))
setGreywaterCapacityL(tankCapacityInputFromStored(preloadedData.greywaterCapacityL))
return
}
@@ -103,6 +110,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
setMmsi(decrypted.mmsi || '')
setSails(decrypted.sails || [])
setPhoto(decrypted.photo || null)
setFreshwaterCapacityL(tankCapacityInputFromStored(decrypted.freshwaterCapacityL))
setFuelCapacityL(tankCapacityInputFromStored(decrypted.fuelCapacityL))
setGreywaterCapacityL(tankCapacityInputFromStored(decrypted.greywaterCapacityL))
}
}
} catch (err: any) {
@@ -201,12 +211,19 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
let parsedLengthM: number | undefined
let parsedDraftM: number | undefined
let parsedAirDraftM: number | undefined
let parsedFreshwaterCapacityL: number | undefined
let parsedFuelCapacityL: number | undefined
let parsedGreywaterCapacityL: number | undefined
try {
parsedLengthM = parseOptionalMetricMeters(lengthM)
parsedDraftM = parseOptionalMetricMeters(draftM)
parsedAirDraftM = parseOptionalMetricMeters(airDraftM)
} catch {
setError(t('vessel.invalid_metric'))
parsedFreshwaterCapacityL = parseOptionalTankLiters(freshwaterCapacityL)
parsedFuelCapacityL = parseOptionalTankLiters(fuelCapacityL)
parsedGreywaterCapacityL = parseOptionalTankLiters(greywaterCapacityL)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : ''
setError(msg === 'invalid_tank_liters' ? t('vessel.invalid_tank_liters') : t('vessel.invalid_metric'))
setSaving(false)
return
}
@@ -217,6 +234,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
lengthM: parsedLengthM,
draftM: parsedDraftM,
airDraftM: parsedAirDraftM,
freshwaterCapacityL: parsedFreshwaterCapacityL,
fuelCapacityL: parsedFuelCapacityL,
greywaterCapacityL: parsedGreywaterCapacityL,
homePort: homePort.trim(),
charterCompany: charterCompany.trim(),
owner: owner.trim(),
@@ -480,6 +500,49 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
/>
</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={freshwaterCapacityL}
onChange={(e) => setFreshwaterCapacityL(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={fuelCapacityL}
onChange={(e) => setFuelCapacityL(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={greywaterCapacityL}
onChange={(e) => setGreywaterCapacityL(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>
+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>
)
}
+113
View File
@@ -0,0 +1,113 @@
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)
}
if (src) {
el.load()
}
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="metadata" src={src} />
</div>
)
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import {
DEMO_EXCLUDED_STEPS,
DEMO_STEP_ORDER,
FULL_STEP_ORDER,
getTourScrollRetryDelays,
getTourTargetRetryDelay,
tourStepOpensEntry
} from './AppTourContext.tsx'
describe('AppTourContext step order', () => {
it('includes profile steps before finish in full tour', () => {
const profileIndex = FULL_STEP_ORDER.indexOf('nav_profile')
const prefsIndex = FULL_STEP_ORDER.indexOf('profile_preferences')
const finishIndex = FULL_STEP_ORDER.indexOf('finish')
expect(profileIndex).toBeGreaterThan(FULL_STEP_ORDER.indexOf('nav_feedback'))
expect(prefsIndex).toBe(profileIndex + 1)
expect(finishIndex).toBe(prefsIndex + 1)
expect(FULL_STEP_ORDER).toContain('profile_vessel_pool')
expect(FULL_STEP_ORDER).toContain('profile_crew_pool')
expect(FULL_STEP_ORDER).toContain('nav_logbook_crew')
expect(FULL_STEP_ORDER.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', () => {
for (const step of DEMO_EXCLUDED_STEPS) {
expect(DEMO_STEP_ORDER).not.toContain(step)
}
expect(DEMO_STEP_ORDER).toContain('finish')
expect(DEMO_STEP_ORDER).toHaveLength(FULL_STEP_ORDER.length - DEMO_EXCLUDED_STEPS.length)
})
it('only opens entry editor on entry_track step', () => {
expect(tourStepOpensEntry('entry_open')).toBe(false)
expect(tourStepOpensEntry('entry_list')).toBe(false)
expect(tourStepOpensEntry('entry_track')).toBe(true)
})
it('retries scroll for entry_track while editor mounts', () => {
expect(getTourTargetRetryDelay('entry_track')).toBeGreaterThanOrEqual(400)
expect(getTourScrollRetryDelays('entry_track').length).toBeGreaterThan(1)
})
})
+144 -19
View File
@@ -26,15 +26,22 @@ export type TourStepId =
| 'entry_open'
| 'entry_track'
| 'nav_vessel'
| 'nav_crew'
| 'profile_vessel_pool'
| 'profile_crew_pool'
| 'nav_logbook_crew'
| 'nav_stats'
| 'nav_feedback'
| 'nav_profile'
| 'profile_preferences'
| 'finish'
interface TourNavigation {
setActiveTab: (tab: AppTab) => void
setSelectedEntryId: (entryId: string | null) => void
setFeedbackOpen: (open: boolean) => void
setLogbookActive: (active: boolean) => void
setProfileOpen: (open: boolean) => void
ensureLogbookForTour?: () => Promise<void>
}
interface DemoTourContext {
@@ -47,6 +54,7 @@ interface AppTourContextValue {
currentStepId: TourStepId | null
currentStepIndex: number
totalSteps: number
layoutTick: number
startTour: (options?: { force?: boolean; demoMode?: boolean }) => void
stopTour: () => void
restartTour: () => void
@@ -58,22 +66,46 @@ interface AppTourContextValue {
requestStartAfterLogin: () => void
}
const FULL_STEP_ORDER: TourStepId[] = [
export const FULL_STEP_ORDER: TourStepId[] = [
'welcome',
'nav_logs',
'entry_list',
'entry_open',
'entry_track',
'nav_vessel',
'nav_crew',
'profile_vessel_pool',
'profile_crew_pool',
'nav_logbook_crew',
'nav_stats',
'nav_feedback',
'nav_profile',
'profile_preferences',
'finish'
]
/** Public demo has no stats/feedback UI — skip those steps. */
const DEMO_EXCLUDED_STEPS: TourStepId[] = ['nav_stats', 'nav_feedback']
const DEMO_STEP_ORDER: TourStepId[] = FULL_STEP_ORDER.filter((id) => !DEMO_EXCLUDED_STEPS.includes(id))
/** Public demo has no stats/feedback/profile UI — skip those steps. */
export const DEMO_EXCLUDED_STEPS: TourStepId[] = [
'profile_crew_pool',
'nav_stats',
'nav_feedback',
'nav_profile',
'profile_preferences'
]
export const DEMO_STEP_ORDER: TourStepId[] = FULL_STEP_ORDER.filter(
(id) => !DEMO_EXCLUDED_STEPS.includes(id)
)
const LOGBOOK_TOUR_STEPS = new Set<TourStepId>([
'nav_logs',
'entry_list',
'entry_open',
'entry_track',
'nav_vessel',
'nav_logbook_crew',
'nav_stats',
'nav_feedback'
])
function getStepOrder(demoMode: boolean): TourStepId[] {
return demoMode ? DEMO_STEP_ORDER : FULL_STEP_ORDER
@@ -85,9 +117,39 @@ const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
entry_open: '[data-tour="entry-first"]',
entry_track: '[data-tour="entry-track"]',
nav_vessel: '[data-tour="nav-vessel"]',
nav_crew: '[data-tour="nav-crew"]',
profile_vessel_pool: '[data-tour="profile-vessel-pool"]',
profile_crew_pool: '[data-tour="profile-crew-pool"]',
nav_logbook_crew: '[data-tour="nav-logbook-crew"]',
nav_stats: '[data-tour="stats-dashboard"]',
nav_feedback: '[data-tour="feedback-form"]'
nav_feedback: '[data-tour="feedback-form"]',
nav_profile: '[data-tour="nav-profile"]',
profile_preferences: '[data-tour="profile-preferences"]'
}
/** Whether a tour step opens the first log entry editor (not the list card). */
export function tourStepOpensEntry(stepId: TourStepId): boolean {
return stepId === 'entry_track'
}
export function getTourTargetDelay(stepId: TourStepId): number {
if (stepId === 'entry_track') return 400
if (stepId === 'nav_feedback') return 180
if (
stepId === 'nav_profile' ||
stepId === 'profile_preferences' ||
stepId === 'profile_vessel_pool' ||
stepId === 'profile_crew_pool'
) {
return 250
}
return 0
}
/** Extra scroll attempts while async UI (e.g. entry editor) mounts. */
export function getTourScrollRetryDelays(stepId: TourStepId): number[] {
if (stepId === 'entry_track') return [400, 700, 1100, 1600]
const initial = getTourTargetDelay(stepId)
return initial > 0 ? [initial] : [0]
}
const AppTourContext = createContext<AppTourContextValue | null>(null)
@@ -97,6 +159,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
const [stepIndex, setStepIndex] = useState(0)
const [pendingAfterLogin, setPendingAfterLogin] = useState(false)
const [isDemoTour, setIsDemoTour] = useState(false)
const [layoutTick, setLayoutTick] = useState(0)
const navigationRef = useRef<TourNavigation | null>(null)
const demoContextRef = useRef<DemoTourContext | null>(null)
const tourModeRef = useRef<{ demoMode: boolean }>({ demoMode: false })
@@ -112,19 +175,37 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
const nav = navigationRef.current
if (!nav) return
if (LOGBOOK_TOUR_STEPS.has(stepId)) {
nav.setProfileOpen(false)
nav.setLogbookActive(true)
}
if (stepId === 'nav_logs' || stepId === 'entry_list' || stepId === 'entry_open' || stepId === 'entry_track') {
nav.setActiveTab('logs')
}
if (stepId === 'entry_open' || stepId === 'entry_track') {
if (stepId === 'entry_list' || stepId === 'entry_open') {
nav.setSelectedEntryId(null)
} else if (tourStepOpensEntry(stepId)) {
const firstEntryId = resolveFirstEntryId()
if (firstEntryId) nav.setSelectedEntryId(firstEntryId)
} else if (LOGBOOK_TOUR_STEPS.has(stepId)) {
nav.setSelectedEntryId(null)
}
if (stepId === 'nav_vessel') {
nav.setSelectedEntryId(null)
nav.setActiveTab('vessel')
}
if (stepId === 'nav_crew') {
if (stepId === 'profile_vessel_pool' || stepId === 'profile_crew_pool') {
nav.setSelectedEntryId(null)
nav.setLogbookActive(false)
nav.setProfileOpen(true)
}
if (stepId === 'nav_logbook_crew') {
nav.setSelectedEntryId(null)
nav.setProfileOpen(false)
nav.setLogbookActive(true)
nav.setActiveTab('crew')
}
if (stepId === 'nav_stats') {
@@ -137,19 +218,34 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
} else {
nav.setFeedbackOpen(false)
}
if (stepId === 'nav_profile') {
nav.setProfileOpen(false)
nav.setLogbookActive(false)
}
if (stepId === 'profile_preferences') {
nav.setLogbookActive(false)
nav.setProfileOpen(true)
}
}, [resolveFirstEntryId])
const scrollToCurrentTarget = useCallback((stepId: TourStepId | null) => {
if (!stepId) return
const selector = TARGET_BY_STEP[stepId]
if (!selector) return
const delayMs = stepId === 'nav_feedback' ? 180 : 0
window.setTimeout(() => {
window.requestAnimationFrame(() => {
const el = document.querySelector(selector)
el?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
})
}, delayMs)
for (const delayMs of getTourScrollRetryDelays(stepId)) {
window.setTimeout(() => {
window.requestAnimationFrame(() => {
const el = document.querySelector(selector)
el?.scrollIntoView({
behavior: stepId === 'entry_track' ? 'instant' : 'smooth',
block: 'center',
inline: 'nearest'
})
})
}, delayMs)
}
}, [])
const startTour = useCallback((options?: { force?: boolean; demoMode?: boolean }) => {
@@ -173,6 +269,8 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED, tourProps)
const nav = navigationRef.current
if (nav && !tourModeRef.current.demoMode) {
nav.setProfileOpen(false)
nav.setLogbookActive(true)
nav.setSelectedEntryId(null)
nav.setActiveTab('stats')
}
@@ -183,6 +281,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
tourModeRef.current = { demoMode: false }
navigationRef.current?.setFeedbackOpen(false)
navigationRef.current?.setProfileOpen(false)
setIsDemoTour(false)
setIsActive(false)
setStepIndex(0)
@@ -213,8 +312,25 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
if (!isActive) return
const stepId = getStepOrder(isDemoTour)[stepIndex]
if (!stepId) return
applyStepSideEffects(stepId)
scrollToCurrentTarget(stepId)
let cancelled = false
const run = async () => {
if (LOGBOOK_TOUR_STEPS.has(stepId) && !isDemoTour) {
await navigationRef.current?.ensureLogbookForTour?.()
}
if (cancelled) return
applyStepSideEffects(stepId)
scrollToCurrentTarget(stepId)
setLayoutTick((tick) => tick + 1)
window.setTimeout(() => {
if (!cancelled) setLayoutTick((tick) => tick + 1)
}, 150)
}
void run()
return () => {
cancelled = true
}
}, [isActive, isDemoTour, stepIndex, applyStepSideEffects, scrollToCurrentTarget])
const restartTour = useCallback(() => {
@@ -257,6 +373,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
currentStepId,
currentStepIndex: stepIndex,
totalSteps: stepOrder.length,
layoutTick,
startTour,
stopTour,
restartTour,
@@ -281,6 +398,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
startTour,
stepIndex,
stepOrder.length,
layoutTick,
stopTour
]
)
@@ -321,3 +439,10 @@ export function getTourTargetSelector(stepId: TourStepId | null): string | null
export function isCenteredTourStep(stepId: TourStepId | null): boolean {
return stepId === 'welcome' || stepId === 'finish'
}
export function getTourTargetRetryDelay(stepId: TourStepId | null): number {
if (stepId === 'entry_track') return 400
if (stepId === 'profile_preferences') return 300
if (stepId === 'nav_profile') return 200
return 120
}
+53 -8
View File
@@ -12,6 +12,7 @@ import { useDialog } from '../components/ModalDialog.tsx'
interface UnsavedChangesContextValue {
setDirty: (source: string, dirty: boolean) => void
registerSaveHandler: (source: string, handler: (() => Promise<void>) | null) => void
confirmLeave: () => Promise<boolean>
}
@@ -19,23 +20,51 @@ const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(n
export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation()
const { showConfirm } = useDialog()
const { showConfirmLeave, showAlert } = useDialog()
const dirtySources = useRef(new Set<string>())
const saveHandlers = useRef(new Map<string, () => Promise<void>>())
const setDirty = useCallback((source: string, dirty: boolean) => {
if (dirty) dirtySources.current.add(source)
else dirtySources.current.delete(source)
}, [])
const registerSaveHandler = useCallback((source: string, handler: (() => Promise<void>) | null) => {
if (handler) saveHandlers.current.set(source, handler)
else saveHandlers.current.delete(source)
}, [])
const confirmLeave = useCallback(async (): Promise<boolean> => {
if (dirtySources.current.size === 0) return true
return showConfirm(
const canSave = [...dirtySources.current].some((source) => saveHandlers.current.has(source))
const choice = await showConfirmLeave(
t('common.unsaved_changes_message'),
t('common.unsaved_changes_title'),
t('common.unsaved_changes_leave'),
t('common.unsaved_changes_stay')
t('common.unsaved_changes_stay'),
t('common.unsaved_changes_save_leave'),
t('common.unsaved_changes_discard'),
{ showSave: canSave }
)
}, [showConfirm, t])
if (choice === 'stay') return false
if (choice === 'discard') return true
const handlers = [...dirtySources.current]
.map((source) => saveHandlers.current.get(source))
.filter((handler): handler is () => Promise<void> => handler != null)
try {
for (const handler of handlers) {
await handler()
}
return true
} catch (err) {
console.error('Failed to save before leaving:', err)
await showAlert(t('errors.save_failed'))
return false
}
}, [showConfirmLeave, showAlert, t])
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
@@ -47,7 +76,10 @@ export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
return () => window.removeEventListener('beforeunload', handler)
}, [])
const value = useMemo(() => ({ setDirty, confirmLeave }), [setDirty, confirmLeave])
const value = useMemo(
() => ({ setDirty, registerSaveHandler, confirmLeave }),
[setDirty, registerSaveHandler, confirmLeave]
)
return (
<UnsavedChangesContext.Provider value={value}>
@@ -65,13 +97,26 @@ export function useUnsavedChangesContext(): UnsavedChangesContextValue {
}
/** Register a form/view as having unsaved changes (cleared automatically on unmount). */
export function useRegisterUnsavedChanges(source: string, isDirty: boolean) {
const { setDirty, confirmLeave } = useUnsavedChangesContext()
export function useRegisterUnsavedChanges(
source: string,
isDirty: boolean,
onSave?: () => Promise<void>
) {
const { setDirty, registerSaveHandler, confirmLeave } = useUnsavedChangesContext()
useEffect(() => {
setDirty(source, isDirty)
return () => setDirty(source, false)
}, [source, isDirty, setDirty])
useEffect(() => {
if (!onSave) {
registerSaveHandler(source, null)
return
}
registerSaveHandler(source, onSave)
return () => registerSaveHandler(source, null)
}, [source, onSave, registerSaveHandler])
return { confirmLeave }
}
+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
}
+94 -23
View File
@@ -1,13 +1,19 @@
import { useEffect, useRef } from 'react'
import { useRegisterSW } from 'virtual:pwa-register/react'
import {
forcePwaRecovery,
markReloadAttempt,
recentlyAttemptedReload,
triggerServiceWorkerUpdate
} from '../services/pwaStartup.js'
import { isDeployedVersionNewer } from '../services/pwaVersion.js'
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000
const UPDATE_CHECK_INTERVAL_MS = 15 * 60 * 1000
const UPDATE_SUPPRESS_KEY = 'pwa_update_suppress_until'
const UPDATE_SUPPRESS_MS = 30_000
const UPDATE_DISMISS_SUPPRESS_MS = 60 * 60 * 1000
const UPDATE_RELOAD_FALLBACK_MS = 2000
/** Prevent Android PWA cold-start reload loops from onNeedReload. */
const PWA_INITIAL_RELOAD_KEY = 'pwa_sw_initial_reload_done'
const UPDATE_DISMISS_SUPPRESS_MS = 15 * 60 * 1000
const UPDATE_RELOAD_FALLBACK_MS = 2_000
const UPDATE_HARD_RECOVERY_MS = 5_000
function isUpdateSuppressed(): boolean {
const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0')
@@ -22,29 +28,64 @@ function clearUpdateSuppression(): void {
sessionStorage.removeItem(UPDATE_SUPPRESS_KEY)
}
function scheduleUpdateChecks(registration: ServiceWorkerRegistration): () => void {
function scheduleUpdateChecks(
registration: ServiceWorkerRegistration,
onOutdated: () => void
): () => void {
const checkForUpdate = () => {
if (isUpdateSuppressed()) return
registration.update().catch(() => {})
void isDeployedVersionNewer().then((outdated) => {
if (outdated) onOutdated()
})
}
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') {
checkForUpdate()
// Delay check on wake-up to allow the mobile network stack to stabilize
setTimeout(checkForUpdate, 2000)
}
}
const onOnline = () => {
// Small delay to ensure connection is fully established
setTimeout(checkForUpdate, 500)
}
document.addEventListener('visibilitychange', onVisibilityChange)
const intervalId = window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS)
window.addEventListener('online', onOnline)
const updateIntervalId = window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS)
checkForUpdate()
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange)
window.clearInterval(intervalId)
window.removeEventListener('online', onOnline)
window.clearInterval(updateIntervalId)
}
}
function reloadForServiceWorkerTakeover(): void {
if (recentlyAttemptedReload()) return
markReloadAttempt()
clearUpdateSuppression()
window.location.reload()
}
export function usePwaUpdate() {
const cleanupRef = useRef<(() => void) | null>(null)
const reloadFallbackTimerRef = useRef<number | null>(null)
const forceRecoveryTimerRef = useRef<number | null>(null)
const setNeedRefreshRef = useRef<((value: boolean) => void) | null>(null)
const pendingNeedRefreshRef = useRef<boolean | null>(null)
const applyNeedRefresh = (value: boolean) => {
if (setNeedRefreshRef.current) {
setNeedRefreshRef.current(value)
return
}
pendingNeedRefreshRef.current = value
}
const {
needRefresh: [needRefresh, setNeedRefresh],
@@ -52,39 +93,56 @@ export function usePwaUpdate() {
} = useRegisterSW({
immediate: !import.meta.env.DEV,
onNeedReload() {
// First SW takeover requires one reload; guard against repeated reloads on Android PWA resume.
if (sessionStorage.getItem(PWA_INITIAL_RELOAD_KEY)) {
return
}
sessionStorage.setItem(PWA_INITIAL_RELOAD_KEY, '1')
clearUpdateSuppression()
setNeedRefresh(false)
window.location.reload()
if (isUpdateSuppressed()) return
applyNeedRefresh(true)
},
onNeedRefresh() {
if (isUpdateSuppressed()) return
setNeedRefresh(true)
applyNeedRefresh(true)
},
onRegisteredSW(_swUrl: string, registration: ServiceWorkerRegistration | undefined) {
if (!registration) return
if (isUpdateSuppressed() || !registration.waiting) {
setNeedRefresh(false)
applyNeedRefresh(false)
}
cleanupRef.current?.()
cleanupRef.current = scheduleUpdateChecks(registration)
cleanupRef.current = scheduleUpdateChecks(registration, () => {
if (isUpdateSuppressed()) return
applyNeedRefresh(true)
})
}
})
setNeedRefreshRef.current = setNeedRefresh
useEffect(() => {
if (isUpdateSuppressed()) {
setNeedRefresh(false)
} else if (pendingNeedRefreshRef.current !== null) {
const pending = pendingNeedRefreshRef.current
pendingNeedRefreshRef.current = null
setNeedRefresh(pending)
}
void isDeployedVersionNewer().then((outdated) => {
if (outdated) {
setNeedRefresh(true)
}
})
return () => {
cleanupRef.current?.()
cleanupRef.current = null
if (reloadFallbackTimerRef.current !== null) {
window.clearTimeout(reloadFallbackTimerRef.current)
reloadFallbackTimerRef.current = null
}
if (forceRecoveryTimerRef.current !== null) {
window.clearTimeout(forceRecoveryTimerRef.current)
forceRecoveryTimerRef.current = null
}
}
}, [setNeedRefresh])
@@ -93,11 +151,24 @@ export function usePwaUpdate() {
suppressUpdatePrompt()
await updateServiceWorker(true)
await triggerServiceWorkerUpdate()
// vite-plugin-pwa reloads via the "controlling" event; fallback if that does not fire.
window.setTimeout(() => {
window.location.reload()
if (reloadFallbackTimerRef.current !== null) {
window.clearTimeout(reloadFallbackTimerRef.current)
}
if (forceRecoveryTimerRef.current !== null) {
window.clearTimeout(forceRecoveryTimerRef.current)
}
reloadFallbackTimerRef.current = window.setTimeout(() => {
reloadFallbackTimerRef.current = null
reloadForServiceWorkerTakeover()
}, UPDATE_RELOAD_FALLBACK_MS)
forceRecoveryTimerRef.current = window.setTimeout(() => {
forceRecoveryTimerRef.current = null
void forcePwaRecovery()
}, UPDATE_HARD_RECOVERY_MS)
}
const dismissUpdate = () => {
+9 -2
View File
@@ -3,12 +3,19 @@ import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import enJson from './locales/en.json'
import deJson from './locales/de.json'
import daJson from './locales/da.json'
import svJson from './locales/sv.json'
import nbJson from './locales/nb.json'
import { initSeo } from '../utils/seo.js'
import { SUPPORTED_LANGUAGES } from '../utils/i18nLanguages.js'
/** JSON files wrap strings in `translation` — register that namespace explicitly. */
const resources = {
en: { translation: enJson.translation },
de: { translation: deJson.translation }
de: { translation: deJson.translation },
da: { translation: daJson.translation },
sv: { translation: svJson.translation },
nb: { translation: nbJson.translation }
}
i18n
@@ -18,7 +25,7 @@ i18n
resources,
defaultNS: 'translation',
fallbackLng: 'en',
supportedLngs: ['de', 'en'],
supportedLngs: [...SUPPORTED_LANGUAGES],
nonExplicitSupportedLngs: true,
load: 'languageOnly',
interpolation: {
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import deJson from '../i18n/locales/de.json'
import enJson from '../i18n/locales/en.json'
import daJson from '../i18n/locales/da.json'
import svJson from '../i18n/locales/sv.json'
import nbJson from '../i18n/locales/nb.json'
function collectKeys(obj: Record<string, unknown>, prefix = ''): string[] {
const keys: string[] = []
for (const [key, value] of Object.entries(obj)) {
const path = prefix ? `${prefix}.${key}` : key
if (value && typeof value === 'object' && !Array.isArray(value)) {
keys.push(...collectKeys(value as Record<string, unknown>, path))
} else {
keys.push(path)
}
}
return keys.sort()
}
const bundles = {
de: deJson.translation,
en: enJson.translation,
da: daJson.translation,
sv: svJson.translation,
nb: nbJson.translation
} as const
describe('i18n locale key parity', () => {
const masterKeys = collectKeys(bundles.de)
it.each(Object.keys(bundles).filter((lang) => lang !== 'de'))(
'%s has the same keys as de',
(lang) => {
const keys = collectKeys(bundles[lang as keyof typeof bundles])
expect(keys).toEqual(masterKeys)
}
)
})
File diff suppressed because it is too large Load Diff
+389 -25
View File
@@ -6,16 +6,40 @@
"beta": "Beta",
"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": {
"de": "Deutsch",
"en": "English",
"da": "Dansk",
"sv": "Svenska",
"nb": "Norsk"
},
"dialog": {
"ok": "OK",
"yes": "Ja",
"no": "Nein"
},
"errors": {
"load_failed": "Daten konnten nicht geladen werden.",
"save_failed": "Änderungen konnten nicht gespeichert werden.",
"delete_failed": "Löschen fehlgeschlagen.",
"export_failed": "Export fehlgeschlagen."
},
"common": {
"unsaved_changes_title": "Ungespeicherte Änderungen",
"unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.",
"unsaved_changes_leave": "Verlassen",
"unsaved_changes_stay": "Bleiben"
"unsaved_changes_stay": "Bleiben",
"unsaved_changes_save_leave": "Speichern & verlassen",
"unsaved_changes_discard": "Verwerfen",
"unsaved_changes_leave": "Verlassen"
},
"nav": {
"dashboard": "Dashboard",
"vessel": "Schiffsdaten",
"crew": "Crew-Liste",
"crew": "Crew",
"deviation": "Ablenkungstabelle",
"logs": "Logbucheinträge",
"stats": "Statistik",
@@ -61,7 +85,12 @@
"enter_pin_placeholder": "Gib deine PIN ein...",
"decrypt_with_pin": "Entschlüsseln",
"use_recovery_instead": "Stattdessen Wiederherstellungsschlüssel verwenden",
"error_incorrect_pin": "Falsche PIN. Entschlüsselung fehlgeschlagen."
"error_incorrect_pin": "Falsche PIN. Entschlüsselung fehlgeschlagen.",
"error_invalid_host": "Passkeys funktionieren nicht über 127.0.0.1. Bitte die App über localhost öffnen.",
"use_localhost_link": "Zu localhost wechseln",
"error_passkey_cancelled": "Passkey-Anmeldung abgebrochen oder abgelaufen. Bitte erneut versuchen.",
"error_invalid_rp_id": "Passkey-Domain passt nicht (RP ID). Lokal nur http://localhost:5173 mit RP_ID=localhost in .env verwenden.",
"error_session_incomplete": "Anmeldung unvollständig. Bitte erneut mit Passkey anmelden."
},
"pwa": {
"title": "App installieren",
@@ -80,13 +109,18 @@
"update_title": "Update verfügbar",
"update_desc": "Eine neue Version von Kapteins Daagbok ist bereit. Bitte aktualisieren, um die neuesten Änderungen zu erhalten.",
"update_now": "Jetzt aktualisieren",
"update_reloading": "Wird geladen…"
"update_reloading": "Wird geladen…",
"storage_persist_hint": "Der Browser kann Offline-Daten löschen. Erlaube dauerhafte Speicherung, damit dein Logbuch geschützt bleibt (in den Browser-Einstellungen oder beim nächsten Hinweis)."
},
"sync": {
"status_synced": "Synchronisiert",
"status_syncing": "Synchronisiere…",
"status_offline": "Offline-Cache",
"status_unsynced": "Unsynchronisierte Änderungen"
"status_unsynced": "Unsynchronisierte Änderungen",
"conflict_title": "Synchronisationskonflikt",
"conflict_message": "{{count}} Änderung(en) konnten nicht synchronisiert werden (Eintrag {{id}}…). Bitte wähle, welche Version gelten soll.",
"conflict_use_server": "Server-Version übernehmen",
"conflict_keep_local": "Meine Version behalten"
},
"vessel": {
"title": "Schiffs-Stammdaten",
@@ -117,7 +151,13 @@
"no_sails": "Keine Segel hinterlegt.",
"photo_add": "Foto hinzufügen",
"photo_change": "Foto ändern",
"photo_delete": "Foto löschen"
"photo_delete": "Foto löschen",
"tanks_section": "Tanks (Fassungsvermögen)",
"tanks_help": "Optional in Liter — ermöglicht Slider im Journal bei bekannten Tankgrößen.",
"freshwater_capacity_l": "Trinkwasser (Liter)",
"fuel_capacity_l": "Treibstoff (Liter)",
"greywater_capacity_l": "Grauwasser (Liter)",
"invalid_tank_liters": "Ungültiger Zahlenwert — bitte Liter als Zahl eingeben (z. B. 200)."
},
"logs": {
"title": "Logbuch-Journal",
@@ -132,12 +172,17 @@
"sign_cleared_skipper_re_sign_title": "Skipper-Unterschrift entfernt",
"sign_cleared_skipper_re_sign": "Das Ereignisprotokoll wurde geändert. Die Skipper-Unterschrift wurde entfernt. Bitte erneut freigeben.",
"date": "Datum",
"day_of_travel": "Tag der Reise / Reisetag",
"day_of_travel": "Reisetag",
"travel_day_number": "Reisetag {{number}}",
"departure": "Start-Hafen (Reise von)",
"destination": "Ziel-Hafen (nach)",
"route": "Reise von/nach",
"freshwater": "Frischwasser (Liter)",
"fuel": "Treibstoff / Fuel (Liter)",
"greywater": "Grauwasser (Liter)",
"greywater_level": "Füllstand",
"tank_slider_of_max": "{{current}} / {{max}} L",
"tank_capacity_tooltip": "Wenn in den Schiffsdaten die Tank-Fassungsvermögen (Liter) hinterlegt sind, kannst du Füllstände hier per Slider eingeben.",
"morning": "Stand morgens",
"refilled": "Nachgefüllt",
"evening": "Stand abends",
@@ -180,13 +225,126 @@
"saving": "Wird gespeichert...",
"saved": "Logbuchseite erfolgreich gespeichert!",
"loading": "Journal wird geladen...",
"view_mode_label": "Ansicht",
"view_list": "Liste",
"live_mode": "Live",
"live_title": "Live-Journal",
"live_loading": "Live-Journal wird geladen...",
"live_retry": "Erneut versuchen",
"live_load_error": "Live-Journal konnte nicht geladen werden.",
"live_action_error": "Eintrag konnte nicht gespeichert werden.",
"live_open_editor": "Vollständiger Editor",
"live_actions_label": "Schnellaktionen",
"live_stream_label": "Ereignisprotokoll",
"live_stream_title": "Journal",
"live_no_events": "Noch keine Einträge — tippe auf eine Aktion.",
"live_motor_start": "Motor Start",
"live_motor_stop": "Motor Stop",
"live_cast_off": "Ablegen",
"live_moor": "Anlegen",
"live_sails_btn": "Segel",
"live_sails_pick": "Segel auswählen",
"live_sails_pick_hint": "Mehrere Segel antippen (erneut antippen zum Abwählen), dann Eintragen.",
"live_sails_selected": "Auswahl: {{sails}}",
"live_sails_confirm": "Eintragen",
"live_sails_confirm_count": "Eintragen ({{count}})",
"live_sails": "Segel: {{sails}}",
"live_position": "Position",
"live_position_coords": "Position {{lat}}, {{lng}}",
"live_position_manual_hint": "GPS nicht verfügbar. Breiten- und Längengrad manuell eingeben oder erneut per GPS-Knopf versuchen.",
"live_position_gps_loading": "GPS-Position wird ermittelt…",
"live_position_invalid": "Bitte gültige Koordinaten eingeben (Breite 90…90, Länge 180…180).",
"live_position_lat_placeholder": "Breite (Lat)",
"live_position_lng_placeholder": "Länge (Lng)",
"live_photo_btn": "Foto (Kamera)",
"live_photo_capture_btn": "Aufnehmen",
"live_photo_save_btn": "Speichern",
"live_photo_retake_btn": "Neu aufnehmen",
"live_photo_capture_failed": "Aufnahme fehlgeschlagen. Bitte erneut versuchen.",
"live_photo_open_camera_btn": "Kamera öffnen",
"live_photo_native_hint": "Foto mit der Gerätekamera aufnehmen und anschließend hier speichern.",
"live_photo_camera_starting": "Kamera wird gestartet…",
"live_photo_camera_denied": "Kamerazugriff verweigert oder nicht verfügbar.",
"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_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto aufgenommen",
"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_placeholder": "Freitext eingeben…",
"live_comment_confirm": "Eintragen",
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
"live_gps_start_hint": "Beginne deine Tagesreise immer mit einer Position.",
"live_event_generic": "Ereignis",
"live_weather_btn": "Wetter",
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
"live_weather_owm_loading": "Wetter wird geladen…",
"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_position_stale": "Die letzte Position ist älter als 6 Stunden. Bitte erneut eine Position loggen, bevor du Wetter abrufst.",
"live_wind_btn": "Wind",
"live_temp_btn": "T °C",
"live_pressure_btn": "Luftdruck",
"live_precip_btn": "Niederschlag",
"live_sea_state_btn": "Seegang",
"live_visibility_btn": "Sichtweite",
"live_course_btn": "Kurs",
"live_fuel_btn": "+ Diesel",
"live_water_btn": "+ Wasser",
"live_wind_entry": "Wind {{value}}",
"live_temp_entry": "Temperatur {{temp}} °C",
"live_pressure_entry": "Luftdruck {{value}} hPa",
"live_precip_entry": "Niederschlag {{value}}",
"live_sea_state_entry": "Seegang {{value}}",
"live_visibility_entry": "Sichtweite {{value}}",
"live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Wasser +{{liters}} L",
"live_auto_position": "Auto-Position",
"live_undo_hint": "Eintrag gespeichert",
"live_undo_btn": "Rückgängig",
"live_cancel": "Abbruch",
"live_pressure_placeholder": "z. B. 1013",
"live_temp_placeholder": "z. B. 18",
"live_precip_placeholder": "z. B. leichter Regen",
"live_sea_state_placeholder": "z. B. 3",
"live_visibility_placeholder": "z. B. 10 km",
"live_course_placeholder": "z. B. 245",
"live_fuel_placeholder": "Nachgefüllte Liter",
"live_water_placeholder": "Nachgefüllte Liter",
"live_sog_btn": "SOG",
"live_stw_btn": "STW",
"live_sog_entry": "SOG {{speed}} kn",
"live_stw_entry": "STW {{speed}} kn",
"live_sog_placeholder": "z. B. 5,2",
"live_stw_placeholder": "z. B. 4,8",
"live_sog_hint": "Fahrt über Grund (kn) — GPS-Wert wird vorgefüllt, wenn verfügbar.",
"delete_entry": "Tag löschen",
"delete_confirm": "Bist du sicher, dass du diesen Reisetag unwiderruflich löschen möchtest?",
"carry_over_tanks_title": "Daten vom Vortag übernehmen?",
"carry_over_tanks_confirm": "Start-Hafen, Frischwasser- und Kraftstoff-Morgenstände vom letzten Reisetag übernehmen?\n\nStart-Hafen: {{departure}}\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L",
"carry_over_tanks_confirm": "Start-Hafen, Frischwasser-, Kraftstoff- und Grauwasser-Startstände vom letzten Reisetag übernehmen?\n\nStart-Hafen: {{departure}}\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L\nGrauwasser: {{greywater}} L",
"carry_over_tanks_yes": "Übernehmen",
"carry_over_tanks_no": "Mit 0 starten",
"event_title": "Chronologisches Ereignisprotokoll",
"event_creator": "Eingetragen von",
"no_events": "Noch keine Ereignisse für diesen Reisetag eingetragen.",
"event_time": "Uhrzeit",
"event_mgk": "MgK Kurs",
@@ -211,6 +369,12 @@
"event_wind_direction": "Wind-Richtung",
"event_wind_strength": "Windstärke",
"event_sea_state": "Seegang",
"event_visibility": "Sichtweite",
"event_visibility_placeholder": "z. B. 10 km",
"weather_slider_unset": "—",
"weather_slider_pressure": "{{value}} hPa",
"weather_slider_sea_state": "Stufe {{value}}",
"weather_slider_heel": "{{value}}°",
"event_weather": "Wetter",
"event_log": "Logge (sm)",
"event_gps": "GPS-Position",
@@ -218,7 +382,26 @@
"event_location_placeholder": "z. B. Kiel",
"event_remarks": "Bemerkungen / Vorkommnisse",
"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_offline": "OpenWeatherMap erfordert eine Internetverbindung. Du bist derzeit offline.",
"event_wind_pressure": "Luftdruck (hPa)",
"event_heel": "Krängung (°)",
"event_sails": "Segelführung / Motor",
@@ -232,6 +415,18 @@
"share_csv": "CSV teilen",
"export_pdf": "PDF herunterladen",
"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)",
"photo_caption_label": "Foto-Beschreibung / Label (Optional)",
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
@@ -256,6 +451,56 @@
"track_map_end": "Ziel",
"track_map_speed_slow": "langsam",
"track_map_speed_fast": "schnell",
"nmea_import_title": "NMEA-Protokoll importieren",
"nmea_import_intro": "Lade eine .nmea-Datei vom Bord-Logger. Die App schlägt Journal-Einträge vor — du entscheidest, was übernommen wird.",
"nmea_import_btn": "NMEA importieren",
"nmea_file_label": "NMEA-Datei",
"nmea_stats": "{{lines}} Sätze erkannt · Typen: {{types}}",
"nmea_warn_no_position": "Keine Positions-Sätze gefunden — Track und GPS-Felder können leer bleiben.",
"nmea_warn_duplicate_file": "Diese NMEA-Datei wurde bereits importiert. Ein erneuter Import derselben Datei fügt doppelte Journal-Einträge hinzu.",
"nmea_mode_label": "Journal-Einträge erzeugen",
"nmea_mode_interval": "Nach Zeitintervall",
"nmea_mode_change": "Bei signifikanter Änderung",
"nmea_mode_both": "Beides (zusammenführen)",
"nmea_interval_label": "Intervall (Minuten)",
"nmea_import_track": "GPS-Track aus NMEA übernehmen",
"nmea_preview": "Vorschau",
"nmea_preview_hint": "{{count}} vorgeschlagene Journal-Einträge",
"nmea_select_all": "Alle auswählen",
"nmea_select_none": "Keine auswählen",
"nmea_source_interval": "Intervall",
"nmea_source_change": "Ereignis",
"nmea_apply": "In Journal übernehmen",
"nmea_back": "Zurück",
"nmea_cancel": "Abbrechen",
"nmea_archive_question": "Rohprotokoll lokal archivieren? (Nur auf diesem Gerät, nicht synchronisiert.)",
"nmea_archive_keep": "Archivieren",
"nmea_archive_discard": "Verwerfen",
"nmea_archive_stored": "NMEA archiviert: {{name}}",
"nmea_archive_delete_confirm": "Archiviertes NMEA-Protokoll von diesem Gerät löschen?",
"nmea_error_no_samples": "Keine verwertbaren NMEA-Sätze in der Datei.",
"nmea_error_parse": "NMEA-Datei konnte nicht gelesen werden.",
"nmea_error_read": "Datei konnte nicht gelesen werden.",
"nmea_error_no_file": "Bitte zuerst eine NMEA-Datei wählen.",
"nmea_error_no_selection": "Bitte mindestens einen Journal-Eintrag auswählen.",
"nmea_remark_interval": "NMEA Intervall",
"nmea_remark_uncertain": "unsicher",
"nmea_remark_depth": "Tiefe {{depth}} m",
"nmea_change_course": "Kursänderung {{from}}° → {{to}}°",
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
"nmea_change_pressure": "Luftdruck {{from}} → {{to}} hPa",
"nmea_change_depth": "Tiefe {{from}} → {{to}} m",
"nmea_change_engine_start": "Motor an ({{rpm}} U/min)",
"nmea_change_engine_stop": "Motor aus",
"nmea_change_autopilot_on": "Autopilot ein",
"nmea_change_autopilot_off": "Autopilot aus",
"nmea_change_gps_lost": "GPS-Position verloren",
"nmea_change_gps_regained": "GPS-Position wiederhergestellt",
"nmea_change_water_temp": "Wassertemp. {{from}} → {{to}} °C",
"nmea_change_departure": "Abfahrt / Fahrtbeginn",
"nmea_change_anchor": "Ankern / Stop",
"nmea_change_speed": "Geschw. {{from}} → {{to}} kn",
"track_map_error": "Karte konnte nicht geladen werden.",
"exporting": "Exportiere...",
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.",
@@ -275,7 +520,7 @@
"new_logbook_placeholder": "Name des Logbuchs oder der Yacht",
"logout": "Abmelden",
"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!",
"loading": "Logbücher werden geladen...",
"status_synced": "Synchronisiert",
@@ -291,10 +536,27 @@
"role_read": "Nur Lesen",
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung",
"open_profile": "Profil von {{name}} öffnen",
"open_logbook": "Logbuch „{{title}}“ öffnen",
"edit_title": "Logbuch umbenennen",
"edit_placeholder": "Neuer Name des Logbuchs",
"edit_success": "Logbuch erfolgreich umbenannt",
"edit_btn": "Umbenennen"
"edit_btn": "Umbenennen",
"filter_label": "Logbücher filtern",
"filter_placeholder": "Name, Jahr, Datum, Crew oder Schiff …",
"filter_clear": "Filter zurücksetzen",
"filter_results": "{{count}} Treffer",
"filter_no_results": "Keine Logbücher passen zu deiner Suche. Probiere einen anderen Namen oder ein anderes Jahr.",
"sort_label": "Sortieren",
"sort_by_label": "Sortieren nach",
"sort_by_name": "Name",
"sort_by_date": "Datum",
"sort_dir_label": "Reihenfolge",
"sort_asc": "Aufsteigend",
"sort_desc": "Absteigend",
"sort_name_asc": "Name A bis Z",
"sort_name_desc": "Name Z bis A",
"sort_date_asc": "Älteste zuerst",
"sort_date_desc": "Neueste zuerst"
},
"profile": {
"title": "Benutzerprofil",
@@ -411,7 +673,72 @@
"push_unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.",
"push_denied_hint": "Benachrichtigungen sind blockiert. Erlaube sie in den Browser- oder Geräteeinstellungen.",
"push_ios_install_hint": "Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), um Push zu nutzen.",
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden."
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden.",
"sections": {
"account": "Konto & Einstellungen",
"fleet": "Flotte & Crew",
"security": "Sicherheit & Gerät",
"stats": "Statistik",
"danger": "Gefahrenzone"
}
},
"vessel_pool": {
"title": "Schiffsflotte",
"section_title": "Deine Schiffe",
"subtitle": "Pflege hier alle Schiffe für deine Logbücher. Pro Logbuch wählst du das aktive Schiff aus dieser Liste.",
"loading": "Schiffsflotte wird geladen…",
"add_vessel": "Schiff hinzufügen",
"edit_vessel": "Schiff bearbeiten",
"no_vessels": "Noch keine Schiffe im Pool.",
"delete_confirm": "Dieses Schiff wirklich aus der Flotte entfernen?",
"max_vessels": "Maximale Anzahl von 20 Schiffen im Pool erreicht."
},
"logbook_vessel": {
"title": "Schiff für dieses Logbuch",
"subtitle": "Wähle das Schiff für dieses Logbuch. Reisetage nutzen Segel- und Tankdaten des gewählten Schiffs.",
"active_vessel": "Schiff für dieses Logbuch",
"no_vessels_in_pool": "Kein Schiff in der Flotte zuerst im Benutzerprofil anlegen.",
"no_vessel": "Kein Schiff gewählt",
"unnamed": "Unbenannt",
"save": "Schiff speichern",
"saved": "Schiff für das Logbuch gespeichert.",
"selection_only_hint": "Du siehst das vom Eigner gewählte Schiff (geteiltes Logbuch).",
"manage_in_profile": "Schiffe im Benutzerprofil verwalten"
},
"person_pool": {
"title": "Stammcrew & Skipper",
"subtitle": "Lege hier deinen Personen-Pool an Skipper und Crew für alle Logbücher. Aus diesem Pool wählst du pro Logbuch und Reisetag die aktive Crew.",
"loading": "Personen-Pool wird geladen…",
"skippers_section": "Stammskipper",
"crew_section": "Stammcrew",
"add_skipper": "Skipper hinzufügen",
"add_crew": "Crew-Mitglied hinzufügen",
"edit_skipper": "Skipper bearbeiten",
"no_skippers": "Noch kein Skipper im Pool.",
"no_crew": "Noch keine Crew-Mitglieder im Pool.",
"delete_confirm": "Diese Person wirklich aus dem Pool entfernen?"
},
"logbook_crew": {
"title": "Crew für dieses Logbuch",
"subtitle": "Wähle Skipper und Crew für dieses Logbuch. Neue Reisetage übernehmen diese Auswahl standardmäßig.",
"loading": "Crew wird geladen…",
"active_skipper": "Skipper für dieses Logbuch",
"active_crew": "Crew für dieses Logbuch",
"no_skippers_in_pool": "Kein Skipper im Pool zuerst im Benutzerprofil anlegen.",
"no_crew_in_pool": "Keine Crew im Pool zuerst im Benutzerprofil anlegen.",
"no_skipper": "Kein Skipper gewählt",
"unnamed": "Unbenannt",
"save": "Crew speichern",
"saved": "Crew für das Logbuch gespeichert.",
"selection_only_hint": "Du siehst die vom Eigner festgelegte Crew (geteiltes Logbuch)."
},
"entry_crew": {
"title": "Crew an diesem Reisetag",
"subtitle": "Kann vom Logbuch-Standard abweichen. Folge-Reisetage übernehmen den Vortag.",
"day_skipper": "Skipper an diesem Tag",
"day_crew": "Crew an diesem Tag",
"no_skipper": "Kein Skipper gewählt",
"no_crew": "Keine Crew gewählt"
},
"crew": {
"title": "Skipper- & Crew-Profile",
@@ -421,7 +748,7 @@
"add_crew": "Crew-Mitglied hinzufügen",
"edit_crew": "Crew-Mitglied bearbeiten",
"no_crew": "Noch keine Crew-Mitglieder hinzugefügt.",
"max_crew": "Maximale Anzahl von 5 Crew-Mitgliedern erreicht.",
"max_crew": "Maximale Anzahl von 12 Crew-Mitgliedern im Pool erreicht.",
"name": "Name",
"address": "Anschrift",
"birthdate": "Geburtstag",
@@ -462,6 +789,8 @@
"share_enable": "Öffentlichen Link aktivieren",
"share_copied": "Link kopiert!",
"share_copy_btn": "Link kopieren",
"link_qr_hint": "QR-Code zum Scannen mit dem Smartphone",
"link_qr_alt": "QR-Code für den Link",
"danger_zone_title": "Gefahrenzone",
"danger_zone_desc": "Durch das Löschen deines Kontos werden alle deine Passkeys, Logbücher, Schiffsdaten, Crew-Profile, Reiseeinträge und E2E-Schlüssel unwiderruflich gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"delete_account_btn": "Konto unwiderruflich löschen",
@@ -470,12 +799,18 @@
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
"delete_account_confirm_no": "Abbrechen",
"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…",
"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_ios_message": "Sobald Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), dann Push im Benutzerprofil aktivieren.",
"invite_push_prompt_enable": "Jetzt aktivieren",
"invite_push_prompt_later": "Später",
"invite_push_prompt_success": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
"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_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_desc": "Stellt ein Backup in deinem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.",
"backup_passphrase": "Backup-Passphrase",
@@ -487,7 +822,13 @@
"backup_export_btn": "Backup herunterladen",
"backup_exporting": "Backup wird erstellt…",
"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_previewing": "Prüfe…",
"backup_restore_btn": "Wiederherstellen",
@@ -535,6 +876,7 @@
"category_general": "Allgemein",
"category_bug": "Fehler melden",
"category_feature": "Feature-Wunsch",
"category_translation": "Übersetzungsfehler",
"contact_label": "E-Mail (optional)",
"contact_placeholder": "deine@email.beispiel",
"message_label": "Nachricht",
@@ -623,7 +965,13 @@
"unit_l": "L",
"day_label": "Tag {{day}}",
"account_logbooks": "Logbücher im Überblick",
"col_logbook": "Logbuch"
"col_logbook": "Logbuch",
"event_series_title": "Ereignis-Verläufe",
"event_series_hint": "Chronologische Werte aus dem Ereignisprotokoll.",
"event_series_pressure": "Luftdruck",
"event_series_wind": "Wind",
"event_series_motor": "Motor",
"event_series_empty": "Keine Einträge vorhanden."
},
"tour": {
"skip": "Tour überspringen",
@@ -638,7 +986,7 @@
},
"welcome_public": {
"title": "Willkommen an Bord!",
"body": "Erkunde unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde ganz ohne Account. Diese kurze Tour zeigt dir Schiffsdaten, Crew und Logbucheinträge."
"body": "Erkunde unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde ganz ohne Account. Die Tour zeigt dir Logbucheinträge, die Schiff- und Crew-Auswahl für dieses Logbuch. Flotte und Stammcrew pflegst du später im Benutzerprofil."
},
"nav_logs": {
"title": "Logbucheinträge",
@@ -657,12 +1005,20 @@
"body": "Lade GPX-Dateien hoch oder sieh bereits gespeicherte Routen auf der Karte inklusive Distanz und Geschwindigkeit."
},
"nav_vessel": {
"title": "Schiffsdaten",
"body": "Hinterlege Name, Maße und technische Daten deiner Yacht einmal ausfüllen, für alle Reisetage verfügbar."
"title": "Schiff fürs Logbuch",
"body": "Wähle aus deiner Schiffsflotte das Schiff für dieses Logbuch. Schiffe pflegst du im Benutzerprofil unter Flotte & Crew."
},
"nav_crew": {
"title": "Crew-Liste",
"body": "Verwalte Besatzungsmitglieder und weise sie später Reisetagen zu."
"profile_vessel_pool": {
"title": "Schiffsflotte",
"body": "Im Benutzerprofil legst du alle deine Schiffe an Charteryachten, eigenes Boot usw. Pro Logbuch wählst du dann das passende Schiff."
},
"profile_crew_pool": {
"title": "Stammcrew & Skipper",
"body": "Im Benutzerprofil pflegst du deinen Personen-Pool mehrere Skipper (z. B. Charter) und Crew-Mitglieder für alle Logbücher."
},
"nav_logbook_crew": {
"title": "Crew pro Logbuch",
"body": "Wähle aus dem Pool, wer auf diesem Logbuch als Skipper und Crew gilt. Reisetage übernehmen diese Auswahl standardmäßig."
},
"nav_stats": {
"title": "Statistik-Dashboard",
@@ -672,9 +1028,17 @@
"title": "Feedback senden",
"body": "Über dieses Formular kannst du Fehler, Ideen oder allgemeines Feedback direkt an das Projektteam schicken auch nach der Tour jederzeit über das Symbol oben rechts."
},
"nav_profile": {
"title": "Dein Benutzerprofil",
"body": "Über den Skipper-Button oben erreichst du dein persönliches Profil unabhängig vom aktuellen Logbuch."
},
"profile_preferences": {
"title": "Konto & Darstellung",
"body": "Hier verwaltest du deine Konto-Identität, Theme und Hell/Dunkel-Modus. Die App-Tour kannst du jederzeit erneut starten. Passkeys und Sicherheitseinstellungen findest du weiter unten im Profil."
},
"finish": {
"title": "Alles klar!",
"body": "Du landest gleich im Statistik-Dashboard. Die Tour kannst du jederzeit unter Einstellungen erneut starten. Gute Fahrt!"
"body": "Du landest gleich im Statistik-Dashboard. Die Tour kannst du jederzeit im Benutzerprofil erneut starten. Gute Fahrt!"
}
}
},
+389 -25
View File
@@ -6,16 +6,40 @@
"beta": "Beta",
"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": {
"de": "Deutsch",
"en": "English",
"da": "Dansk",
"sv": "Svenska",
"nb": "Norsk"
},
"dialog": {
"ok": "OK",
"yes": "Yes",
"no": "No"
},
"errors": {
"load_failed": "Could not load data.",
"save_failed": "Could not save changes.",
"delete_failed": "Could not delete.",
"export_failed": "Export failed."
},
"common": {
"unsaved_changes_title": "Unsaved changes",
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
"unsaved_changes_leave": "Leave",
"unsaved_changes_stay": "Stay"
"unsaved_changes_stay": "Stay",
"unsaved_changes_save_leave": "Save & leave",
"unsaved_changes_discard": "Discard",
"unsaved_changes_leave": "Leave"
},
"nav": {
"dashboard": "Dashboard",
"vessel": "Vessel Profile",
"crew": "Crew List",
"crew": "Crew",
"deviation": "Deviation Table",
"logs": "Logbook Entries",
"stats": "Statistics",
@@ -61,7 +85,12 @@
"enter_pin_placeholder": "Enter your PIN...",
"decrypt_with_pin": "Decrypt",
"use_recovery_instead": "Use recovery phrase instead",
"error_incorrect_pin": "Incorrect PIN. Decryption failed."
"error_incorrect_pin": "Incorrect PIN. Decryption failed.",
"error_invalid_host": "Passkeys do not work on 127.0.0.1. Please open the app via localhost.",
"use_localhost_link": "Switch to localhost",
"error_passkey_cancelled": "Passkey sign-in was cancelled or timed out. Please try again.",
"error_invalid_rp_id": "Passkey domain mismatch (RP ID). For local dev use http://localhost:5173 with RP_ID=localhost in .env.",
"error_session_incomplete": "Sign-in incomplete. Please sign in with your passkey again."
},
"pwa": {
"title": "Install app",
@@ -80,13 +109,18 @@
"update_title": "Update available",
"update_desc": "A new version of Kapteins Daagbok is ready. Reload to get the latest changes.",
"update_now": "Reload now",
"update_reloading": "Reloading…"
"update_reloading": "Reloading…",
"storage_persist_hint": "Your browser may delete offline data. Allow persistent storage to keep your logbook safe (browser settings or when prompted)."
},
"sync": {
"status_synced": "Synced",
"status_syncing": "Syncing…",
"status_offline": "Offline Cache",
"status_unsynced": "Unsynced changes"
"status_unsynced": "Unsynced changes",
"conflict_title": "Sync conflict",
"conflict_message": "{{count}} change(s) could not be synced (entry {{id}}…). Choose which version to keep.",
"conflict_use_server": "Use server version",
"conflict_keep_local": "Keep my version"
},
"vessel": {
"title": "Vessel Master Data",
@@ -117,7 +151,13 @@
"no_sails": "No sails defined.",
"photo_add": "Add Photo",
"photo_change": "Change Photo",
"photo_delete": "Delete Photo"
"photo_delete": "Delete Photo",
"tanks_section": "Tanks (capacity)",
"tanks_help": "Optional, in liters — enables sliders in the journal when tank sizes are known.",
"freshwater_capacity_l": "Freshwater (liters)",
"fuel_capacity_l": "Fuel (liters)",
"greywater_capacity_l": "Greywater (liters)",
"invalid_tank_liters": "Invalid number — please enter capacity in liters (e.g. 200)."
},
"logs": {
"title": "Logbook Journal",
@@ -132,12 +172,17 @@
"sign_cleared_skipper_re_sign_title": "Skipper signature removed",
"sign_cleared_skipper_re_sign": "The event log was changed. The skipper signature was removed. Please sign again.",
"date": "Date",
"day_of_travel": "Day of Travel",
"day_of_travel": "Travel day",
"travel_day_number": "Travel day {{number}}",
"departure": "Departure Port (von)",
"destination": "Destination Port (nach)",
"route": "Route / Journey",
"freshwater": "Freshwater (Liters)",
"fuel": "Fuel (Liters)",
"greywater": "Greywater (Liters)",
"greywater_level": "Fill level",
"tank_slider_of_max": "{{current}} / {{max}} L",
"tank_capacity_tooltip": "If tank capacities (liters) are set in vessel master data, you can enter fill levels here using sliders.",
"morning": "Morning Level",
"refilled": "Refilled",
"evening": "Evening Level",
@@ -180,13 +225,126 @@
"saving": "Saving...",
"saved": "Logbook page saved successfully!",
"loading": "Loading journal...",
"view_mode_label": "View",
"view_list": "List",
"live_mode": "Live",
"live_title": "Live Journal",
"live_loading": "Loading live journal...",
"live_retry": "Try again",
"live_load_error": "Could not load live journal.",
"live_action_error": "Could not save entry.",
"live_open_editor": "Full editor",
"live_actions_label": "Quick actions",
"live_stream_label": "Event log",
"live_stream_title": "Journal",
"live_no_events": "No entries yet — tap an action.",
"live_motor_start": "Engine Start",
"live_motor_stop": "Engine Stop",
"live_cast_off": "Cast off",
"live_moor": "Moor",
"live_sails_btn": "Sails",
"live_sails_pick": "Select sails",
"live_sails_pick_hint": "Tap multiple sails (tap again to deselect), then log.",
"live_sails_selected": "Selected: {{sails}}",
"live_sails_confirm": "Log entry",
"live_sails_confirm_count": "Log entry ({{count}})",
"live_sails": "Sails: {{sails}}",
"live_position": "Position",
"live_position_coords": "Position {{lat}}, {{lng}}",
"live_position_manual_hint": "GPS unavailable. Enter latitude and longitude manually, or try again with the GPS button.",
"live_position_gps_loading": "Getting GPS position…",
"live_position_invalid": "Please enter valid coordinates (latitude 90…90, longitude 180…180).",
"live_position_lat_placeholder": "Latitude (Lat)",
"live_position_lng_placeholder": "Longitude (Lng)",
"live_photo_btn": "Photo (camera)",
"live_photo_capture_btn": "Capture",
"live_photo_save_btn": "Save",
"live_photo_retake_btn": "Retake",
"live_photo_capture_failed": "Capture failed. Please try again.",
"live_photo_open_camera_btn": "Open camera",
"live_photo_native_hint": "Take a photo with your device camera, then save it here.",
"live_photo_camera_starting": "Starting camera…",
"live_photo_camera_denied": "Camera access denied or unavailable.",
"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_entry": "Photo: {{caption}}",
"live_photo_entry_plain": "Photo captured",
"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_placeholder": "Enter text…",
"live_comment_confirm": "Log entry",
"live_gps_error": "Could not determine GPS position.",
"live_gps_start_hint": "Always start your day's voyage with a position.",
"live_event_generic": "Event",
"live_weather_btn": "Weather",
"live_weather_owm_btn": "Fetch OpenWeatherMap weather",
"live_weather_owm_loading": "Loading weather…",
"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_position_stale": "The last position is older than 6 hours. Log a new position before fetching weather.",
"live_wind_btn": "Wind",
"live_temp_btn": "Temp °C",
"live_pressure_btn": "Pressure",
"live_precip_btn": "Precipitation",
"live_sea_state_btn": "Sea state",
"live_visibility_btn": "Visibility",
"live_course_btn": "Course",
"live_fuel_btn": "+ Fuel",
"live_water_btn": "+ Water",
"live_wind_entry": "Wind {{value}}",
"live_temp_entry": "Temperature {{temp}} °C",
"live_pressure_entry": "Pressure {{value}} hPa",
"live_precip_entry": "Precipitation {{value}}",
"live_sea_state_entry": "Sea state {{value}}",
"live_visibility_entry": "Visibility {{value}}",
"live_course_entry": "Course {{course}}",
"live_fuel_entry": "Fuel +{{liters}} L",
"live_water_entry": "Water +{{liters}} L",
"live_auto_position": "Auto position",
"live_undo_hint": "Entry saved",
"live_undo_btn": "Undo",
"live_cancel": "Cancel",
"live_pressure_placeholder": "e.g. 1013",
"live_temp_placeholder": "e.g. 18",
"live_precip_placeholder": "e.g. light rain",
"live_sea_state_placeholder": "e.g. 3",
"live_visibility_placeholder": "e.g. 10 km",
"live_course_placeholder": "e.g. 245",
"live_fuel_placeholder": "Liters refilled",
"live_water_placeholder": "Liters refilled",
"live_sog_btn": "SOG",
"live_stw_btn": "STW",
"live_sog_entry": "SOG {{speed}} kn",
"live_stw_entry": "STW {{speed}} kn",
"live_sog_placeholder": "e.g. 5.2",
"live_stw_placeholder": "e.g. 4.8",
"live_sog_hint": "Speed over ground (kn) — prefilled from GPS when available.",
"delete_entry": "Delete Day",
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
"carry_over_tanks_title": "Carry over from previous day?",
"carry_over_tanks_confirm": "Use the previous travel day's destination as departure port and closing tank levels as morning levels?\n\nDeparture port: {{departure}}\nFreshwater: {{fw}} L\nFuel: {{fuel}} L",
"carry_over_tanks_confirm": "Use the previous travel day's destination as departure port and closing tank levels as morning levels?\n\nDeparture port: {{departure}}\nFreshwater: {{fw}} L\nFuel: {{fuel}} L\nGreywater: {{greywater}} L",
"carry_over_tanks_yes": "Carry over",
"carry_over_tanks_no": "Start at 0",
"event_title": "Chronological Event Logbook",
"event_creator": "Entered by",
"no_events": "No events logged for this travel day yet.",
"event_time": "Time",
"event_mgk": "MgK Course",
@@ -211,6 +369,12 @@
"event_wind_direction": "Wind Dir",
"event_wind_strength": "Wind Str",
"event_sea_state": "Sea State",
"event_visibility": "Visibility",
"event_visibility_placeholder": "e.g. 10 km",
"weather_slider_unset": "—",
"weather_slider_pressure": "{{value}} hPa",
"weather_slider_sea_state": "State {{value}}",
"weather_slider_heel": "{{value}}°",
"event_weather": "Weather",
"event_log": "Log (nm)",
"event_gps": "GPS Position",
@@ -218,7 +382,26 @@
"event_location_placeholder": "e.g. Kiel",
"event_remarks": "Remarks / Events",
"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_offline": "OpenWeatherMap requires an internet connection. You are currently offline.",
"event_wind_pressure": "Barometer (hPa)",
"event_heel": "Heel Angle (°)",
"event_sails": "Sails / Motor Status",
@@ -232,6 +415,18 @@
"share_csv": "Share CSV",
"export_pdf": "Download 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)",
"photo_caption_label": "Photo Caption / Label (Optional)",
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
@@ -256,6 +451,56 @@
"track_map_end": "End",
"track_map_speed_slow": "slow",
"track_map_speed_fast": "fast",
"nmea_import_title": "Import NMEA log",
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
"nmea_import_btn": "Import NMEA",
"nmea_file_label": "NMEA file",
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries.",
"nmea_mode_label": "Generate journal entries",
"nmea_mode_interval": "By time interval",
"nmea_mode_change": "On significant change",
"nmea_mode_both": "Both (merge)",
"nmea_interval_label": "Interval (minutes)",
"nmea_import_track": "Import GPS track from NMEA",
"nmea_preview": "Preview",
"nmea_preview_hint": "{{count}} suggested journal entries",
"nmea_select_all": "Select all",
"nmea_select_none": "Select none",
"nmea_source_interval": "Interval",
"nmea_source_change": "Event",
"nmea_apply": "Apply to journal",
"nmea_back": "Back",
"nmea_cancel": "Cancel",
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
"nmea_archive_keep": "Archive",
"nmea_archive_discard": "Discard",
"nmea_archive_stored": "NMEA archived: {{name}}",
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
"nmea_error_parse": "Could not read NMEA file.",
"nmea_error_read": "Could not read file.",
"nmea_error_no_file": "Please choose an NMEA file first.",
"nmea_error_no_selection": "Please select at least one journal entry.",
"nmea_remark_interval": "NMEA interval",
"nmea_remark_uncertain": "uncertain",
"nmea_remark_depth": "Depth {{depth}} m",
"nmea_change_course": "Course change {{from}}° → {{to}}°",
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
"nmea_change_depth": "Depth {{from}} → {{to}} m",
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
"nmea_change_engine_stop": "Engine off",
"nmea_change_autopilot_on": "Autopilot on",
"nmea_change_autopilot_off": "Autopilot off",
"nmea_change_gps_lost": "GPS position lost",
"nmea_change_gps_regained": "GPS position restored",
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop",
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
"track_map_error": "Could not load map.",
"exporting": "Exporting...",
"share_unsupported": "Web sharing is not supported on this device. File downloaded instead.",
@@ -275,7 +520,7 @@
"new_logbook_placeholder": "Logbook or Yacht Name",
"logout": "Logout",
"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!",
"loading": "Loading logbooks...",
"status_synced": "Synced",
@@ -291,10 +536,27 @@
"role_read": "Read only",
"role_read_hint": "Shared logbook — view only, no editing",
"open_profile": "Open profile for {{name}}",
"open_logbook": "Open logbook “{{title}}”",
"edit_title": "Rename Logbook",
"edit_placeholder": "New name of the logbook",
"edit_success": "Logbook renamed successfully",
"edit_btn": "Rename"
"edit_btn": "Rename",
"filter_label": "Filter logbooks",
"filter_placeholder": "Name, year, date, crew or vessel …",
"filter_clear": "Clear filter",
"filter_results": "{{count}} matches",
"filter_no_results": "No logbooks match your search. Try a different name or year.",
"sort_label": "Sort",
"sort_by_label": "Sort by",
"sort_by_name": "Name",
"sort_by_date": "Date",
"sort_dir_label": "Order",
"sort_asc": "Ascending",
"sort_desc": "Descending",
"sort_name_asc": "Name A to Z",
"sort_name_desc": "Name Z to A",
"sort_date_asc": "Oldest first",
"sort_date_desc": "Newest first"
},
"profile": {
"title": "User profile",
@@ -411,7 +673,72 @@
"push_unsupported": "Push notifications are not supported in this browser.",
"push_denied_hint": "Notifications are blocked. Allow them in your browser or device settings.",
"push_ios_install_hint": "On iPhone/iPad: add the app to your Home Screen (iOS 16.4+) to use push notifications.",
"push_error": "Could not enable push notifications."
"push_error": "Could not enable push notifications.",
"sections": {
"account": "Account & settings",
"fleet": "Fleet & crew",
"security": "Security & device",
"stats": "Statistics",
"danger": "Danger zone"
}
},
"vessel_pool": {
"title": "Vessel fleet",
"section_title": "Your vessels",
"subtitle": "Maintain all vessels for your logbooks here. Select the active vessel per logbook from this list.",
"loading": "Loading vessel fleet…",
"add_vessel": "Add vessel",
"edit_vessel": "Edit vessel",
"no_vessels": "No vessels in the pool yet.",
"delete_confirm": "Remove this vessel from the fleet?",
"max_vessels": "Maximum of 20 vessels in the pool reached."
},
"logbook_vessel": {
"title": "Vessel for this logbook",
"subtitle": "Choose the vessel for this logbook. Travel days use sails and tank data from the selected vessel.",
"active_vessel": "Vessel for this logbook",
"no_vessels_in_pool": "No vessel in the fleet — add one in your user profile first.",
"no_vessel": "No vessel selected",
"unnamed": "Unnamed",
"save": "Save vessel",
"saved": "Logbook vessel saved.",
"selection_only_hint": "You see the vessel chosen by the owner (shared logbook).",
"manage_in_profile": "Manage vessels in user profile"
},
"person_pool": {
"title": "Core Crew & skippers",
"subtitle": "Maintain your person pool here — skippers and crew for all logbooks. Select active crew per logbook and travel day from this pool.",
"loading": "Loading person pool…",
"skippers_section": "Skippers",
"crew_section": "Core Crew",
"add_skipper": "Add skipper",
"add_crew": "Add crew member",
"edit_skipper": "Edit skipper",
"no_skippers": "No skippers in the pool yet.",
"no_crew": "No crew members in the pool yet.",
"delete_confirm": "Remove this person from the pool?"
},
"logbook_crew": {
"title": "Crew for this logbook",
"subtitle": "Choose skipper and crew for this logbook. New travel days inherit this selection by default.",
"loading": "Loading crew…",
"active_skipper": "Skipper for this logbook",
"active_crew": "Crew for this logbook",
"no_skippers_in_pool": "No skipper in the pool — add one in your user profile first.",
"no_crew_in_pool": "No crew in the pool — add members in your user profile first.",
"no_skipper": "No skipper selected",
"unnamed": "Unnamed",
"save": "Save crew",
"saved": "Logbook crew saved.",
"selection_only_hint": "You see the crew set by the owner (shared logbook)."
},
"entry_crew": {
"title": "Crew on this travel day",
"subtitle": "May differ from the logbook default. Following days inherit from the previous day.",
"day_skipper": "Skipper on this day",
"day_crew": "Crew on this day",
"no_skipper": "No skipper selected",
"no_crew": "No crew selected"
},
"crew": {
"title": "Skipper & Crew Profiles",
@@ -421,7 +748,7 @@
"add_crew": "Add Crew Member",
"edit_crew": "Edit Crew Member",
"no_crew": "No crew members added yet.",
"max_crew": "Maximum of 5 crew members reached.",
"max_crew": "Maximum of 12 crew members in the pool reached.",
"name": "Full Name",
"address": "Address",
"birthdate": "Date of Birth",
@@ -462,6 +789,8 @@
"share_enable": "Enable Public Link",
"share_copied": "Link copied!",
"share_copy_btn": "Copy Link",
"link_qr_hint": "Scan this QR code with your phone",
"link_qr_alt": "QR code for the link",
"danger_zone_title": "Danger Zone",
"danger_zone_desc": "Deleting your account will permanently delete all your passkeys, logbooks, vessel data, crew profiles, travel logs, and E2E keys. This action cannot be undone.",
"delete_account_btn": "Permanently Delete Account",
@@ -470,12 +799,18 @@
"delete_account_confirm_yes": "Yes, Delete Account and All Data",
"delete_account_confirm_no": "Cancel",
"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…",
"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_ios_message": "When crew members sync changes, you can get push notifications. On iPhone/iPad: add the app to your Home Screen (iOS 16.4+), then enable push in your user profile.",
"invite_push_prompt_enable": "Enable now",
"invite_push_prompt_later": "Later",
"invite_push_prompt_success": "Push notifications are active on this device.",
"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_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_desc": "Restores a backup into your current account — including after registering a new account.",
"backup_passphrase": "Backup passphrase",
@@ -487,7 +822,13 @@
"backup_export_btn": "Download backup",
"backup_exporting": "Creating backup…",
"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_previewing": "Verifying…",
"backup_restore_btn": "Restore",
@@ -535,6 +876,7 @@
"category_general": "General",
"category_bug": "Bug report",
"category_feature": "Feature request",
"category_translation": "Translation error",
"contact_label": "Email (optional)",
"contact_placeholder": "your@email.example",
"message_label": "Message",
@@ -623,7 +965,13 @@
"unit_l": "L",
"day_label": "Day {{day}}",
"account_logbooks": "Logbooks overview",
"col_logbook": "Logbook"
"col_logbook": "Logbook",
"event_series_title": "Event series",
"event_series_hint": "Chronological values from the event log.",
"event_series_pressure": "Barometric pressure",
"event_series_wind": "Wind",
"event_series_motor": "Engine",
"event_series_empty": "No entries yet."
},
"tour": {
"skip": "Skip tour",
@@ -638,7 +986,7 @@
},
"welcome_public": {
"title": "Welcome aboard!",
"body": "Explore our demo logbook with three travel days in Kiel Bay — no account required. This short tour shows vessel data, crew, and log entries."
"body": "Explore our demo logbook with three travel days in Kiel Bay — no account required. The tour covers log entries and vessel and crew selection for this logbook. Manage your fleet and core crew later in your user profile."
},
"nav_logs": {
"title": "Log entries",
@@ -657,12 +1005,20 @@
"body": "Upload GPX files or view saved routes on the map including distance and speed stats."
},
"nav_vessel": {
"title": "Vessel data",
"body": "Enter your yacht's name, dimensions, and technical details fill once, use on every travel day."
"title": "Vessel for logbook",
"body": "Choose a vessel from your fleet for this logbook. Manage vessels in your user profile under Fleet & crew."
},
"nav_crew": {
"title": "Crew list",
"body": "Manage crew members and assign them to travel days later."
"profile_vessel_pool": {
"title": "Vessel fleet",
"body": "In your user profile you add all your vessels — charter yachts, your own boat, etc. Then pick the right vessel per logbook."
},
"profile_crew_pool": {
"title": "Core Crew & skippers",
"body": "In your user profile you maintain a person pool — multiple skippers (e.g. charter) and crew for all logbooks."
},
"nav_logbook_crew": {
"title": "Crew per logbook",
"body": "Pick skipper and crew from the pool for this logbook. Travel days inherit this selection by default."
},
"nav_stats": {
"title": "Statistics dashboard",
@@ -672,9 +1028,17 @@
"title": "Send feedback",
"body": "Use this form to report bugs, ideas, or general feedback to the project team — you can also open it anytime later via the icon in the top right."
},
"nav_profile": {
"title": "Your user profile",
"body": "Tap the skipper button at the top to open your personal profile — independent of the current logbook."
},
"profile_preferences": {
"title": "Account & appearance",
"body": "Manage your account identity, theme, and light/dark mode here. You can restart the app tour anytime. Passkeys and security settings are further down on the profile page."
},
"finish": {
"title": "You're all set!",
"body": "You'll land on the statistics dashboard next. You can restart the tour anytime in Settings. Fair winds!"
"body": "You'll land on the statistics dashboard next. You can restart the tour anytime from your user profile. Fair winds!"
}
}
},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+65 -97
View File
@@ -1,64 +1,8 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
/* Minimal app shell — component styles live in App.css / themes.css */
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
*,
*::before,
*::after {
box-sizing: border-box;
}
@@ -66,46 +10,70 @@ body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
#root {
width: 100%;
max-width: 100%;
min-height: 100svh;
display: flex;
flex-direction: column;
align-items: center;
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
/* 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;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
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);
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
+49
View File
@@ -3,9 +3,26 @@ import { createRoot } from 'react-dom/client'
import 'leaflet/dist/leaflet.css'
import './themes.css'
import './index.css'
import './App.css'
import './i18n'
import App from './App.tsx'
import { applyAppearanceToDocument } from './services/appearance.ts'
import { flushPendingPwaBootEvents } from './services/analytics.ts'
import {
installStaleAssetRecovery,
markReloadAttempt,
reconcileVersionOnStartup
} from './services/pwaStartup.ts'
import { redirectToPasskeyCompatibleHostIfNeeded } from './utils/passkeyHost.ts'
declare global {
interface Window {
__KDB_MAIN_MODULE_LOADED?: boolean
__KDB_APP_BOOTSTRAPPED?: boolean
}
}
window.__KDB_MAIN_MODULE_LOADED = true
/** Stale PWA precache on localhost can shadow Vite dev modules. */
async function clearDevServiceWorkerCaches(): Promise<void> {
@@ -34,9 +51,39 @@ function renderBootstrapError(message: string): void {
}
async function bootstrap(): Promise<void> {
if (redirectToPasskeyCompatibleHostIfNeeded()) {
return
}
applyAppearanceToDocument()
installStaleAssetRecovery()
flushPendingPwaBootEvents()
window.addEventListener('load', () => {
flushPendingPwaBootEvents()
}, { once: true })
await clearDevServiceWorkerCaches()
const startupResult = await reconcileVersionOnStartup()
if (startupResult === 'reload') {
markReloadAttempt()
window.location.reload()
return
}
if (startupResult === 'recovered') {
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')
if (!rootEl) {
throw new Error('Missing #root element')
@@ -47,6 +94,7 @@ async function bootstrap(): Promise<void> {
<App />
</StrictMode>,
)
window.__KDB_APP_BOOTSTRAPPED = true
}
void bootstrap().catch((err) => {
@@ -54,4 +102,5 @@ void bootstrap().catch((err) => {
renderBootstrapError(
'Die App konnte nicht gestartet werden. Bitte neu laden oder die App vollständig beenden und erneut öffnen.',
)
window.__KDB_APP_BOOTSTRAPPED = false
})
+61
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 }
}
+73 -1
View File
@@ -26,6 +26,7 @@ export const PlausibleEvents = {
PUSH_ENABLED: 'Push Enabled',
PUSH_DISABLED: 'Push Disabled',
FOOTER_LINK_CLICKED: 'Footer Link Clicked',
KOFI_LINK_CLICKED: 'Ko-fi Link Clicked',
PROFILE_OPENED: 'Profile Opened',
PASSKEY_ADDED: 'Passkey Added',
PASSKEY_REMOVED: 'Passkey Removed',
@@ -34,12 +35,34 @@ export const PlausibleEvents = {
LOCAL_PIN_SET: 'Local PIN Set',
LOCAL_PIN_REMOVED: 'Local PIN Removed',
DEVICE_FORGOTTEN: 'Device Forgotten',
RECOVERY_ROTATED: 'Recovery Rotated'
RECOVERY_ROTATED: 'Recovery Rotated',
LANGUAGE_CHANGED: 'Language Changed',
NMEA_IMPORTED: 'NMEA Imported',
NMEA_UPLOADED: 'NMEA Uploaded',
LIVE_LOG_OPENED: 'Live Log Opened',
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded',
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
/** Where a successful OpenWeatherMap API call originated (no coordinates or place names). */
export type OwmAnalyticsSource = 'live_log' | 'entry_editor' | 'entry_editor_gps_lookup'
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
export type PlausibleEventProps = Record<string, string | number | boolean>
type PendingPwaBootEvent = {
name: PlausibleEventName
props?: PlausibleEventProps
ts?: number
}
const PWA_BOOT_PENDING_EVENTS_KEY = 'pwa_boot_pending_events'
export function trackPlausibleEvent(name: PlausibleEventName, props?: PlausibleEventProps): void {
if (typeof window.plausible !== 'function') return
@@ -49,3 +72,52 @@ export function trackPlausibleEvent(name: PlausibleEventName, props?: PlausibleE
}
window.plausible(name)
}
export function flushPendingPwaBootEvents(): number {
if (typeof window.plausible !== 'function') return 0
let raw: string | null = null
try {
raw = sessionStorage.getItem(PWA_BOOT_PENDING_EVENTS_KEY)
} catch {
return 0
}
if (!raw) return 0
let pending: PendingPwaBootEvent[]
try {
pending = JSON.parse(raw) as PendingPwaBootEvent[]
} catch {
try {
sessionStorage.removeItem(PWA_BOOT_PENDING_EVENTS_KEY)
} catch {
/* ignore storage errors */
}
return 0
}
if (!Array.isArray(pending) || pending.length === 0) {
try {
sessionStorage.removeItem(PWA_BOOT_PENDING_EVENTS_KEY)
} catch {
/* ignore storage errors */
}
return 0
}
for (const event of pending) {
if (!event || typeof event.name !== 'string') continue
if (event.props && Object.keys(event.props).length > 0) {
window.plausible(event.name, { props: event.props })
} else {
window.plausible(event.name)
}
}
try {
sessionStorage.removeItem(PWA_BOOT_PENDING_EVENTS_KEY)
} catch {
/* ignore storage errors */
}
return pending.length
}
+29 -8
View File
@@ -10,22 +10,43 @@ export class ApiError extends Error {
export async function apiFetch(
input: string,
init: RequestInit = {}
init: RequestInit = {},
timeoutMs = 15000
): Promise<Response> {
const headers = new Headers(init.headers)
if (init.body !== undefined && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
return fetch(input, {
...init,
headers,
credentials: 'include'
})
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
if (init.signal) {
if (init.signal.aborted) {
controller.abort()
} else {
init.signal.addEventListener('abort', () => controller.abort())
}
}
try {
return await fetch(input, {
...init,
headers,
credentials: 'include',
signal: controller.signal
})
} finally {
clearTimeout(timeoutId)
}
}
export async function apiJson<T>(input: string, init: RequestInit = {}): Promise<T> {
const res = await apiFetch(input, init)
export async function apiJson<T>(
input: string,
init: RequestInit = {},
timeoutMs = 15000
): Promise<T> {
const res = await apiFetch(input, init, timeoutMs)
const data = await res.json().catch(() => ({}))
if (!res.ok) {
const message =
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
fetchAppearancePrefs,
saveAppearancePrefsToServer,
syncAppearancePrefs
} from './appearancePrefs.js'
import { setThemePreference } from './userPreferences.js'
const USER_ID = 'appearance-sync-user'
vi.mock('./api.js', () => ({
apiJson: vi.fn()
}))
import { apiJson } from './api.js'
const mockedApiJson = vi.mocked(apiJson)
describe('appearancePrefs', () => {
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
})
it('fetchAppearancePrefs returns defaults when not authenticated', async () => {
await expect(fetchAppearancePrefs()).resolves.toEqual({
theme: 'auto',
colorScheme: 'auto',
persisted: false
})
expect(mockedApiJson).not.toHaveBeenCalled()
})
it('syncAppearancePrefs applies server prefs after cache wipe', async () => {
localStorage.setItem('active_userid', USER_ID)
mockedApiJson.mockResolvedValueOnce({
theme: 'ocean',
colorScheme: 'dark',
persisted: true
})
const changed = vi.fn()
window.addEventListener('appearance-changed', changed)
await syncAppearancePrefs(USER_ID)
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean')
expect(localStorage.getItem(`user_pref_color_scheme_${USER_ID}`)).toBe('dark')
expect(changed).toHaveBeenCalledTimes(1)
})
it('syncAppearancePrefs uploads local prefs when server has none', async () => {
localStorage.setItem('active_userid', USER_ID)
setThemePreference(USER_ID, 'material')
mockedApiJson
.mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', persisted: false })
.mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', persisted: true })
await syncAppearancePrefs(USER_ID)
expect(mockedApiJson).toHaveBeenCalledTimes(2)
expect(mockedApiJson).toHaveBeenLastCalledWith('/api/auth/appearance-prefs', {
method: 'PUT',
body: JSON.stringify({ theme: 'material', colorScheme: 'auto' })
})
})
it('saveAppearancePrefsToServer skips when not authenticated', async () => {
await saveAppearancePrefsToServer('ocean', 'light')
expect(mockedApiJson).not.toHaveBeenCalled()
})
it('syncAppearancePrefs skips server sync when userId does not match active session', async () => {
localStorage.setItem('active_userid', 'session-user')
setThemePreference('other-user', 'ocean')
mockedApiJson.mockResolvedValue({
theme: 'material',
colorScheme: 'dark',
persisted: true
})
await syncAppearancePrefs('other-user')
expect(mockedApiJson).not.toHaveBeenCalled()
expect(localStorage.getItem('user_pref_theme_other-user')).toBe('ocean')
})
it('syncAppearancePrefs skips server sync when active session is missing', async () => {
setThemePreference(USER_ID, 'ocean')
await syncAppearancePrefs(USER_ID)
expect(mockedApiJson).not.toHaveBeenCalled()
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean')
})
})
+76
View File
@@ -0,0 +1,76 @@
import { apiJson } from './api.js'
import { notifyAppearanceChanged } from './appearance.js'
import {
getActiveUserId,
getColorSchemePreference,
getThemePreference,
setColorSchemePreference,
setThemePreference
} from './userPreferences.js'
const API_BASE = '/api/auth/appearance-prefs'
export interface AppearancePrefs {
theme: string
colorScheme: string
persisted: boolean
}
function hasLocalAppearancePrefs(userId: string): boolean {
return (
localStorage.getItem(`user_pref_theme_${userId}`) != null ||
localStorage.getItem(`user_pref_color_scheme_${userId}`) != null
)
}
function resolveSyncedUserId(userId?: string | null): string | null {
const id = userId?.trim() || getActiveUserId()?.trim() || null
if (!id) return null
const activeId = getActiveUserId()?.trim() || null
if (!activeId || activeId !== id) return null
return id
}
export async function fetchAppearancePrefs(userId?: string | null): Promise<AppearancePrefs> {
if (!resolveSyncedUserId(userId)) {
return { theme: 'auto', colorScheme: 'auto', persisted: false }
}
return apiJson<AppearancePrefs>(API_BASE)
}
export async function saveAppearancePrefsToServer(
theme: string,
colorScheme: string,
userId?: string | null
): Promise<void> {
if (!resolveSyncedUserId(userId)) return
await apiJson<AppearancePrefs>(API_BASE, {
method: 'PUT',
body: JSON.stringify({ theme, colorScheme })
})
}
/** Merge server-stored appearance with local cache (server wins after cache wipe). */
export async function syncAppearancePrefs(userId?: string | null): Promise<void> {
const id = resolveSyncedUserId(userId)
if (!id) return
try {
const server = await fetchAppearancePrefs(id)
if (server.persisted) {
setThemePreference(id, server.theme)
setColorSchemePreference(id, server.colorScheme)
} else if (hasLocalAppearancePrefs(id)) {
await saveAppearancePrefsToServer(getThemePreference(id), getColorSchemePreference(id), id)
}
} catch (err) {
console.warn('Failed to sync appearance preferences:', err)
}
notifyAppearanceChanged()
}
+13 -2
View File
@@ -12,6 +12,7 @@ import { clearLogbookKeysCache } from './logbookKeys.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
import { db } from './db.js'
import { apiFetch, apiJson } from './api.js'
import { isWebAuthnUserAbortError } from '../utils/passkeyHost.js'
const API_BASE = '/api/auth'
@@ -361,7 +362,11 @@ export async function loginUser(username?: string): Promise<LoginResult> {
const prfRequested = !!options.extensions?.prf
try {
credentialResponse = await startAuthentication({ optionsJSON: options })
} catch (err: any) {
} catch (err: unknown) {
// User cancelled or timed out — never open a second platform prompt.
if (isWebAuthnUserAbortError(err)) {
throw err
}
if (prfRequested) {
console.warn('Passkey authentication with PRF extension failed, retrying without PRF:', err)
if (options.extensions) {
@@ -551,9 +556,15 @@ export async function deleteAccount(): Promise<boolean> {
db.deviations.clear(),
db.entries.clear(),
db.photos.clear(),
db.voiceMemos.clear(),
db.gpsTracks.clear(),
db.syncQueue.clear(),
db.logbookKeys.clear()
db.logbookKeys.clear(),
db.personPool.clear(),
db.vesselPool.clear(),
db.logbookCrewSelections.clear(),
db.logbookVesselSelections.clear(),
db.userSyncQueue.clear()
])
// Wipe localStorage and session variables
+125
View File
@@ -0,0 +1,125 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { decryptJson, encryptJson } from './crypto.js'
import { getLogbookKey } from './logbookKeys.js'
import type { PersonData } from '../types/person.js'
import { buildLogbookCrewSelection, pickActiveSkipperId } from '../utils/personSnapshots.js'
import { entryCrewFromLogbookSelection } from '../utils/personSnapshots.js'
import { saveLogbookCrewSelection } from './logbookCrewSelection.js'
const MIGRATION_FLAG = 'crew_pool_migration_v1_done'
export async function migrateLegacyCrewToPoolIfNeeded(): 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 poolByLegacyKey = new Map<string, string>()
const poolData = new Map<string, PersonData>()
for (const logbook of ownedLogbooks) {
const logbookKey = (await getLogbookKey(logbook.id)) || masterKey
const legacyCrews = await db.crews.where({ logbookId: logbook.id }).toArray()
const legacyIds: { skipperIds: string[]; crewIds: string[] } = {
skipperIds: [],
crewIds: []
}
for (const record of legacyCrews) {
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, logbookKey)) as
| PersonData
| null
if (!data) continue
const role = record.payloadId === 'skipper' ? 'skipper' : 'crew'
const personData: PersonData = { ...data, role }
const dedupeKey = `${role}:${personData.name}:${personData.passportNumber}`
let poolId = poolByLegacyKey.get(dedupeKey)
if (!poolId) {
poolId = record.payloadId === 'skipper' ? 'skipper' : record.payloadId
const existing = await db.personPool.get(poolId)
if (!existing) {
const encrypted = await encryptJson(personData, masterKey)
const now = new Date().toISOString()
await db.personPool.put({
payloadId: poolId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.userSyncQueue.put({
action: 'create',
type: 'person',
payloadId: poolId,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
poolByLegacyKey.set(dedupeKey, poolId)
poolData.set(poolId, personData)
}
if (role === 'skipper') {
if (!legacyIds.skipperIds.includes(poolId)) legacyIds.skipperIds.push(poolId)
} else {
legacyIds.crewIds.push(poolId)
}
}
const activeSkipperId = pickActiveSkipperId(legacyIds.skipperIds)
const existingSelection = await db.logbookCrewSelections.get(logbook.id)
if (!existingSelection && (activeSkipperId || legacyIds.crewIds.length > 0)) {
const selection = buildLogbookCrewSelection(
activeSkipperId,
legacyIds.crewIds,
poolData
)
await saveLogbookCrewSelection(logbook.id, selection)
const entryCrew = entryCrewFromLogbookSelection(selection)
const entries = await db.entries.where({ logbookId: logbook.id }).toArray()
for (const entry of entries) {
const dec = (await decryptJson(entry.encryptedData, entry.iv, entry.tag, logbookKey)) as Record<
string,
unknown
> | null
if (!dec) continue
if (dec.selectedSkipperId != null || (Array.isArray(dec.selectedCrewIds) && dec.selectedCrewIds.length > 0)) {
continue
}
const updated = {
...dec,
...entryCrew
}
const encrypted = await encryptJson(updated, logbookKey)
const now = new Date().toISOString()
await db.entries.put({
...entry,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'update',
type: 'entry',
payloadId: entry.payloadId,
logbookId: logbook.id,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
}
}
localStorage.setItem(MIGRATION_FLAG, userId)
} catch (err) {
console.warn('Crew pool migration failed:', err)
}
}
+35 -23
View File
@@ -37,22 +37,17 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
throw new Error('Encryption key not found. User must log in.')
}
// 1. Fetch Yacht details
const yachtRecord = await db.yachts.get(logbookId);
if (yachtRecord) {
try {
const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey);
yachtName = yacht.name || '';
homePort = yacht.port || '';
owner = yacht.owner || '';
charter = yacht.charter || '';
registration = yacht.registration || '';
callsign = yacht.callsign || '';
atis = yacht.atis || '';
mmsi = yacht.mmsi || '';
} catch (e) {
console.error('Failed to decrypt yacht details for CSV:', e);
}
const { resolveVesselForLogbook } = await import('./resolveVessel.js')
const yacht = await resolveVesselForLogbook(logbookId)
if (yacht) {
yachtName = yacht.name || ''
homePort = yacht.homePort || ''
owner = yacht.owner || ''
charter = yacht.charterCompany || ''
registration = yacht.registrationNumber || ''
callsign = yacht.callSign || ''
atis = yacht.atis || ''
mmsi = yacht.mmsi || ''
}
// 2. Fetch logbook entries
@@ -79,15 +74,16 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
// Headers matching the requested event fields & metadata
const headers = [
'Date', 'Day of Travel', 'Departure Port', 'Destination Port',
'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'AI Summary',
'Skipper Signature', 'Crew Signature',
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
'Event Time', 'MgK Course', 'RwK Course',
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State',
'Event Time', 'Event Creator', 'MgK Course', 'RwK Course',
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Visibility',
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
'Latitude', 'Longitude', 'Remarks',
'Freshwater Morning (L)', 'Freshwater Refilled (L)', 'Freshwater Evening (L)', 'Freshwater Consumption (L)',
'Fuel Morning (L)', 'Fuel Refilled (L)', 'Fuel Evening (L)', 'Fuel Consumption (L)',
'Greywater Level (L)',
'Yacht Name', 'Home Port', 'Owner', 'Charter Company', 'Registration', 'Callsign', 'ATIS', 'MMSI'
];
@@ -123,36 +119,52 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const fuelR = entry.fuel?.refilled ?? '';
const fuelE = entry.fuel?.evening ?? '';
const fuelCons = entry.fuel?.consumption ?? '';
const greywaterLevel = entry.greywater?.level ?? '';
const aiSummary = entry.aiSummary ?? '';
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {};
const eventsList = entry.events || [];
if (eventsList.length === 0) {
// Create one row even if there are no events for the day
rows.push([
dateVal, travelDay, dep, dest,
dateVal, travelDay, dep, dest, aiSummary,
signS, signC,
trackDist, trackMax, trackAvg, motorH,
'', '', '',
'', '', '', '',
'', '', '', '', '',
'', '', '', '', '',
'', '', '',
fwM, fwR, fwE, fwCons,
fuelM, fuelR, fuelE, fuelCons,
greywaterLevel,
yachtName, homePort, owner, charter, registration, callsign, atis, mmsi
].map(escapeCsvValue));
} else {
// Sort events chronologically by time
const sortedEvents = sortLogEventsByTime(eventsList);
for (const ev of sortedEvents) {
const creatorSnap = ev.creatorId ? crewSnapshots[ev.creatorId] : null;
let creatorName = '';
if (creatorSnap) {
creatorName = creatorSnap.name || '';
} else if (ev.creatorId === 'skipper') {
creatorName = 'Skipper';
} else if (ev.creatorId) {
creatorName = ev.creatorId;
}
rows.push([
dateVal, travelDay, dep, dest,
dateVal, travelDay, dep, dest, aiSummary,
signS, signC,
trackDist, trackMax, trackAvg, motorH,
ev.time || '', ev.mgk || '', ev.rwk || '',
ev.time || '', creatorName, ev.mgk || '', ev.rwk || '',
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
ev.visibility || '',
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
ev.gpsLat || '', ev.gpsLng || '', ev.remarks || '',
fwM, fwR, fwE, fwCons,
fuelM, fuelR, fuelE, fuelCons,
greywaterLevel,
yachtName, homePort, owner, charter, registration, callsign, atis, mmsi
].map(escapeCsvValue));
}
+176 -1
View File
@@ -35,6 +35,14 @@ export interface LocalDeviation {
updatedAt: string
}
export interface EntryListCache {
date: string
dayOfTravel: string
departure: string
destination: string
skipperSignStatus: 'none' | 'valid' | 'invalid'
}
export interface LocalEntry {
payloadId: string
logbookId: string
@@ -42,6 +50,8 @@ export interface LocalEntry {
iv: string
tag: string
updatedAt: string
/** Plaintext list fields — avoids full decrypt when opening the journal list. */
listCache?: EntryListCache
}
export interface LocalPhoto {
@@ -55,6 +65,16 @@ export interface LocalPhoto {
updatedAt: string
}
export interface LocalVoiceMemo {
payloadId: string
entryId: string
logbookId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface LocalGpsTrack {
entryId: string // one track per daily journal entry
logbookId: string
@@ -64,6 +84,15 @@ export interface LocalGpsTrack {
updatedAt: string
}
export interface LocalNmeaArchive {
entryId: string
logbookId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface LocalLogbookKey {
logbookId: string
encryptedKey: string
@@ -71,16 +100,76 @@ export interface LocalLogbookKey {
tag: string
}
export interface LocalPerson {
payloadId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface LocalVessel {
payloadId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface LocalLogbookCrewSelection {
logbookId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface LocalLogbookVesselSelection {
logbookId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface SyncQueueItem {
id?: number
action: 'create' | 'update' | 'delete'
type: 'yacht' | 'crew' | 'deviation' | 'entry' | 'logbook' | 'photo' | 'gpsTrack'
type:
| 'yacht'
| 'crew'
| 'deviation'
| 'entry'
| 'logbook'
| 'photo'
| 'voiceMemo'
| 'gpsTrack'
| 'logbookCrew'
| 'logbookVessel'
payloadId: string // payloadId or logbookId depending on the type
logbookId: string
data: string // JSON representation of the local record
updatedAt: string
}
export interface UserSyncQueueItem {
id?: number
action: 'create' | 'update' | 'delete'
type: 'person' | 'vessel'
payloadId: string
data: string
updatedAt: string
}
export interface EntryDraftRecord {
logbookId: string
entryId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
class DaagboxDatabase extends Dexie {
logbooks!: Table<LocalLogbook>
yachts!: Table<LocalYacht>
@@ -88,9 +177,17 @@ class DaagboxDatabase extends Dexie {
deviations!: Table<LocalDeviation>
entries!: Table<LocalEntry>
photos!: Table<LocalPhoto>
voiceMemos!: Table<LocalVoiceMemo>
gpsTracks!: Table<LocalGpsTrack>
nmeaArchives!: Table<LocalNmeaArchive>
logbookKeys!: Table<LocalLogbookKey>
personPool!: Table<LocalPerson>
vesselPool!: Table<LocalVessel>
logbookCrewSelections!: Table<LocalLogbookCrewSelection>
logbookVesselSelections!: Table<LocalLogbookVesselSelection>
syncQueue!: Table<SyncQueueItem>
userSyncQueue!: Table<UserSyncQueueItem>
entryDrafts!: Table<EntryDraftRecord, [string, string]>
constructor() {
super('DaagboxDatabase')
@@ -145,6 +242,84 @@ class DaagboxDatabase extends Dexie {
gpsTracks: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId'
})
this.version(6).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'
})
this.version(7).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',
entryDrafts: '[logbookId+entryId], updatedAt'
})
this.version(8).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',
logbookCrewSelections: 'logbookId, updatedAt',
userSyncQueue: '++id, action, type, payloadId',
entryDrafts: '[logbookId+entryId], updatedAt'
})
this.version(9).stores({
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
yachts: 'logbookId, updatedAt',
crews: 'payloadId, logbookId, updatedAt',
deviations: 'logbookId, updatedAt',
entries: 'payloadId, logbookId, updatedAt',
syncQueue: '++id, action, type, payloadId, logbookId',
photos: 'payloadId, entryId, logbookId, updatedAt',
gpsTracks: 'entryId, logbookId, updatedAt',
nmeaArchives: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId',
personPool: 'payloadId, updatedAt',
vesselPool: 'payloadId, updatedAt',
logbookCrewSelections: 'logbookId, updatedAt',
logbookVesselSelections: 'logbookId, updatedAt',
userSyncQueue: '++id, action, type, payloadId',
entryDrafts: '[logbookId+entryId], updatedAt'
})
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'
})
}
}
+36
View File
@@ -0,0 +1,36 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
clearDemoLogbookRefs,
getDemoFirstEntryStorageKey,
getDemoLogbookStorageKey
} from './demoLogbook.js'
describe('clearDemoLogbookRefs', () => {
const userId = 'user-1'
beforeEach(() => {
localStorage.clear()
localStorage.setItem('active_userid', userId)
})
it('removes demo logbook and first-entry keys for the user', () => {
const logbookId = 'lb-demo'
localStorage.setItem(getDemoLogbookStorageKey(userId), logbookId)
localStorage.setItem(getDemoFirstEntryStorageKey(userId), 'entry-1')
clearDemoLogbookRefs(userId, logbookId)
expect(localStorage.getItem(getDemoLogbookStorageKey(userId))).toBeNull()
expect(localStorage.getItem(getDemoFirstEntryStorageKey(userId))).toBeNull()
})
it('does not clear refs when logbookId does not match stored demo id', () => {
localStorage.setItem(getDemoLogbookStorageKey(userId), 'other-logbook')
localStorage.setItem(getDemoFirstEntryStorageKey(userId), 'entry-1')
clearDemoLogbookRefs(userId, 'deleted-logbook')
expect(localStorage.getItem(getDemoLogbookStorageKey(userId))).toBe('other-logbook')
expect(localStorage.getItem(getDemoFirstEntryStorageKey(userId))).toBe('entry-1')
})
})
+123 -24
View File
@@ -4,9 +4,13 @@ import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import { putEntryRecord } from '../utils/entryListCache.js'
import { syncPersonPool } from './personPoolSync.js'
import i18n from '../i18n/index.js'
import type { PersonData } from '../types/person.js'
import { buildLogbookCrewSelection } from '../utils/personSnapshots.js'
import {
buildDemoCrewRecords,
buildDemoPersonPool,
buildDemoEntryPayloads,
buildDemoYachtData
} from './demoLogbookData.js'
@@ -24,7 +28,7 @@ export function getDemoFirstEntryStorageKey(userId: string): string {
async function putEncryptedRecord(
logbookId: string,
key: ArrayBuffer,
type: 'entry' | 'crew' | 'yacht' | 'gpsTrack',
type: 'entry' | 'yacht' | 'gpsTrack' | 'logbookCrew',
payloadId: string,
data: unknown,
now: string
@@ -32,23 +36,17 @@ async function putEncryptedRecord(
const encrypted = await encryptJson(data, key)
if (type === 'entry') {
await db.entries.put({
payloadId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
} else if (type === 'crew') {
await db.crews.put({
payloadId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await putEntryRecord(
{
payloadId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
},
data as Record<string, unknown>
)
} else if (type === 'yacht') {
await db.yachts.put({
logbookId,
@@ -66,25 +64,62 @@ async function putEncryptedRecord(
tag: encrypted.tag,
updatedAt: now
})
} else if (type === 'logbookCrew') {
await db.logbookCrewSelections.put({
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
}
await db.syncQueue.put({
action: type === 'yacht' ? 'update' : 'create',
action: type === 'yacht' || type === 'logbookCrew' ? 'update' : 'create',
type,
payloadId: type === 'yacht' ? logbookId : payloadId,
payloadId: type === 'yacht' || type === 'logbookCrew' ? logbookId : payloadId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
async function seedPersonPool(masterKey: ArrayBuffer, now: string): Promise<Map<string, PersonData>> {
const poolMap = new Map<string, PersonData>()
for (const person of buildDemoPersonPool()) {
poolMap.set(person.payloadId, person.data)
const encrypted = await encryptJson(person.data, masterKey)
await db.personPool.put({
payloadId: person.payloadId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.userSyncQueue.put({
action: 'create',
type: 'person',
payloadId: person.payloadId,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
syncPersonPool().catch((err) => console.warn('Demo person pool sync failed:', err))
return poolMap
}
async function seedYachtAndCrew(logbookId: string, key: ArrayBuffer, now: string): Promise<void> {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not available')
const yachtData = buildDemoYachtData()
await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now)
for (const crew of buildDemoCrewRecords()) {
await putEncryptedRecord(logbookId, key, 'crew', crew.payloadId, crew.data, now)
}
const poolMap = await seedPersonPool(masterKey, now)
const skipperId = [...poolMap.entries()].find(([, d]) => d.role === 'skipper')?.[0] ?? null
const crewIds = [...poolMap.entries()].filter(([, d]) => d.role === 'crew').map(([id]) => id)
const selection = buildLogbookCrewSelection(skipperId, crewIds, poolMap)
await putEncryptedRecord(logbookId, key, 'logbookCrew', logbookId, selection, now)
}
export interface DemoSeedResult {
@@ -108,6 +143,7 @@ export async function seedDemoLogbookIfNeeded(): Promise<DemoSeedResult | null>
const title = i18n.t('demo.logbook_title')
return { logbookId: existingId, title, firstEntryId }
}
clearDemoLogbookRefs(userId, existingId)
}
if (!shouldSeed) return null
@@ -152,3 +188,66 @@ export function getStoredDemoFirstEntryId(): string | null {
if (!userId) return null
return localStorage.getItem(getDemoFirstEntryStorageKey(userId))
}
/** Remove persisted demo logbook pointers when the logbook no longer exists. */
export function clearDemoLogbookRefs(userId: string, logbookId?: string): void {
const storedId = localStorage.getItem(getDemoLogbookStorageKey(userId))
if (logbookId && storedId && storedId !== logbookId) return
localStorage.removeItem(getDemoLogbookStorageKey(userId))
localStorage.removeItem(getDemoFirstEntryStorageKey(userId))
}
export async function entryExistsInLogbook(logbookId: string, entryId: string): Promise<boolean> {
const entry = await db.entries.get(entryId)
return entry?.logbookId === logbookId
}
export interface TourLogbookContext {
logbookId: string
title: string
firstEntryId: string | null
}
/** Pick a logbook + first entry for the onboarding tour (handles deleted demo data). */
export async function resolveTourLogbookContext(
preferLogbookId?: string | null
): Promise<TourLogbookContext | null> {
const userId = localStorage.getItem('active_userid')
if (!userId || !getActiveMasterKey()) return null
const demoId = localStorage.getItem(getDemoLogbookStorageKey(userId))
if (demoId && !(await db.logbooks.get(demoId))) {
clearDemoLogbookRefs(userId, demoId)
}
const { fetchLogbooks } = await import('./logbook.js')
const books = await fetchLogbooks()
if (books.length === 0) return null
const activeId = localStorage.getItem('active_logbook_id')
const pick =
(preferLogbookId ? books.find((b) => b.id === preferLogbookId) : undefined) ??
(activeId ? books.find((b) => b.id === activeId) : undefined) ??
(demoId ? books.find((b) => b.id === demoId) : undefined) ??
books[0]
const firstEntryId = await resolveTourFirstEntryId(pick.id, userId)
return { logbookId: pick.id, title: pick.title, firstEntryId }
}
async function resolveTourFirstEntryId(logbookId: string, userId: string): Promise<string | null> {
const stored = localStorage.getItem(getDemoFirstEntryStorageKey(userId))
if (stored && (await entryExistsInLogbook(logbookId, stored))) {
return stored
}
if (stored) {
localStorage.removeItem(getDemoFirstEntryStorageKey(userId))
}
const localEntries = await db.entries.where({ logbookId }).toArray()
if (localEntries.length === 0) return null
localEntries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
return localEntries[0]?.payloadId ?? null
}
+92 -6
View File
@@ -1,6 +1,7 @@
import { parseTrackFile } from './trackUpload.js'
import { computeTrackStats } from '../utils/trackStats.js'
import i18n from '../i18n/index.js'
import { isGermanLocale } from '../utils/i18nLanguages.js'
import kielLaboeGpx from '../assets/demo/kiel-laboe.gpx?raw'
import laboeDampGpx from '../assets/demo/laboe-damp.gpx?raw'
@@ -15,6 +16,8 @@ const PUBLIC_DEMO_ENTRY_IDS = [
'a0000001-0000-4000-8000-000000000003'
] as const
export const PUBLIC_DEMO_SKIPPER_ID = 'skipper'
export const PUBLIC_DEMO_VESSEL_ID = 'demo-vessel-1'
const PUBLIC_DEMO_CREW_MEMBER_ID = 'a0000001-0000-4000-8000-000000000010'
export interface DemoDaySpec {
@@ -26,6 +29,7 @@ export interface DemoDaySpec {
filename: string
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
fuel: { morning: number; refilled: number; evening: number; consumption: number }
greywaterLevel?: number
motorHours?: number
events: Array<Record<string, string>>
}
@@ -47,10 +51,27 @@ export interface DemoCrewRecord {
}
}
export interface DemoVesselRecord {
payloadId: string
data: Record<string, unknown> & { name: string }
}
export interface PublicDemoFixture {
title: string
yacht: Record<string, unknown>
vesselPool: DemoVesselRecord[]
logbookVesselSelection: {
activeVesselId: string | null
vesselSnapshot: Record<string, unknown> | null
}
/** @deprecated legacy share payload */
crews: DemoCrewRecord[]
personPool: DemoCrewRecord[]
logbookCrewSelection: {
activeSkipperId: string
activeCrewIds: string[]
snapshotsById: Record<string, DemoCrewRecord['data'] & { id: string }>
}
entries: Array<Record<string, unknown> & { payloadId: string }>
gpsTracks: Array<{ entryId: string; waypoints: unknown[]; filename: string; gpxContent?: string; fileType: string }>
photos: never[]
@@ -58,7 +79,7 @@ export interface PublicDemoFixture {
}
export function buildDemoDays(): DemoDaySpec[] {
const isDe = i18n.language.startsWith('de')
const isDe = isGermanLocale(i18n.language)
return [
{
date: '2026-05-29',
@@ -69,6 +90,7 @@ export function buildDemoDays(): DemoDaySpec[] {
filename: 'kiel-laboe.gpx',
freshwater: { morning: 120, refilled: 0, evening: 105, consumption: 15 },
fuel: { morning: 85, refilled: 0, evening: 78, consumption: 7 },
greywaterLevel: 25,
events: [
{
time: '10:15',
@@ -101,6 +123,7 @@ export function buildDemoDays(): DemoDaySpec[] {
filename: 'laboe-damp.gpx',
freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 },
fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 },
greywaterLevel: 38,
motorHours: 1.5,
events: [
{
@@ -134,6 +157,7 @@ export function buildDemoDays(): DemoDaySpec[] {
filename: 'damp-schleimuende.gpx',
freshwater: { morning: 110, refilled: 0, evening: 95, consumption: 15 },
fuel: { morning: 70, refilled: 15, evening: 80, consumption: 5 },
greywaterLevel: 52,
events: [
{
time: '08:30',
@@ -161,7 +185,7 @@ export function buildDemoDays(): DemoDaySpec[] {
}
export function buildDemoYachtData(): Record<string, unknown> {
const isDe = i18n.language.startsWith('de')
const isDe = isGermanLocale(i18n.language)
return {
name: 'Seeadler',
vesselType: isDe ? 'Segelyacht' : 'Sailing yacht',
@@ -176,15 +200,22 @@ export function buildDemoYachtData(): Record<string, unknown> {
atis: '',
mmsi: '',
sails: isDe ? ['Großsegel', 'Genua', 'Spinnaker'] : ['Mainsail', 'Genoa', 'Spinnaker'],
photo: null
photo: null,
freshwaterCapacityL: 200,
fuelCapacityL: 100,
greywaterCapacityL: 80
}
}
export function buildDemoPersonPool(): DemoCrewRecord[] {
return buildDemoCrewRecords()
}
export function buildDemoCrewRecords(): DemoCrewRecord[] {
const isDe = i18n.language.startsWith('de')
const isDe = isGermanLocale(i18n.language)
return [
{
payloadId: 'skipper',
payloadId: PUBLIC_DEMO_SKIPPER_ID,
data: {
name: 'Demo Skipper',
address: isDe ? 'Am Hafen 12, 24103 Kiel' : 'Harbour Quay 12, 24103 Kiel',
@@ -218,10 +249,46 @@ export function buildDemoCrewRecords(): DemoCrewRecord[] {
]
}
function buildDemoVesselPool(yacht: Record<string, unknown>): DemoVesselRecord[] {
return [
{
payloadId: PUBLIC_DEMO_VESSEL_ID,
data: { name: String(yacht.name ?? 'Demo'), ...yacht }
}
]
}
function buildDemoLogbookVesselSelection(
yacht: Record<string, unknown>
): PublicDemoFixture['logbookVesselSelection'] {
return {
activeVesselId: PUBLIC_DEMO_VESSEL_ID,
vesselSnapshot: { id: PUBLIC_DEMO_VESSEL_ID, ...yacht }
}
}
function buildDemoLogbookCrewSelection(pool: DemoCrewRecord[]) {
const skipper = pool.find((p) => p.data.role === 'skipper')
const crew = pool.filter((p) => p.data.role === 'crew')
const snapshotsById: Record<string, DemoCrewRecord['data'] & { id: string }> = {}
for (const p of pool) {
snapshotsById[p.payloadId] = { id: p.payloadId, ...p.data }
}
return {
activeSkipperId: skipper?.payloadId ?? PUBLIC_DEMO_SKIPPER_ID,
activeCrewIds: crew.map((c) => c.payloadId),
snapshotsById
}
}
export function buildPublicDemoFixture(): PublicDemoFixture {
const title = i18n.t('demo.logbook_title')
const yacht = buildDemoYachtData()
const crews = buildDemoCrewRecords()
const vesselPool = buildDemoVesselPool(yacht)
const logbookVesselSelection = buildDemoLogbookVesselSelection(yacht)
const personPool = buildDemoPersonPool()
const crews = personPool
const logbookCrewSelection = buildDemoLogbookCrewSelection(personPool)
const days = buildDemoDays()
const entries: PublicDemoFixture['entries'] = []
const gpsTracks: PublicDemoFixture['gpsTracks'] = []
@@ -239,11 +306,18 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
destination: day.destination,
freshwater: { ...day.freshwater },
fuel: { ...day.fuel },
selectedSkipperId: logbookCrewSelection.activeSkipperId,
selectedCrewIds: [...logbookCrewSelection.activeCrewIds],
crewSnapshotsById: { ...logbookCrewSelection.snapshotsById },
signSkipper: '',
signCrew: '',
events: day.events
}
if (day.greywaterLevel != null && day.greywaterLevel > 0) {
entryPayload.greywater = { level: day.greywaterLevel }
}
if (stats) {
entryPayload.trackDistanceNm = stats.distanceNm
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
@@ -267,7 +341,11 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
return {
title,
yacht,
vesselPool,
logbookVesselSelection,
crews,
personPool,
logbookCrewSelection,
entries,
gpsTracks,
photos: [],
@@ -285,6 +363,7 @@ export function buildDemoEntryPayloads(): Array<{
entryPayload: Record<string, unknown>
trackData: { waypoints: unknown[]; gpxContent: string; filename: string; fileType: string }
}> {
const logbookCrewSelection = buildDemoLogbookCrewSelection(buildDemoPersonPool())
const days = buildDemoDays()
return days.map((day) => {
const entryId = crypto.randomUUID()
@@ -298,11 +377,18 @@ export function buildDemoEntryPayloads(): Array<{
destination: day.destination,
freshwater: { ...day.freshwater },
fuel: { ...day.fuel },
selectedSkipperId: logbookCrewSelection.activeSkipperId,
selectedCrewIds: [...logbookCrewSelection.activeCrewIds],
crewSnapshotsById: { ...logbookCrewSelection.snapshotsById },
signSkipper: '',
signCrew: '',
events: day.events
}
if (day.greywaterLevel != null && day.greywaterLevel > 0) {
entryPayload.greywater = { level: day.greywaterLevel }
}
if (stats) {
entryPayload.trackDistanceNm = stats.distanceNm
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
+53
View File
@@ -0,0 +1,53 @@
import { db } from './db.js'
import { encryptJson, decryptJson } from './crypto.js'
import { getActiveMasterKey } from './auth.js'
export interface EntryDraftRecord {
logbookId: string
entryId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export async function saveEntryDraft(
logbookId: string,
entryId: string,
payload: unknown
): Promise<void> {
const masterKey = getActiveMasterKey()
if (!masterKey) return
const { ciphertext, iv, tag } = await encryptJson(payload, masterKey)
await db.entryDrafts.put({
logbookId,
entryId,
encryptedData: ciphertext,
iv,
tag,
updatedAt: new Date().toISOString()
})
}
export async function loadEntryDraft<T = unknown>(
logbookId: string,
entryId: string
): Promise<T | null> {
const masterKey = getActiveMasterKey()
if (!masterKey) return null
const row = await db.entryDrafts.get([logbookId, entryId])
if (!row) return null
try {
return (await decryptJson(row.encryptedData, row.iv, row.tag, masterKey)) as T
} catch {
await db.entryDrafts.delete([logbookId, entryId])
return null
}
}
export async function clearEntryDraft(logbookId: string, entryId: string): Promise<void> {
await db.entryDrafts.delete([logbookId, entryId])
}
@@ -0,0 +1,106 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.js'
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
import type { LogEventPayload } from '../utils/logEntryPayload.js'
import { LIVE_EVENT_CODES } from '../utils/liveEventCodes.js'
export interface EventSeriesPoint {
entryId: string
date: string
dayOfTravel: string
time: string
summary: string
}
export interface EventSeriesSummary {
pressure: EventSeriesPoint[]
wind: EventSeriesPoint[]
motor: EventSeriesPoint[]
}
function sortPoints(points: EventSeriesPoint[]): EventSeriesPoint[] {
return [...points].sort((a, b) => {
const dateCompare = a.date.localeCompare(b.date)
if (dateCompare !== 0) return dateCompare
return a.time.localeCompare(b.time)
})
}
export async function loadLogbookEventSeries(logbookId: string): Promise<EventSeriesSummary> {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const local = await db.entries.where({ logbookId }).toArray()
const decryptedEntries: Array<{
entryId: string
date: string
dayOfTravel: string
events: LogEventPayload[]
}> = []
for (const entry of local) {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
if (!decrypted) continue
decryptedEntries.push({
entryId: entry.payloadId,
date: String(decrypted.date || ''),
dayOfTravel: String(decrypted.dayOfTravel || ''),
events: (decrypted.events as LogEventPayload[]) || []
})
}
decryptedEntries.sort((a, b) =>
compareTravelDaysChronological(
{ date: a.date, dayOfTravel: a.dayOfTravel },
{ date: b.date, dayOfTravel: b.dayOfTravel }
)
)
const pressure: EventSeriesPoint[] = []
const wind: EventSeriesPoint[] = []
const motor: EventSeriesPoint[] = []
for (const entry of decryptedEntries) {
for (const event of entry.events) {
const base = {
entryId: entry.entryId,
date: entry.date,
dayOfTravel: entry.dayOfTravel,
time: event.time
}
if (event.windPressure?.trim()) {
pressure.push({
...base,
summary: `${event.windPressure} hPa`
})
}
if (event.windDirection?.trim() || event.windStrength?.trim()) {
wind.push({
...base,
summary: [event.windDirection, event.windStrength].filter(Boolean).join(' ')
})
}
const code = event.remarks?.trim() ?? ''
if (
code === LIVE_EVENT_CODES.MOTOR_START ||
code === LIVE_EVENT_CODES.MOTOR_STOP
) {
motor.push({
...base,
summary: code === LIVE_EVENT_CODES.MOTOR_START ? 'start' : 'stop'
})
}
}
}
return {
pressure: sortPoints(pressure),
wind: sortPoints(wind),
motor: sortPoints(motor)
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { apiFetch } from './api.js'
export type FeedbackCategory = 'bug' | 'feature' | 'general'
export type FeedbackCategory = 'bug' | 'feature' | 'general' | 'translation'
export class FeedbackApiError extends Error {
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' | 'INVALID_EMAIL' | 'RATE_LIMITED' | 'SPAM_DETECTED'
+9
View File
@@ -4,6 +4,7 @@ import { encryptJson, decryptJson, encryptBuffer, decryptBuffer } from './crypto
import { getLogbookKey, saveLogbookKey, generateLogbookKey } from './logbookKeys.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
import { apiFetch } from './api.js'
import { clearDemoLogbookRefs, getDemoLogbookStorageKey } from './demoLogbook.js'
const API_BASE = '/api/logbooks'
@@ -213,6 +214,10 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
if (response.ok) {
const serverLb = await response.json()
if (serverLb.id !== localId) {
await saveLogbookKey(serverLb.id, logbookKey)
await db.logbookKeys.delete(localId)
}
await db.logbooks.put({
id: serverLb.id,
encryptedTitle: serverLb.encryptedTitle,
@@ -278,6 +283,7 @@ export async function deleteLocalLogbookCache(id: string): Promise<void> {
await db.deviations.where({ logbookId: id }).delete()
await db.entries.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.syncQueue.where({ logbookId: id }).delete()
await db.logbookKeys.where({ logbookId: id }).delete()
@@ -320,6 +326,9 @@ export async function deleteLogbook(id: string): Promise<void> {
// Perform local cascading cleanup
await deleteLocalLogbookCache(id)
if (userId && id === localStorage.getItem(getDemoLogbookStorageKey(userId))) {
clearDemoLogbookRefs(userId, id)
}
trackPlausibleEvent(PlausibleEvents.LOGBOOK_DELETED)
}
+360 -311
View File
@@ -1,3 +1,4 @@
import { formatAppDecimal } from '../utils/numberFormat.js'
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import {
@@ -9,89 +10,54 @@ import { decryptLogbookTitle, deleteLocalLogbookCache } from './logbook.js'
import { ensureLogbookKey, getLogbookKey, saveLogbookKey } from './logbookKeys.js'
import { syncLogbook } from './sync.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 const BACKUP_VERSION = 1 as const
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 { BACKUP_FORMAT, BACKUP_VERSION }
export type { BackupExportProgress, BackupManifestCounts, BackupManifestV2 }
export interface LogbookBackupPreview {
title: string
exportedAt: 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> {
const encoder = new TextEncoder()
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(
'raw',
@@ -120,26 +86,17 @@ async function wrapLogbookKey(logbookKey: ArrayBuffer, passphrase: string) {
return encryptBuffer(logbookKey, key)
}
async function unwrapLogbookKey(
wrapped: LogbookBackupFile['logbookKey'],
async function unwrapLogbookKeyFromEnc(
keyEnc: Uint8Array,
passphrase: string
): Promise<ArrayBuffer> {
const key = await deriveBackupPassphraseKey(passphrase)
return decryptBuffer(wrapped.ciphertext, wrapped.iv, wrapped.tag, key)
}
function isBackupFile(value: unknown): value is LogbookBackupFile {
if (!value || typeof value !== 'object') return false
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
)
try {
const fields = dexieFieldsFromEncBytes(keyEnc)
const cryptoKey = await deriveBackupPassphraseKey(passphrase)
return decryptBuffer(fields.encryptedData, fields.iv, fields.tag, cryptoKey)
} catch {
throw new Error('BACKUP_WRONG_PASSPHRASE')
}
}
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(
logbookId: string,
encryptedTitle: string,
logbookKey: ArrayBuffer,
payloads: LogbookBackupFile['payloads']
manifest: BackupManifestV2,
files: Record<string, Uint8Array>
): Promise<void> {
const masterKey = getActiveMasterKey()
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({
action: 'update',
type: 'yacht',
payloadId: logbookId,
logbookId,
data: encryptedPayloadData(
payloads.yacht.encryptedData,
payloads.yacht.iv,
payloads.yacht.tag
),
updatedAt: payloads.yacht.updatedAt
data: encryptedPayloadData(yacht.encryptedData, yacht.iv, yacht.tag),
updatedAt: now
})
}
if (payloads.deviation) {
const deviation = readFields(manifest.files.deviation)
if (deviation) {
items.push({
action: 'update',
type: 'deviation',
payloadId: logbookId,
logbookId,
data: encryptedPayloadData(
payloads.deviation.encryptedData,
payloads.deviation.iv,
payloads.deviation.tag
),
updatedAt: payloads.deviation.updatedAt
data: encryptedPayloadData(deviation.encryptedData, deviation.iv, deviation.tag),
updatedAt: now
})
}
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({
action: 'create',
type: 'crew',
payloadId: crew.payloadId,
logbookId,
data: encryptedPayloadData(crew.encryptedData, crew.iv, crew.tag),
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
updatedAt: crew.updatedAt
})
}
for (const entry of payloads.entries) {
for (const entry of manifest.files.entries) {
const f = readFields(entry.path)
items.push({
action: 'create',
type: 'entry',
payloadId: entry.payloadId,
logbookId,
data: encryptedPayloadData(entry.encryptedData, entry.iv, entry.tag),
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
updatedAt: entry.updatedAt
})
}
for (const photo of payloads.photos) {
for (const photo of manifest.files.photos) {
const f = readFields(photo.path)
items.push({
action: 'create',
type: 'photo',
payloadId: photo.payloadId,
logbookId,
data: encryptedPayloadData(photo.encryptedData, photo.iv, photo.tag, {
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag, {
entryId: photo.entryId
}),
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({
action: 'create',
type: 'gpsTrack',
payloadId: track.entryId,
logbookId,
data: encryptedPayloadData(track.encryptedData, track.iv, track.tag),
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
updatedAt: track.updatedAt
})
}
@@ -357,101 +275,190 @@ async function queueRestoredLogbookForSync(
async function writeBackupToDexie(
logbookId: string,
backup: LogbookBackupFile,
logbookKey: ArrayBuffer
logbookMeta: LogbookMetaJson,
logbookKey: ArrayBuffer,
manifest: BackupManifestV2,
files: Record<string, Uint8Array>
): Promise<void> {
const { logbook, payloads } = backup
await db.logbooks.put({
id: logbookId,
encryptedTitle: logbook.encryptedTitle,
updatedAt: logbook.updatedAt,
encryptedTitle: logbookMeta.encryptedTitle,
updatedAt: logbookMeta.updatedAt,
isSynced: 0,
isShared: 0,
isDemo: logbook.isDemo ? 1 : 0
isDemo: logbookMeta.isDemo ? 1 : 0
})
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({
logbookId,
encryptedData: payloads.yacht.encryptedData,
iv: payloads.yacht.iv,
tag: payloads.yacht.tag,
updatedAt: payloads.yacht.updatedAt
encryptedData: yacht.encryptedData,
iv: yacht.iv,
tag: yacht.tag,
updatedAt: logbookMeta.updatedAt
})
}
if (payloads.deviation) {
const deviation = readFields(manifest.files.deviation)
if (deviation) {
await db.deviations.put({
logbookId,
encryptedData: payloads.deviation.encryptedData,
iv: payloads.deviation.iv,
tag: payloads.deviation.tag,
updatedAt: payloads.deviation.updatedAt
encryptedData: deviation.encryptedData,
iv: deviation.iv,
tag: deviation.tag,
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(
payloads.crews.map((c) => ({
payloadId: c.payloadId,
logbookId,
encryptedData: c.encryptedData,
iv: c.iv,
tag: c.tag,
updatedAt: c.updatedAt
}))
manifest.files.crews.map((c) => {
const f = dexieFieldsFromEncBytes(readBinaryFile(files, c.path))
return {
payloadId: c.payloadId,
logbookId,
encryptedData: f.encryptedData,
iv: f.iv,
tag: f.tag,
updatedAt: c.updatedAt
}
})
)
}
if (payloads.entries.length > 0) {
if (manifest.files.entries.length > 0) {
await db.entries.bulkPut(
payloads.entries.map((e) => ({
payloadId: e.payloadId,
logbookId,
encryptedData: e.encryptedData,
iv: e.iv,
tag: e.tag,
updatedAt: e.updatedAt
}))
manifest.files.entries.map((e) => {
const f = dexieFieldsFromEncBytes(readBinaryFile(files, e.path))
return {
payloadId: e.payloadId,
logbookId,
encryptedData: f.encryptedData,
iv: f.iv,
tag: f.tag,
updatedAt: e.updatedAt
}
})
)
}
if (payloads.photos.length > 0) {
if (manifest.files.photos.length > 0) {
await db.photos.bulkPut(
payloads.photos.map((p) => ({
payloadId: p.payloadId,
entryId: p.entryId,
logbookId,
encryptedData: p.encryptedData,
iv: p.iv,
tag: p.tag,
caption: '',
updatedAt: p.updatedAt
}))
manifest.files.photos.map((p) => {
const f = dexieFieldsFromEncBytes(readBinaryFile(files, p.path))
return {
payloadId: p.payloadId,
entryId: p.entryId,
logbookId,
encryptedData: f.encryptedData,
iv: f.iv,
tag: f.tag,
caption: '',
updatedAt: p.updatedAt
}
})
)
}
if (payloads.gpsTracks.length > 0) {
await db.gpsTracks.bulkPut(
payloads.gpsTracks.map((t) => ({
entryId: t.entryId,
logbookId,
encryptedData: t.encryptedData,
iv: t.iv,
tag: t.tag,
updatedAt: t.updatedAt
}))
if (manifest.files.voiceMemos.length > 0) {
await db.voiceMemos.bulkPut(
manifest.files.voiceMemos.map((v) => {
const f = dexieFieldsFromEncBytes(readBinaryFile(files, v.path))
return {
payloadId: v.payloadId,
entryId: v.entryId,
logbookId,
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(
logbookId: string,
passphrase: string
): Promise<{ blob: Blob; filename: string; backup: LogbookBackupFile }> {
passphrase: string,
options: ExportLogbookBackupOptions = {}
): Promise<{ blob: Blob; filename: string; manifest: BackupManifestV2 }> {
if (!passphrase.trim() || passphrase.length < 8) {
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 payloads = await collectLogbookPayloads(logbookId)
const wrappedKey = await wrapLogbookKey(logbookKey, passphrase)
const wrapped = await wrapLogbookKey(logbookKey, passphrase)
const keyEnc = encBytesFromDexieFields({
encryptedData: wrapped.ciphertext,
iv: wrapped.iv,
tag: wrapped.tag
})
const backup: LogbookBackupFile = {
format: BACKUP_FORMAT,
version: BACKUP_VERSION,
const { zipBytes, manifest } = buildArchiveFromCollected(collected, keyEnc, {
exportedAt: new Date().toISOString(),
logbook: {
id: logbook.id,
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
}
}
appVersion: getAppVersion(),
onProgress: options.onProgress
})
const title = await decryptLogbookTitle(logbookId, logbook.encryptedTitle)
const safeTitle = title.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook'
const datePart = new Date().toISOString().slice(0, 10)
const filename = `${safeTitle}-${datePart}.daagbok.json`
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' })
const filename = `${safeTitle}-${datePart}.daagbok`
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> {
const text = await file.text()
let parsed: unknown
function detectLegacyJsonV1(text: string): boolean {
const trimmed = text.trimStart()
if (!trimmed.startsWith('{')) return false
try {
parsed = JSON.parse(text)
const parsed = JSON.parse(trimmed) as { format?: string; version?: number }
return parsed.format === BACKUP_FORMAT && parsed.version === 1
} 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)) {
throw new Error('BACKUP_INVALID_FORMAT')
}
return parsed
const files = unzipArchive(bytes)
const manifest = readManifestFromArchive(files)
return { manifest, files }
}
export async function previewLogbookBackup(
backup: LogbookBackupFile,
backup: ParsedLogbookBackup,
passphrase: string
): Promise<LogbookBackupPreview> {
const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase)
const parsed = JSON.parse(backup.logbook.encryptedTitle)
const title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey)
const logbookKey = await unwrapLogbookKeyFromEnc(
readBinaryFile(backup.files, backup.manifest.files.key),
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 {
title,
exportedAt: backup.exportedAt,
sourceLogbookId: backup.logbook.id,
counts: backup.counts
exportedAt: backup.manifest.exportedAt,
sourceLogbookId: backup.manifest.logbookId,
counts: backup.manifest.counts,
totalUncompressedBytes: backup.manifest.totalUncompressedBytes
}
}
@@ -540,7 +561,7 @@ export interface RestoreLogbookOptions {
}
export async function restoreLogbookBackup(
backup: LogbookBackupFile,
backup: ParsedLogbookBackup,
passphrase: string,
options: RestoreLogbookOptions = {}
): Promise<{ logbookId: string; title: string }> {
@@ -548,16 +569,22 @@ export async function restoreLogbookBackup(
throw new Error('BACKUP_NOT_AUTHENTICATED')
}
const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase)
const parsedTitle = JSON.parse(backup.logbook.encryptedTitle)
const title = await decryptJson(
parsedTitle.ciphertext,
parsedTitle.iv,
parsedTitle.tag,
logbookKey
const logbookKey = await unwrapLogbookKeyFromEnc(
readBinaryFile(backup.files, backup.manifest.files.key),
passphrase
)
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)
if (existing && !options.overwrite && !options.assignNewId) {
@@ -568,18 +595,29 @@ export async function restoreLogbookBackup(
await deleteLocalLogbookCache(targetId)
}
let prepared = backup
if (options.assignNewId || (existing && !options.overwrite)) {
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(
targetId,
prepared.logbook.encryptedTitle,
finalMeta.encryptedTitle,
logbookKey,
prepared.payloads
prepared.manifest,
prepared.files
)
if (navigator.onLine) {
@@ -599,3 +637,14 @@ export function downloadBackupBlob(blob: Blob, filename: string): void {
anchor.click()
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
}
@@ -0,0 +1,75 @@
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 { LogbookCrewSelectionData } from '../types/person.js'
import { emptyLogbookCrewSelection } from '../types/person.js'
import { buildLogbookCrewSelection } from '../utils/personSnapshots.js'
import type { PersonData } from '../types/person.js'
import { loadPersonPoolMap } from './personPool.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 loadLogbookCrewSelection(
logbookId: string
): Promise<LogbookCrewSelectionData> {
const record = await db.logbookCrewSelections.get(logbookId)
if (!record) return emptyLogbookCrewSelection()
const key = await resolveLogbookKey(logbookId)
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, key)) as
| LogbookCrewSelectionData
| null
if (!data) return emptyLogbookCrewSelection()
return {
activeSkipperId: data.activeSkipperId ?? null,
activeCrewIds: Array.isArray(data.activeCrewIds) ? data.activeCrewIds : [],
snapshotsById: data.snapshotsById && typeof data.snapshotsById === 'object' ? data.snapshotsById : {}
}
}
export async function saveLogbookCrewSelection(
logbookId: string,
selection: LogbookCrewSelectionData
): Promise<void> {
const key = await resolveLogbookKey(logbookId)
const encrypted = await encryptJson(selection, key)
const now = new Date().toISOString()
await db.logbookCrewSelections.put({
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'update',
type: 'logbookCrew',
payloadId: logbookId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
export async function saveLogbookCrewSelectionFromIds(
logbookId: string,
activeSkipperId: string | null,
activeCrewIds: string[],
poolOverride?: Map<string, PersonData>
): Promise<LogbookCrewSelectionData> {
const pool = poolOverride ?? (await loadPersonPoolMap())
const selection = buildLogbookCrewSelection(activeSkipperId, activeCrewIds, pool)
await saveLogbookCrewSelection(logbookId, selection)
return selection
}
+12
View File
@@ -91,6 +91,7 @@ export function clearLogbookKeysCache() {
export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer> {
const localLb = await db.logbooks.get(logbookId)
const encryptedTitle = localLb ? localLb.encryptedTitle : ''
const isShared = localLb?.isShared === 1
const masterKey = getActiveMasterKey()
let key = await getLogbookKey(logbookId)
@@ -103,6 +104,11 @@ export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer>
// Key works, return it
return key
} 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)...')
try {
const parsed = JSON.parse(encryptedTitle)
@@ -145,6 +151,12 @@ export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer>
// If no logbook key exists yet
if (!key) {
if (isShared) {
throw new Error(
'Shared logbook encryption key not found. Please go online and refresh your logbooks.'
)
}
if (encryptedTitle && masterKey) {
try {
// 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
}
@@ -0,0 +1,31 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parseNmeaFile } from './nmeaParse.js'
import { detectNmeaChanges } from './nmeaChangeDetection.js'
import { generateNmeaJournalCandidates } from './nmeaJournalGenerator.js'
const nmeaPath = resolve(import.meta.dirname, '../../../../testdata/tracks/kieler-foerde-5sm.nmea')
describe('kieler-foerde testdata', () => {
it('parses the sample NMEA log and yields journal candidates', () => {
const text = readFileSync(nmeaPath, 'utf8')
const result = parseNmeaFile(text, 'kieler-foerde-5sm.nmea')
expect(result.stats.checksumErrors).toBe(0)
expect(result.points.length).toBeGreaterThan(30)
expect(result.stats.sentenceTypes).toEqual(expect.arrayContaining(['RMC', 'GGA', 'MWV', 'DPT', 'MDA']))
const changes = detectNmeaChanges(result.points)
expect(changes.length).toBeGreaterThan(0)
expect(changes.some((c) => ['wind', 'engine_start', 'departure', 'speed', 'depth'].includes(c.type))).toBe(true)
const journal = generateNmeaJournalCandidates({
points: result.points,
mode: 'both',
intervalMinutes: 60,
t: (key) => key
})
expect(journal.candidates.length).toBeGreaterThanOrEqual(3)
})
})
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import type { NmeaTimePoint } from './nmeaTypes.js'
import { detectNmeaChanges } from './nmeaChangeDetection.js'
function point(
timestamp: number,
overrides: Partial<NmeaTimePoint> = {}
): NmeaTimePoint {
return { timestamp, ...overrides }
}
describe('detectNmeaChanges', () => {
it('detects significant course changes while underway', () => {
const points = [
point(0, { cog: 0, sog: 5 }),
point(60_000, { cog: 45, sog: 5 })
]
const events = detectNmeaChanges(points, {
courseDeltaDeg: 30,
windDirDeltaDeg: 30,
windSpeedDeltaKnots: 5,
pressureDeltaHpa: 2,
depthDeltaM: 1,
depthDeltaPercent: 25,
rpmIdle: 400,
rpmRunning: 800,
sogUnderWayKn: 2,
sogStoppedKn: 0.5,
anchorMinutes: 10,
speedDeltaKn: 2,
dedupeWindowMs: 60_000
})
expect(events.some((e) => e.type === 'course')).toBe(true)
const course = events.find((e) => e.type === 'course')
expect(course?.summaryParams).toMatchObject({ from: 0, to: 45 })
})
it('detects engine start when RPM rises above threshold', () => {
const points = [
point(0, { sog: 0, rpm: 0 }),
point(30_000, { sog: 3, rpm: 1200 })
]
const events = detectNmeaChanges(points)
expect(events.some((e) => e.type === 'engine_start')).toBe(true)
})
it('dedupes repeated events within the configured window', () => {
const points = [
point(0, { cog: 0, sog: 5 }),
point(10_000, { cog: 50, sog: 5 }),
point(20_000, { cog: 100, sog: 5 })
]
const events = detectNmeaChanges(points, {
courseDeltaDeg: 30,
windDirDeltaDeg: 30,
windSpeedDeltaKnots: 5,
pressureDeltaHpa: 2,
depthDeltaM: 1,
depthDeltaPercent: 25,
rpmIdle: 400,
rpmRunning: 800,
sogUnderWayKn: 2,
sogStoppedKn: 0.5,
anchorMinutes: 10,
speedDeltaKn: 2,
dedupeWindowMs: 120_000
})
const courseEvents = events.filter((e) => e.type === 'course')
expect(courseEvents.length).toBe(1)
})
})

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