Compare commits

..

167 Commits

Author SHA1 Message Date
elpatron faf3b8e3cf chore: release v0.1.1.30 2026-06-07 14:32:46 +02:00
elpatron 74ff8eb16b style: fix journal entry action buttons alignment on mobile 2026-06-07 14:27:44 +02:00
elpatron 81d3e3b777 feat: show travel day count badge on logbook dashboard 2026-06-07 14:22:17 +02:00
elpatron 97c5173e63 chore: release v0.1.1.29 2026-06-07 13:51:26 +02:00
elpatron 8b34044481 chore: switch default git remote to self-hosted Gitea instance 2026-06-07 13:46:28 +02:00
elpatron d948325a45 feat: add French and Spanish locales and update language selector 2026-06-07 13:44:27 +02:00
elpatron 8b8196f6e3 chore: release v0.1.1.28 2026-06-07 13:30:32 +02:00
elpatron 6593b320ee feat(i18n): integrate LanguageDropdown in LogbookDashboard 2026-06-07 13:26:29 +02:00
elpatron 9a931024d6 chore: revert git remote configuration to use github by default 2026-06-07 13:04:42 +02:00
elpatron 4dfe2cea4e feat(i18n): replace language cycle buttons with flag dropdown selector using inline SVGs 2026-06-07 12:59:40 +02:00
elpatron 944f4518e9 chore: update default git remote and url to point to gitea instance 2026-06-07 11:44:06 +02:00
elpatron 0c765f712c chore: release v0.1.1.27 2026-06-07 11:22:28 +02:00
elpatron 676547686b chore: update default git remote to github repository 2026-06-07 11:22:24 +02:00
elpatron 66606c5eca chore: default deployment script back to Gitea origin 2026-06-07 11:11:51 +02:00
elpatron a30fac029d chore: release v0.1.1.26 2026-06-07 11:10:43 +02:00
elpatron 796e61f4ea chore: migrate deployment script to use GitHub remote instead of origin Gitea 2026-06-07 09:09:43 +02:00
elpatron 594c65d1a5 feat: make photo capture attachments section collapsible by default 2026-06-07 09:00:14 +02:00
elpatron fafefff29b chore: release v0.1.1.25 2026-06-06 22:02:25 +02:00
elpatron 4fd7f3c6cf feat(journal): wrap Crew an diesem Reisetag card inside a collapsible accordion defaulting to collapsed 2026-06-06 21:59:25 +02:00
elpatron 262c48a01a chore: document COMPOSE_FILE in .env.example to lock environment compose stack configurations 2026-06-06 21:53:43 +02:00
elpatron 9ad3c2cf38 Add Database Size single metric and time series history chart to Admin Dashboard 2026-06-06 21:45:19 +02:00
elpatron 6848390ffa chore: release v0.1.1.24 2026-06-06 21:38:12 +02:00
elpatron 65d2215a35 Render maximized photo overlay via React Portal to resolve CSS stacking context issue 2026-06-06 21:33:47 +02:00
elpatron f321e5bbd1 Simplify photos_title localization across all languages by removing E2E encryption label 2026-06-06 21:32:01 +02:00
elpatron d2961b050a Rearrange journal cards layout according to user request order 2026-06-06 21:30:00 +02:00
elpatron 6943fd2dc4 Implement column selector customizer popover for chronological events logbook 2026-06-06 21:17:50 +02:00
elpatron f332eccf22 fix: restore click events for editing logbook title in dashboard 2026-06-06 21:11:29 +02:00
elpatron 9d2a19dbf8 feat: group freshwater, fuel, and greywater cards in collapsible Tanks section 2026-06-06 21:07:51 +02:00
elpatron e3cd89be5d feat: separate chronological events list and add event form into separate cards 2026-06-06 21:04:25 +02:00
elpatron a86da72b04 feat: implement collapsible accordions for event protocol list and form 2026-06-06 21:02:35 +02:00
elpatron 7d6f381f55 feat: implement responsive event cards for mobile viewports 2026-06-06 20:58:04 +02:00
elpatron 878be33b7c feat: add fullscreen photo viewer overlay on click & resolve appearance compat warnings 2026-06-06 20:40:13 +02:00
elpatron 318f5e65da feat: add camera/gallery choice for photos & sync AI profile pref to server 2026-06-06 20:37:21 +02:00
elpatron 8c6ab59d67 chore: release v0.1.1.23 2026-06-06 12:24:33 +02:00
elpatron a9c3e9ce3e Fix custom dialog coloring to support Light Theme via CSS variable mapping 2026-06-06 12:17:40 +02:00
elpatron 3eaf59e2b3 Implement AI consent gating, user preference settings, and Ko-fi hint 2026-06-06 12:08:46 +02:00
elpatron b1e17be7fd feat(analytics): add Plausible custom event VOICE_MEMO_TRANSCRIBED with status and mode properties 2026-06-06 11:51:07 +02:00
elpatron ac7e7c92d1 fix(asr): switch whisper model to whisper-large-v3-turbo 2026-06-06 11:43:09 +02:00
elpatron e10cef4b05 chore: remove parakeet service and configuration, switch completely to OpenRouter Whisper 2026-06-06 11:38:51 +02:00
elpatron 0ec5c51102 chore: configure parakeet to use 1 worker to significantly reduce memory footprint 2026-06-06 11:33:48 +02:00
elpatron 57b93b7ce7 fix: update transcribe route regex to support data URLs with codecs parameters 2026-06-06 11:14:24 +02:00
elpatron a4b3515711 feat: implement voice memo transcription with local parakeet container and fallback timeouts 2026-06-06 11:01:15 +02:00
elpatron 41acbaebac chore: release v0.1.1.22 2026-06-05 19:58:21 +02:00
elpatron 6c83cd7d36 feat: differentiate weather fetch errors by cause 2026-06-05 19:52:33 +02:00
elpatron 9089e1c6f9 feat: resolve user profile photos in chronological event log 2026-06-05 19:46:18 +02:00
elpatron 1504960d85 chore: release v0.1.1.21 2026-06-05 19:13:20 +02:00
elpatron 599f090895 fix: resolve unbound variable error on remote deploy to prod 2026-06-05 19:13:03 +02:00
elpatron 4eb2b4c517 chore: release v0.1.1.20 2026-06-05 19:07:30 +02:00
elpatron be3b23ed8c docs: update marketing sticker image 2026-06-05 19:07:21 +02:00
elpatron 697c5781b7 feat(deploy): Unterstützung für Staging-Backups und -Wiederherstellungen
Erweitert die Backup- und Restore-Skripte um die Möglichkeit, Staging-Umgebungen zu unterstützen. Fügt die Option `-dest stage` hinzu, um spezifische Konfigurationen für Staging zu verwenden, einschließlich separater Docker-Compose-Dateien und Datenbankcontainer. Dokumentation aktualisiert, um manuelle Tests und Umgebungsvariablen für Staging zu reflektieren.
2026-06-05 18:45:52 +02:00
elpatron 4c36c9160a feat(deploy): Server-Backup und Restore für Produktion
Automatisiert pg_dump, .env, Compose und Git-Archiv mit Tag-Zuordnung, Retention (5) und Pre-Deploy-Hook nur für Prod.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:40:47 +02:00
elpatron d559a762d2 fix(deploy): Vor Deploy sauberen und synchronen Git-Stand erzwingen
update-remotes.sh bricht ab, wenn uncommitted Änderungen bestehen oder
HEAD nicht mit origin übereinstimmt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:24:21 +02:00
elpatron a2180a302c refactor(tour): interne z-index-Schichtung im Overlay vereinfachen
Ersetzt irreführende 10001/10002-Werte durch relative Layer 1–3 innerhalb
von .app-tour-root und dokumentiert den Stacking-Context.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:15:17 +02:00
elpatron cd29115233 fix(tour): Tour-Tooltip über hervorgehobenen Profil-Schritten anzeigen
Erhöht den z-index des Tour-Overlays über app-tour-target-active, damit
das Modal in Schritt 8 (Stammcrew & Skipper) nicht von der Spotlight-Karte verdeckt wird.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:12:28 +02:00
elpatron e4b07ca896 refactor(deploy): update-prod.sh zu update-remotes.sh umbenennen
Ein Skript für Prod und Staging; update-staging.sh entfällt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:12:08 +02:00
elpatron f0c3cacb06 feat(analytics): Plausible über PLAUSIBLE_ENABLED und PLAUSIBLE_HOST steuerbar
Runtime-Konfiguration im Frontend-Container trennt Prod und Staging;
Staging deaktiviert Analytics standardmäßig.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 18:04:31 +02:00
elpatron 5821e20086 fix(deploy): Backend-Healthcheck und Staging-Wrapper absichern
Expliziter Compose-Healthcheck für das Backend, curl-Fallback und längeres
MAX_WAIT im Deploy-Skript; update-staging.sh lehnt -dest ab.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 17:52:05 +02:00
elpatron aff8d1517d feat(deploy): Staging-Umgebung und einheitliches Deploy-Skript
Fügt docker-compose.staging.yml, Staging-Dokumentation und -dest prod|stage
in update-prod.sh hinzu, damit Prod und Staging über ein Skript deploybar sind.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 17:47:12 +02:00
elpatron f4d6b11414 chore: release v0.1.1.19 2026-06-05 11:47:07 +02:00
elpatron 968e81f4fb feat(auth): Session-Wiederherstellung nach Reload ohne vollen Login
Nach gültigem Server-Cookie wird automatisch Passkey oder PIN zum Entsperren angeboten, statt die komplette Anmelde-Maske zu zeigen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 11:42:06 +02:00
elpatron 10835c9def chore: release v0.1.1.18 2026-06-05 11:30:44 +02:00
elpatron cdbc618521 fix(admin): kompakteres Mobile-Layout für Admin-Dashboard
KPI-Karten bleiben auf schmalen Viewports in zwei Spalten, Header und Filter nutzen weniger vertikalen Platz.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 11:30:25 +02:00
elpatron f75fe42910 chore: release v0.1.1.17 2026-06-05 11:15:46 +02:00
elpatron 212775ffdc fix(deploy): pass ADMIN_USER_IDS into backend container
Docker Compose did not forward the admin whitelist from .env, so production always treated every user as non-admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 11:14:56 +02:00
elpatron c80760db02 chore: release v0.1.1.16 2026-06-05 11:05:03 +02:00
elpatron cd1dd12c15 fix: require auth before rendering admin dashboard
Show login instead of AdminDashboard on /admin when unauthenticated to avoid pointless admin API calls.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 10:31:54 +02:00
elpatron 43cf589613 feat: add in-app admin navigation for whitelisted users
Detect admin access after login and expose a header button that opens /admin via client-side routing so the session stays unlocked when returning to the app.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 10:03:46 +02:00
elpatron e1cb2754c4 fix: keep session when leaving admin
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 09:32:30 +02:00
elpatron 5dedb8fac0 feat: add admin dashboard with usage stats
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 09:26:55 +02:00
elpatron 78f1659db4 chore: release v0.1.1.15 2026-06-04 19:30:53 +02:00
elpatron 935c263648 style: link KnorrLabs to dashy with compass icon badge 2026-06-04 19:29:59 +02:00
elpatron 29ac96f892 chore: release v0.1.1.14 2026-06-04 19:27:12 +02:00
elpatron 4d3b7210b3 style: replace name with email address in footer mail link badge 2026-06-04 19:27:00 +02:00
elpatron 369bca2ef1 chore: release v0.1.1.13 2026-06-04 19:23:13 +02:00
elpatron 2fcc741f5e style: style email link in footer as icon badge similar to Ko-Fi badge 2026-06-04 19:22:43 +02:00
elpatron 27722186d1 AI Video updated 2026-06-04 19:18:10 +02:00
elpatron 5710c74706 feat: add square sticker/sharepic image 2026-06-04 19:09:27 +02:00
elpatron cd27dfa27d Add AI generated marketing video 2026-06-04 19:05:36 +02:00
elpatron c4c7d42de4 feat: add portrait sharepic and update generation script 2026-06-04 18:39:07 +02:00
elpatron 71025b3d61 style: add QR code to sharepic layout 2026-06-04 18:36:51 +02:00
elpatron f790a6adcc feat: add sharepic HTML template, generation script, and client package task 2026-06-04 18:36:12 +02:00
elpatron de5a46938b chore: release v0.1.1.12 2026-06-04 18:26:15 +02:00
elpatron 16944c1a26 chore: update contact email in footer to moin@kapteins-daagbok.eu 2026-06-04 18:26:02 +02:00
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
156 changed files with 15817 additions and 3705 deletions
+25 -2
View File
@@ -1,5 +1,11 @@
OpenWeatherMapAPIKey=<owm_api_key> OpenWeatherMapAPIKey=<owm_api_key>
# OpenRouter API (AI travel day summaries — server-side proxy)
OpenRouterAPIKey=
# Optional model override (default: anthropic/claude-3.5-haiku)
# Valid examples: anthropic/claude-3.5-haiku, anthropic/claude-3-haiku, anthropic/claude-haiku-4.5
# OpenRouterModel=anthropic/claude-3.5-haiku
# DeepL API (for scripts/translate-locales.mjs and scripts/translate-flyer.mjs) # DeepL API (for scripts/translate-locales.mjs and scripts/translate-flyer.mjs)
# Free plan keys use api-free.deepl.com automatically (suffix :fx) # Free plan keys use api-free.deepl.com automatically (suffix :fx)
DeepLAPIKey= DeepLAPIKey=
@@ -9,12 +15,17 @@ DeepLAPIKey=
# Production (kapteins-daagbok.eu): # Production (kapteins-daagbok.eu):
# RP_ID=kapteins-daagbok.eu # RP_ID=kapteins-daagbok.eu
# ORIGIN=https://kapteins-daagbok.eu # ORIGIN=https://kapteins-daagbok.eu
# Staging (staging.kapteins-daagbok.eu):
# RP_ID=staging.kapteins-daagbok.eu
# ORIGIN=https://staging.kapteins-daagbok.eu
# POSTGRES_DB=daagbox_staging
# NTFY_TOPIC=kapteins-daagbok-staging-feedback
RP_ID=localhost RP_ID=localhost
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost) # Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
ORIGIN=http://localhost:5173 ORIGIN=http://localhost:5173
# Behind Nginx Proxy Manager — see docs/deployment/npm-security.md # Behind reverse proxy — see docs/deployment/npm-security.md
# TRUST_PROXY=172.16.10.10 # Docker Compose (NPM → frontend nginx → backend): TRUST_PROXY=1
# TRUST_PROXY=1 # TRUST_PROXY=1
# Docker Compose database (required for production deploy) # Docker Compose database (required for production deploy)
@@ -23,6 +34,8 @@ ORIGIN=http://localhost:5173
# POSTGRES_USER=postgres # POSTGRES_USER=postgres
# POSTGRES_PASSWORD= # POSTGRES_PASSWORD=
# POSTGRES_DB=daagbox # POSTGRES_DB=daagbox
# Optional: lock Docker Compose to a specific configuration file (e.g. staging or production) on the server:
# COMPOSE_FILE=docker-compose.staging.yml
# Optional: comma-separated CORS origins (defaults to ORIGIN; 127.0.0.1 may be allowed for CORS but not for login) # 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 # CORS_ORIGINS=http://localhost:5173
@@ -30,6 +43,10 @@ ORIGIN=http://localhost:5173
# Generate: openssl rand -base64 48 # Generate: openssl rand -base64 48
SESSION_SECRET= SESSION_SECRET=
# Admin dashboard access — comma-separated list of User IDs (UUIDs)
# Example: ADMIN_USER_IDS=11111111-2222-3333-4444-555555555555,22222222-3333-4444-5555-666666666666
ADMIN_USER_IDS=
# Web Push (VAPID) — generate with: npx web-push generate-vapid-keys # Web Push (VAPID) — generate with: npx web-push generate-vapid-keys
# Public key may also be set on the client as VITE_VAPID_PUBLIC_KEY # Public key may also be set on the client as VITE_VAPID_PUBLIC_KEY
VAPID_PUBLIC_KEY= VAPID_PUBLIC_KEY=
@@ -41,3 +58,9 @@ VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
NTFY_SERVER=https://ntfy.sh NTFY_SERVER=https://ntfy.sh
NTFY_TOPIC=kapteins-daagbok-feedback NTFY_TOPIC=kapteins-daagbok-feedback
NTFY_TOKEN=tk_example_ntfy_access_token NTFY_TOKEN=tk_example_ntfy_access_token
# Plausible Analytics (frontend container — see docs/plausible-events.md)
# Production: PLAUSIBLE_ENABLED=true, data-domain = current hostname (kapteins-daagbok.eu)
# Staging: PLAUSIBLE_ENABLED=false (default in docker-compose.staging.yml)
PLAUSIBLE_ENABLED=true
PLAUSIBLE_HOST=https://plausible.elpatron.me
+16 -2
View File
@@ -251,15 +251,27 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen). Führ
```bash ```bash
./scripts/update-prod.sh ./scripts/update-remotes.sh -dest prod
``` ```
Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar. Standard-Ziel Prod: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
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. 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.
Prod-Deploy legt vor dem Update automatisch ein Server-Backup an (DB, `.env`, Compose, App-Code). Tägliches Cron-Backup und Restore: [docs/deployment/backup.md](docs/deployment/backup.md).
Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deployment/npm-security.md). Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deployment/npm-security.md).
### Staging
Testumgebung unter [staging.kapteins-daagbok.eu](https://staging.kapteins-daagbok.eu) — Deploy ohne Release-Tag:
```bash
./scripts/update-remotes.sh -dest stage
```
Standard-Ziel Staging: `root@10.0.0.27:/opt/kapteins-daagbok-staging` — per `REMOTE_HOST`, `REMOTE_DIR`, `DEPLOY_BRANCH` überschreibbar. Details: [docs/deployment/staging.md](docs/deployment/staging.md).
## Dokumentation ## Dokumentation
| Dokument | Inhalt | | Dokument | Inhalt |
@@ -267,6 +279,8 @@ Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deploymen
| [docs/deployment/npm-security.md](docs/deployment/npm-security.md) | NPM, TLS, `trust proxy`, Security-Header | | [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/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/deployment/postgres-password.md](docs/deployment/postgres-password.md) | PostgreSQL-Passwort rotieren / App-Rolle |
| [docs/deployment/backup.md](docs/deployment/backup.md) | Server-Backup, Crontab, Restore (Prod) |
| [docs/deployment/staging.md](docs/deployment/staging.md) | Staging-VM, Deploy, `.env` |
| [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics | | [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/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 | | [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.89 0.1.1.31
+8 -5
View File
@@ -18,15 +18,18 @@ RUN npm run build
FROM nginx:1.25-alpine FROM nginx:1.25-alpine
WORKDIR /usr/share/nginx/html WORKDIR /usr/share/nginx/html
# Copy custom Nginx configuration RUN apk add --no-cache gettext
COPY client/nginx.conf /etc/nginx/conf.d/default.conf
COPY client/nginx.conf.template /etc/nginx/templates/default.conf.template
COPY client/docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# Copy built assets from builder # Copy built assets from builder
COPY --from=builder /app/dist . COPY --from=builder /app/dist .
# Expose HTTP port
EXPOSE 80 EXPOSE 80
# Health check to verify Nginx is actively running
HEALTHCHECK --interval=30s --timeout=5s --start-period=3s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=3s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1 CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:80/ || exit 1
ENTRYPOINT ["/docker-entrypoint.sh"]
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
set -eu
PLAUSIBLE_ENABLED="${PLAUSIBLE_ENABLED:-true}"
PLAUSIBLE_HOST="${PLAUSIBLE_HOST:-https://plausible.elpatron.me}"
PLAUSIBLE_HOST="${PLAUSIBLE_HOST%/}"
case "$(printf '%s' "$PLAUSIBLE_ENABLED" | tr '[:upper:]' '[:lower:]')" in
true|1|yes)
PLAUSIBLE_ENABLED_JSON=true
PLAUSIBLE_CSP=" ${PLAUSIBLE_HOST}"
;;
*)
PLAUSIBLE_ENABLED_JSON=false
PLAUSIBLE_CSP=""
;;
esac
export PLAUSIBLE_CSP
envsubst '${PLAUSIBLE_CSP}' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf
cat > /usr/share/nginx/html/runtime-config.json <<EOF
{"plausibleEnabled":${PLAUSIBLE_ENABLED_JSON},"plausibleHost":"${PLAUSIBLE_HOST}"}
EOF
exec nginx -g 'daemon off;'
+1 -1
View File
@@ -22,6 +22,7 @@
<meta name="apple-mobile-web-app-title" content="Daagbok" /> <meta name="apple-mobile-web-app-title" content="Daagbok" />
<meta name="theme-color" content="#0b0c10" /> <meta name="theme-color" content="#0b0c10" />
<script src="/appearance-bootstrap.js"></script> <script src="/appearance-bootstrap.js"></script>
<script src="/plausible-bootstrap.js"></script>
<script src="/bootstrap-watchdog.js"></script> <script src="/bootstrap-watchdog.js"></script>
<link rel="apple-touch-icon" href="/logo.png" /> <link rel="apple-touch-icon" href="/logo.png" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
@@ -38,7 +39,6 @@
<meta name="twitter:description" content="Kostenlos und werbefrei: sicheres, E2E-verschlüsseltes Logbuch für Skipper. Reisetage, GPS-Tracks, Crew- und Schiffsdaten Passkey-Anmeldung und Offline-PWA." /> <meta name="twitter:description" content="Kostenlos und werbefrei: sicheres, E2E-verschlüsseltes Logbuch für Skipper. Reisetage, GPS-Tracks, Crew- und Schiffsdaten Passkey-Anmeldung und Offline-PWA." />
<meta name="twitter:image" content="https://kapteins-daagbok.eu/logo.png" /> <meta name="twitter:image" content="https://kapteins-daagbok.eu/logo.png" />
<meta name="twitter:image:alt" content="Kapteins Daagbok Logo" /> <meta name="twitter:image:alt" content="Kapteins Daagbok Logo" />
<script defer data-domain="kapteins-daagbok.eu" src="https://plausible.elpatron.me/js/script.tagged-events.js"></script>
<title>Kapteins Daagbok Kostenloses digitales Yacht-Logbuch (werbefrei)</title> <title>Kapteins Daagbok Kostenloses digitales Yacht-Logbuch (werbefrei)</title>
</head> </head>
<body> <body>
+2 -48
View File
@@ -1,48 +1,2 @@
server { # Generated at container start from PLAUSIBLE_* — see client/nginx.conf.template and docker-entrypoint.sh
listen 80; # Local Docker Compose uses the template via client/Dockerfile entrypoint.
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=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
# Service worker and app shell must revalidate so PWA updates are detected
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
root /usr/share/nginx/html;
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=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location = /index.html {
root /usr/share/nginx/html;
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=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:5000/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
+51
View File
@@ -0,0 +1,51 @@
server {
listen 80;
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'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
# Service worker and app shell must revalidate so PWA updates are detected
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
root /usr/share/nginx/html;
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'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location = /index.html {
root /usr/share/nginx/html;
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'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:5000/api/;
proxy_http_version 1.1;
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;
}
}
+1
View File
@@ -12,6 +12,7 @@
"bip39": "^3.1.0", "bip39": "^3.1.0",
"dexie": "^4.4.2", "dexie": "^4.4.2",
"dexie-react-hooks": "^4.4.0", "dexie-react-hooks": "^4.4.0",
"fflate": "^0.8.3",
"i18next": "^26.3.0", "i18next": "^26.3.0",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
+4 -2
View File
@@ -13,6 +13,7 @@
"generate:flyer:png": "node ../scripts/generate-beta-flyer.mjs --png", "generate:flyer:png": "node ../scripts/generate-beta-flyer.mjs --png",
"generate:flyer:all": "node ../scripts/generate-beta-flyer.mjs --all", "generate:flyer:all": "node ../scripts/generate-beta-flyer.mjs --all",
"generate:flyer:setup": "playwright install chromium", "generate:flyer:setup": "playwright install chromium",
"generate:sharepic": "node ../scripts/generate-sharepic.mjs",
"translate:locales": "node ../scripts/translate-locales.mjs", "translate:locales": "node ../scripts/translate-locales.mjs",
"translate:flyer": "node ../scripts/translate-flyer.mjs", "translate:flyer": "node ../scripts/translate-flyer.mjs",
"validate:i18n": "node ../scripts/validate-i18n-keys.mjs" "validate:i18n": "node ../scripts/validate-i18n-keys.mjs"
@@ -22,15 +23,16 @@
"bip39": "^3.1.0", "bip39": "^3.1.0",
"dexie": "^4.4.2", "dexie": "^4.4.2",
"dexie-react-hooks": "^4.4.0", "dexie-react-hooks": "^4.4.0",
"fflate": "^0.8.3",
"i18next": "^26.3.0", "i18next": "^26.3.0",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"qrcode": "^1.5.4",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-i18next": "^17.0.8", "react-i18next": "^17.0.8"
"qrcode": "^1.5.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

+25
View File
@@ -0,0 +1,25 @@
/**
* Loads Plausible when enabled via /runtime-config.json (from .env in Docker / Vite dev).
* data-domain is always the current hostname (prod vs staging).
*/
(function () {
function load(cfg) {
if (!cfg || !cfg.plausibleEnabled || !cfg.plausibleHost) return
var host = String(cfg.plausibleHost).replace(/\/$/, '')
if (!host) return
var s = document.createElement('script')
s.defer = true
s.dataset.domain = window.location.hostname
s.src = host + '/js/script.tagged-events.js'
document.head.appendChild(s)
}
fetch('/runtime-config.json', { cache: 'no-store' })
.then(function (r) {
return r.ok ? r.json() : null
})
.then(load)
.catch(function () {
/* analytics optional */
})
})()
+1015 -64
View File
File diff suppressed because it is too large Load Diff
+85 -16
View File
@@ -36,6 +36,7 @@ import { syncAppearancePrefs } from './services/appearancePrefs.js'
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js' import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx' import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
import DemoViewer from './components/DemoViewer.tsx' import DemoViewer from './components/DemoViewer.tsx'
import AdminDashboard from './admin/AdminDashboard.tsx'
import PwaInstallPrompt from './components/PwaInstallPrompt.tsx' import PwaInstallPrompt from './components/PwaInstallPrompt.tsx'
import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx' import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx'
import AppFooter from './components/AppFooter.tsx' import AppFooter from './components/AppFooter.tsx'
@@ -45,12 +46,14 @@ import { db } from './services/db.js'
import { getLogbookAccess } from './services/logbookAccess.js' import { getLogbookAccess } from './services/logbookAccess.js'
import type { LogbookAccessRole } from './services/logbook.js' import type { LogbookAccessRole } from './services/logbook.js'
import { useLiveQuery } from 'dexie-react-hooks' import { useLiveQuery } from 'dexie-react-hooks'
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react' import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, BarChart2 } from 'lucide-react'
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx' import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx' import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
import ProfileHeaderButton from './components/ProfileHeaderButton.tsx' import ProfileHeaderButton from './components/ProfileHeaderButton.tsx'
import AdminHeaderButton from './components/AdminHeaderButton.tsx'
import { checkAdminAccess } from './services/adminApi.js'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cycleAppLanguage } from './utils/i18nLanguages.js' import LanguageDropdown from './components/LanguageDropdown.tsx'
import { import {
resolveTourLogbookContext, resolveTourLogbookContext,
seedDemoLogbookIfNeeded seedDemoLogbookIfNeeded
@@ -63,7 +66,7 @@ import { requestPersistentStorage } from './utils/storagePersist.js'
const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id' const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
function App() { function App() {
const { t, i18n } = useTranslation() const { t } = useTranslation()
const { confirmLeave } = useUnsavedChangesContext() const { confirmLeave } = useUnsavedChangesContext()
const { registerNavigation, registerDemoTourContext, requestStartAfterLogin, isActive, currentStepId } = useAppTour() const { registerNavigation, registerDemoTourContext, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
const [isAuthenticated, setIsAuthenticated] = useState(false) const [isAuthenticated, setIsAuthenticated] = useState(false)
@@ -92,6 +95,10 @@ function App() {
// Public demo mode (no account required) // Public demo mode (no account required)
const [isDemoMode, setIsDemoMode] = useState(() => window.location.pathname === '/demo') const [isDemoMode, setIsDemoMode] = useState(() => window.location.pathname === '/demo')
const [isAdminRoute, setIsAdminRoute] = useState(() => window.location.pathname.startsWith('/admin'))
const [isAdminUser, setIsAdminUser] = useState(false)
const [sessionChecked, setSessionChecked] = useState(false)
const [serverSessionActive, setServerSessionActive] = useState(false)
const syncQueueCount = useLiveQuery( const syncQueueCount = useLiveQuery(
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(), () => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
@@ -103,7 +110,7 @@ function App() {
[activeLogbookId] [activeLogbookId]
) )
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>('OWNER') const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>(null)
useEffect(() => { useEffect(() => {
if (!activeLogbookId) { if (!activeLogbookId) {
@@ -160,14 +167,23 @@ function App() {
}) })
}, []) }, [])
const refreshAdminAccess = useCallback(async () => {
const isAdmin = await checkAdminAccess()
setIsAdminUser(isAdmin)
}, [])
useEffect(() => { useEffect(() => {
if (!isAuthenticated) return if (!isAuthenticated) {
setIsAdminUser(false)
return
}
const userId = localStorage.getItem('active_userid') const userId = localStorage.getItem('active_userid')
if (!userId) return if (!userId) return
void syncAppearancePrefs(userId) void syncAppearancePrefs(userId)
void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool()) void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool())
void migrateLegacyYachtsToPoolIfNeeded().then(() => syncVesselPool()) void migrateLegacyYachtsToPoolIfNeeded().then(() => syncVesselPool())
}, [isAuthenticated]) void refreshAdminAccess()
}, [isAuthenticated, refreshAdminAccess])
useEffect(() => { useEffect(() => {
const handleOnline = () => { const handleOnline = () => {
@@ -199,6 +215,13 @@ function App() {
const hashParams = new URLSearchParams(window.location.hash.substring(1)) const hashParams = new URLSearchParams(window.location.hash.substring(1))
const path = window.location.pathname const path = window.location.pathname
if (path.startsWith('/admin')) {
setIsAdminRoute(true)
return
}
setIsAdminRoute(false)
if (path === '/demo') { if (path === '/demo') {
setIsDemoMode(true) setIsDemoMode(true)
setIsViewerMode(false) setIsViewerMode(false)
@@ -240,6 +263,7 @@ function App() {
const clearAuthenticatedAppState = useCallback(() => { const clearAuthenticatedAppState = useCallback(() => {
setIsAuthenticated(false) setIsAuthenticated(false)
setIsAdminUser(false)
setActiveLogbookId(null) setActiveLogbookId(null)
setActiveLogbookTitle(null) setActiveLogbookTitle(null)
setShowUserProfile(false) setShowUserProfile(false)
@@ -249,7 +273,7 @@ function App() {
/** After PWA/bfcache resume, React state may still say "logged in" while the master key is gone. */ /** After PWA/bfcache resume, React state may still say "logged in" while the master key is gone. */
const enforceUnlockedSession = useCallback(() => { const enforceUnlockedSession = useCallback(() => {
if (isViewerMode || isDemoMode || isAcceptingInvite) return if (isViewerMode || isDemoMode || isAcceptingInvite || isAdminRoute) return
// Require full local session (incl. userId) so API calls are not left headless. // Require full local session (incl. userId) so API calls are not left headless.
if (isAuthenticated && !hasUnlockedLocalSession()) { if (isAuthenticated && !hasUnlockedLocalSession()) {
clearAuthenticatedAppState() clearAuthenticatedAppState()
@@ -259,6 +283,7 @@ function App() {
isViewerMode, isViewerMode,
isDemoMode, isDemoMode,
isAcceptingInvite, isAcceptingInvite,
isAdminRoute,
clearAuthenticatedAppState clearAuthenticatedAppState
]) ])
@@ -293,6 +318,8 @@ function App() {
const session = await checkServerSession() const session = await checkServerSession()
if (cancelled) return if (cancelled) return
setServerSessionActive(session.authenticated)
if (session.authenticated) { if (session.authenticated) {
persistSessionUserId(session.userId) persistSessionUserId(session.userId)
} }
@@ -312,6 +339,10 @@ function App() {
if (!cancelled) { if (!cancelled) {
console.warn('Session restore failed:', err) console.warn('Session restore failed:', err)
} }
} finally {
if (!cancelled) {
setSessionChecked(true)
}
} }
})() })()
@@ -333,6 +364,14 @@ function App() {
setIsAcceptingInvite(false) setIsAcceptingInvite(false)
}, []) }, [])
const openAdmin = useCallback(() => {
window.history.pushState({}, document.title, '/admin')
setIsAdminRoute(true)
setIsDemoMode(false)
setIsViewerMode(false)
setIsAcceptingInvite(false)
}, [])
const selectLogbook = useCallback((id: string, title: string) => { const selectLogbook = useCallback((id: string, title: string) => {
setActiveLogbookId(id) setActiveLogbookId(id)
setActiveLogbookTitle(title) setActiveLogbookTitle(title)
@@ -497,6 +536,7 @@ function App() {
if (!(await confirmLeave())) return if (!(await confirmLeave())) return
void logoutUser() void logoutUser()
setIsAuthenticated(false) setIsAuthenticated(false)
setIsAdminUser(false)
setActiveLogbookId(null) setActiveLogbookId(null)
setActiveLogbookTitle(null) setActiveLogbookTitle(null)
setShowUserProfile(false) setShowUserProfile(false)
@@ -515,15 +555,33 @@ function App() {
localStorage.removeItem('active_logbook_title') localStorage.removeItem('active_logbook_title')
} }
const toggleLanguage = () => {
cycleAppLanguage(i18n)
}
const handleExitDemo = () => { const handleExitDemo = () => {
window.history.replaceState({}, document.title, '/') window.history.replaceState({}, document.title, '/')
syncRouteFromLocation() syncRouteFromLocation()
} }
const handleBackFromAdmin = () => {
window.history.replaceState({}, document.title, '/')
setIsAdminRoute(false)
syncRouteFromLocation()
}
if (isAdminRoute) {
if (!isAuthenticated) {
return (
<div className="auth-screen">
<AuthOnboarding onAuthenticated={handleAuthenticated} onOpenDemo={openDemo} />
</div>
)
}
return (
<div style={{ display: 'contents' }}>
<AdminDashboard onBack={handleBackFromAdmin} />
</div>
)
}
if (isDemoMode) { if (isDemoMode) {
return ( return (
<div style={{ display: 'contents' }}> <div style={{ display: 'contents' }}>
@@ -564,7 +622,17 @@ function App() {
if (!isAuthenticated) { if (!isAuthenticated) {
return ( return (
<div className="auth-screen"> <div className="auth-screen">
<AuthOnboarding onAuthenticated={handleAuthenticated} onOpenDemo={openDemo} /> {!sessionChecked ? (
<div className="auth-card glass">
<p className="dashboard-status-msg">{t('auth.restore_checking')}</p>
</div>
) : (
<AuthOnboarding
restoreSession={serverSessionActive}
onAuthenticated={handleAuthenticated}
onOpenDemo={openDemo}
/>
)}
</div> </div>
) )
} }
@@ -574,7 +642,8 @@ function App() {
const logbookReadOnly = const logbookReadOnly =
activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ' activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ'
const isLogbookOwner = const isLogbookOwner =
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1 activeAccessRole === 'OWNER' ||
(activeLogbookRecord != null && activeLogbookRecord.isShared !== 1)
if (showUserProfile) { if (showUserProfile) {
return ( return (
@@ -596,6 +665,7 @@ function App() {
onSelectLogbook={selectLogbook} onSelectLogbook={selectLogbook}
onLogout={handleLogout} onLogout={handleLogout}
onOpenProfile={() => setShowUserProfile(true)} onOpenProfile={() => setShowUserProfile(true)}
onOpenAdmin={isAdminUser ? openAdmin : undefined}
/> />
</div> </div>
) )
@@ -641,10 +711,9 @@ function App() {
{online ? <Wifi size={18} /> : <WifiOff size={18} />} {online ? <Wifi size={18} /> : <WifiOff size={18} />}
<span>{online ? 'Online' : t('sync.status_offline')}</span> <span>{online ? 'Online' : t('sync.status_offline')}</span>
</div> </div>
<LanguageDropdown variant="icon" align="right" />
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language"> {isAdminUser && <AdminHeaderButton onClick={openAdmin} />}
<Languages size={18} />
</button>
<ProfileHeaderButton onClick={() => setShowUserProfile(true)} /> <ProfileHeaderButton onClick={() => setShowUserProfile(true)} />
+252
View File
@@ -0,0 +1,252 @@
import { useEffect, useState, type ReactNode } from 'react'
import {
fetchAdminMe,
fetchAdminSummary,
fetchAdminTimeSeries,
type AdminSummary,
type AdminTimeSeriesResponse,
type AdminTimeBucket
} from '../services/adminApi.js'
import { BarChart2, Bookmark, ChevronLeft, Database, Image, MapPin, Mic, Users } from 'lucide-react'
function formatNumber(value: number): string {
return value.toLocaleString()
}
function formatBytes(bytes: number | undefined): string {
if (bytes === undefined || bytes === null) return '—'
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
const num = bytes / Math.pow(k, i)
return `${num.toFixed(1)} ${sizes[i]}`
}
function KpiCard({
icon,
label,
value
}: {
icon: ReactNode
label: string
value: number | string
}) {
return (
<div className="stats-kpi-card glass">
<div className="stats-kpi-icon">{icon}</div>
<div className="stats-kpi-body">
<span className="stats-kpi-label">{label}</span>
<span className="stats-kpi-value">{typeof value === 'number' ? formatNumber(value) : value}</span>
</div>
</div>
)
}
function TimeSeriesChart({
title,
seriesKey,
data
}: {
title: string
seriesKey: string
data: AdminTimeSeriesResponse | null
}) {
if (!data) {
return null
}
const metric = data.series.find((s) => s.metric === seriesKey)
if (!metric || metric.points.length === 0) {
return (
<div className="form-card glass">
<div className="form-header">
<BarChart2 className="form-icon" />
<h2>{title}</h2>
</div>
<p className="dashboard-status-msg">Keine Daten im gewählten Zeitraum.</p>
</div>
)
}
const max = metric.points.reduce((acc, p) => (p.count > acc ? p.count : acc), 0) || 1
return (
<div className="form-card glass">
<div className="form-header">
<BarChart2 className="form-icon" />
<h2>{title}</h2>
</div>
<div className="stats-bar-chart" role="img" aria-label={title}>
{metric.points.map((point) => {
const heightPct = Math.max(2, (point.count / max) * 100)
return (
<div key={point.date} className="stats-bar-column" title={`${point.date}: ${point.count}`}>
<span className="stats-bar-value">{point.count > 0 ? String(point.count) : ''}</span>
<div className="stats-bar-track">
<div className="stats-bar stats-bar--distance" style={{ height: `${heightPct}%` }} />
</div>
<span className="stats-bar-label">{point.date.slice(5)}</span>
</div>
)
})}
</div>
</div>
)
}
interface AdminDashboardProps {
onBack: () => void
}
export default function AdminDashboard({ onBack }: AdminDashboardProps) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [summary, setSummary] = useState<AdminSummary | null>(null)
const [timeSeries, setTimeSeries] = useState<AdminTimeSeriesResponse | null>(null)
const [bucket, setBucket] = useState<AdminTimeBucket>('day')
const [windowDays, setWindowDays] = useState(90)
useEffect(() => {
let cancelled = false
async function load() {
try {
setLoading(true)
setError(null)
await fetchAdminMe()
const [summaryRes, tsRes] = await Promise.all([
fetchAdminSummary(),
fetchAdminTimeSeries({ bucket, windowDays })
])
if (!cancelled) {
setSummary(summaryRes)
setTimeSeries(tsRes)
}
} catch (err: unknown) {
if (!cancelled) {
const message =
err instanceof Error && err.message ? err.message : 'Fehler beim Laden des Admin-Dashboards'
setError(message)
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
void load()
return () => {
cancelled = true
}
}, [bucket, windowDays])
if (loading && !summary) {
return (
<div className="admin-page">
<p className="dashboard-status-msg">Admin-Dashboard wird geladen</p>
</div>
)
}
if (error) {
return (
<div className="admin-page">
<header className="admin-header">
<button type="button" className="btn-back" onClick={onBack}>
<ChevronLeft size={16} />
Zur App
</button>
</header>
<p className="dashboard-status-msg">{error}</p>
</div>
)
}
if (!summary) {
return (
<div className="admin-page">
<p className="dashboard-status-msg">Keine Admin-Daten verfügbar.</p>
</div>
)
}
return (
<div className="admin-page">
<header className="admin-header">
<div className="admin-header-left">
<button type="button" className="btn-back" onClick={onBack}>
<ChevronLeft size={16} />
Zur App
</button>
<div>
<h1 className="admin-title">Admin-Dashboard</h1>
<p className="admin-subtitle">
Übersicht über Nutzung und Wachstum von Kapteins Daagbok.
</p>
</div>
</div>
</header>
<main className="admin-main">
<section className="stats-kpi-grid admin-kpi-grid">
<KpiCard icon={<Users size={20} />} label="Registrierte Benutzer" value={summary.totalUsers} />
<KpiCard icon={<Bookmark size={20} />} label="Logbücher" value={summary.totalLogbooks} />
<KpiCard icon={<Image size={20} />} label="Fotos" value={summary.totalPhotos} />
<KpiCard icon={<Mic size={20} />} label="Sprachmemos" value={summary.totalVoiceMemos} />
<KpiCard icon={<MapPin size={20} />} label="GPS-Tracks" value={summary.totalGpsTracks} />
<KpiCard
icon={<BarChart2 size={20} />}
label="Einträge mit AI-Zusammenfassung"
value={summary.aiSummaryEntries}
/>
<KpiCard icon={<Database size={20} />} label="Datenbankgröße" value={formatBytes(summary.dbSize)} />
</section>
<section className="admin-controls">
<div className="admin-control-group">
<span className="admin-control-label">Zeitraum</span>
<div className="admin-control-buttons">
{[30, 90, 365].map((days) => (
<button
key={days}
type="button"
className={days === windowDays ? 'btn primary' : 'btn secondary'}
onClick={() => setWindowDays(days)}
>
{days} Tage
</button>
))}
</div>
</div>
<div className="admin-control-group">
<span className="admin-control-label">Aggregation</span>
<div className="admin-control-buttons">
{(['day', 'week', 'month'] as AdminTimeBucket[]).map((b) => (
<button
key={b}
type="button"
className={b === bucket ? 'btn primary' : 'btn secondary'}
onClick={() => setBucket(b)}
>
{b === 'day' ? 'Tag' : b === 'week' ? 'Woche' : 'Monat'}
</button>
))}
</div>
</div>
</section>
<section className="admin-charts-grid">
<TimeSeriesChart title="Neue Benutzer" seriesKey="users_created" data={timeSeries} />
<TimeSeriesChart title="Neue Logbücher" seriesKey="logbooks_created" data={timeSeries} />
<TimeSeriesChart title="Foto-Aktivität" seriesKey="photos_updated" data={timeSeries} />
<TimeSeriesChart title="Datenbankgröße (MB)" seriesKey="database_size" data={timeSeries} />
</section>
</main>
</div>
)
}
@@ -0,0 +1,23 @@
import { useTranslation } from 'react-i18next'
import { LayoutDashboard } from 'lucide-react'
interface AdminHeaderButtonProps {
onClick: () => void
}
export default function AdminHeaderButton({ onClick }: AdminHeaderButtonProps) {
const { t } = useTranslation()
return (
<button
type="button"
className="btn-icon skipper-badge"
onClick={onClick}
title={t('nav.admin')}
aria-label={t('nav.admin')}
>
<LayoutDashboard size={18} aria-hidden="true" />
<span className="skipper-badge__name">{t('nav.admin')}</span>
</button>
)
}
+45 -7
View File
@@ -1,8 +1,13 @@
import { Coffee, Mail, Compass } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev' const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'
const KOFI_URL = 'https://ko-fi.com/kapteinsdaagbok'
export default function AppFooter() { export default function AppFooter() {
const { t } = useTranslation()
return ( return (
<footer className="app-version-footer"> <footer className="app-version-footer">
<span className="app-version-footer__version">v{APP_VERSION}</span> <span className="app-version-footer__version">v{APP_VERSION}</span>
@@ -10,14 +15,47 @@ export default function AppFooter() {
· ·
</span> </span>
<span className="app-version-footer__copyright"> <span className="app-version-footer__copyright">
© 2026 KnorrLabs/ © 2026
<a
href="mailto:elpatron+kd@mailbox.org"
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
>
Markus F.J. Busche
</a>
</span> </span>
<span className="app-version-footer__sep" aria-hidden="true">
·
</span>
<a
className="knorrlabs-footer-badge"
href="https://dashy.elpatron.me/"
target="_blank"
rel="noopener noreferrer"
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
>
<Compass size={14} aria-hidden="true" />
<span>KnorrLabs</span>
</a>
<span className="app-version-footer__sep" aria-hidden="true">
·
</span>
<a
className="mail-footer-badge"
href="mailto:moin@kapteins-daagbok.eu"
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
>
<Mail size={14} aria-hidden="true" />
<span>moin@kapteins-daagbok.eu</span>
</a>
<span className="app-version-footer__sep" aria-hidden="true">
·
</span>
<a
className="kofi-footer-badge"
href={KOFI_URL}
target="_blank"
rel="noopener noreferrer"
title={t('footer.kofi_title')}
aria-label={t('footer.kofi_title')}
onClick={() => trackPlausibleEvent(PlausibleEvents.KOFI_LINK_CLICKED)}
>
<Coffee size={14} aria-hidden="true" />
<span>{t('footer.kofi_label')}</span>
</a>
</footer> </footer>
) )
} }
+137 -15
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js' import LanguageDropdown from './LanguageDropdown.tsx'
import { import {
registerUser, registerUser,
loginUser, loginUser,
@@ -12,9 +12,10 @@ import {
getKnownUsernames, getKnownUsernames,
forgetUsername, forgetUsername,
hasUnlockedLocalSession, hasUnlockedLocalSession,
logoutUser logoutUser,
resolveRestoreUsername
} from '../services/auth.js' } from '../services/auth.js'
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react' import { KeyRound, ShieldAlert, HelpCircle, UserRound, X } from 'lucide-react'
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx' import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
import DisclaimerModal from './DisclaimerModal.tsx' import DisclaimerModal from './DisclaimerModal.tsx'
import BetaBadge from './BetaBadge.tsx' import BetaBadge from './BetaBadge.tsx'
@@ -27,10 +28,16 @@ import {
interface AuthOnboardingProps { interface AuthOnboardingProps {
onAuthenticated: () => void onAuthenticated: () => void
onOpenDemo?: () => void onOpenDemo?: () => void
/** Server session cookie is valid but the in-memory master key was lost (e.g. after reload). */
restoreSession?: boolean
} }
export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnboardingProps) { export default function AuthOnboarding({
const { t, i18n } = useTranslation() onAuthenticated,
onOpenDemo,
restoreSession = false
}: AuthOnboardingProps) {
const { t } = useTranslation()
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -60,7 +67,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
const [isNewRegistration, setIsNewRegistration] = useState(false) const [isNewRegistration, setIsNewRegistration] = useState(false)
const [showDisclaimer, setShowDisclaimer] = useState(false) const [showDisclaimer, setShowDisclaimer] = useState(false)
const [showHelp, setShowHelp] = useState(false) const [showHelp, setShowHelp] = useState(false)
const [showStandardLogin, setShowStandardLogin] = useState(false)
const autoUnlockAttempted = useRef(false)
const isRestoreFlow = restoreSession && !showStandardLogin
const passkeyHostOk = isPasskeyCompatibleLocation() const passkeyHostOk = isPasskeyCompatibleLocation()
const passkeyCompatibleUrl = passkeyHostOk ? null : toPasskeyCompatibleUrl(window.location.href) const passkeyCompatibleUrl = passkeyHostOk ? null : toPasskeyCompatibleUrl(window.location.href)
@@ -144,6 +154,23 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
} }
} }
useEffect(() => {
if (!isRestoreFlow || autoUnlockAttempted.current) return
const user = resolveRestoreUsername()
if (user && hasLocalPin(user)) {
autoUnlockAttempted.current = true
setUsername(user)
setShowPinLogin(true)
return
}
if (user && passkeyHostOk) {
autoUnlockAttempted.current = true
void handleLogin(user)
}
}, [isRestoreFlow, passkeyHostOk])
const handleRecoverySubmit = async (e: React.FormEvent) => { const handleRecoverySubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!recoveryInput.trim() || !encryptedPayloads) return if (!recoveryInput.trim() || !encryptedPayloads) return
@@ -240,9 +267,6 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
setKnownUsers(getKnownUsernames()) setKnownUsers(getKnownUsernames())
} }
const toggleLanguage = () => {
cycleAppLanguage(i18n)
}
const copyToClipboard = () => { const copyToClipboard = () => {
if (recoveryPhrase) { if (recoveryPhrase) {
@@ -347,10 +371,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
<div className="auth-card glass"> <div className="auth-card glass">
<div className="auth-header"> <div className="auth-header">
<KeyRound className="auth-icon accent" size={48} /> <KeyRound className="auth-icon accent" size={48} />
<h2>{t('auth.enter_pin_title')}</h2> <h2>{isRestoreFlow ? t('auth.restore_title') : t('auth.enter_pin_title')}</h2>
</div> </div>
<p className="recovery-warning"> <p className="recovery-warning">
{t('auth.enter_pin_warning')} {isRestoreFlow ? t('auth.restore_pin_warning') : t('auth.enter_pin_warning')}
</p> </p>
<form onSubmit={handlePinLoginSubmit} className="auth-form"> <form onSubmit={handlePinLoginSubmit} className="auth-form">
@@ -397,6 +421,12 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
type="button" type="button"
className="btn secondary" className="btn secondary"
onClick={() => { onClick={() => {
if (isRestoreFlow) {
setShowPinLogin(false)
setPinLoginInput('')
setError(null)
return
}
void (async () => { void (async () => {
setShowPinLogin(false) setShowPinLogin(false)
setPinLoginInput('') setPinLoginInput('')
@@ -480,6 +510,101 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
) )
} }
// Render: Session restore (active server cookie, master key lost after reload)
if (isRestoreFlow) {
const restoreUser = resolveRestoreUsername()
const restoreKnownUsers = getKnownUsernames()
return (
<div className="auth-card glass">
<div className="auth-header">
<KeyRound className="auth-icon accent" size={48} />
<h2>{t('auth.restore_title')}</h2>
</div>
<p className="recovery-warning">{t('auth.restore_subtitle')}</p>
{loading && (
<p className="dashboard-status-msg" style={{ marginTop: '12px' }}>
{t('auth.restore_unlocking')}
</p>
)}
{error && <div className="auth-error">{error}</div>}
{!loading && (
<div className="auth-actions" style={{ flexDirection: 'column', gap: '10px', marginTop: '16px' }}>
{restoreUser && passkeyHostOk && (
<button
type="button"
className="btn primary"
onClick={() => handleLogin(restoreUser)}
style={{ width: '100%' }}
>
{t('auth.restore_with_passkey', { name: restoreUser })}
</button>
)}
{restoreUser && hasLocalPin(restoreUser) && (
<button
type="button"
className="btn secondary"
onClick={() => {
setUsername(restoreUser)
setShowPinLogin(true)
}}
style={{ width: '100%' }}
>
{t('auth.restore_with_pin')}
</button>
)}
{restoreKnownUsers.length > 1 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', width: '100%' }}>
<span style={{ fontSize: '12px', color: '#64748b', textTransform: 'uppercase' }}>
{t('auth.quick_login')}
</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', width: '100%' }}>
{restoreKnownUsers.map((name) => (
<button
key={name}
type="button"
onClick={() => {
if (hasLocalPin(name)) {
setUsername(name)
setShowPinLogin(true)
} else {
void handleLogin(name)
}
}}
disabled={loading}
className="btn secondary"
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
>
<UserRound size={16} />
{name}
</button>
))}
</div>
</div>
)}
<button
type="button"
className="btn secondary"
onClick={() => {
setShowStandardLogin(true)
setError(null)
}}
style={{ width: '100%' }}
>
{t('auth.restore_other_account')}
</button>
</div>
)}
</div>
)
}
// Render 3: Standard Login / Registration options form // Render 3: Standard Login / Registration options form
return ( return (
<> <>
@@ -652,10 +777,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
</div> </div>
<div className="auth-footer"> <div className="auth-footer">
<button type="button" className="btn-icon-text" onClick={toggleLanguage}> <LanguageDropdown variant="text" align="left" />
<Languages size={18} />
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
<button <button
type="button" type="button"
className="btn-icon-text link-sec" className="btn-icon-text link-sec"
+141
View File
@@ -0,0 +1,141 @@
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) {
let snap: PersonSnapshot | undefined = crewSnapshotsById[creatorId]
// Fallback: If not found directly by key, search by role or name or active user
if (!snap) {
if (creatorId === 'skipper') {
snap = Object.values(crewSnapshotsById).find((s) => s.role === 'skipper')
} else {
// Try to match name case-insensitively
snap = Object.values(crewSnapshotsById).find(
(s) => (s.name || '').trim().toLowerCase() === creatorId.trim().toLowerCase()
)
// Try to match active username/userid to the skipper snapshot
if (!snap) {
const activeUsername = localStorage.getItem('active_username')
const activeUserId = localStorage.getItem('active_userid')
if (
(activeUsername && creatorId.toLowerCase() === activeUsername.toLowerCase()) ||
(activeUserId && creatorId === activeUserId)
) {
snap = Object.values(crewSnapshotsById).find((s) => s.role === 'skipper')
}
}
}
}
if (snap) {
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>
)
}
+1 -1
View File
@@ -818,7 +818,7 @@ export default function CrewForm({
<button className="btn-icon" onClick={() => openEditMember(m)} title="Edit"> <button className="btn-icon" onClick={() => openEditMember(m)} title="Edit">
<Edit2 size={14} /> <Edit2 size={14} />
</button> </button>
<button className="btn-icon logout" onClick={() => handleDeleteMember(m.payloadId)} title="Delete"> <button className="btn-icon danger" onClick={() => handleDeleteMember(m.payloadId)} title="Delete">
<Trash2 size={14} /> <Trash2 size={14} />
</button> </button>
</div> </div>
+5 -10
View File
@@ -1,12 +1,12 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js' import LanguageDropdown from './LanguageDropdown.tsx'
import LogbookVesselPicker from './LogbookVesselPicker.tsx' import LogbookVesselPicker from './LogbookVesselPicker.tsx'
import LogbookCrewPicker from './LogbookCrewPicker.tsx' import LogbookCrewPicker from './LogbookCrewPicker.tsx'
import type { LogbookCrewSelectionData } from '../types/person.js' import type { LogbookCrewSelectionData } from '../types/person.js'
import { personToSnapshot } from '../utils/personSnapshots.js' import { personToSnapshot } from '../utils/personSnapshots.js'
import LogEntriesList from './LogEntriesList.tsx' import LogEntriesList from './LogEntriesList.tsx'
import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react' import { Ship, Users, FileText, Lock, ChevronLeft, UserPlus } from 'lucide-react'
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js' import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
import type { VesselData } from '../types/vessel.js' import type { VesselData } from '../types/vessel.js'
import type { LogbookVesselSelectionData } from '../types/vessel.js' import type { LogbookVesselSelectionData } from '../types/vessel.js'
@@ -52,9 +52,6 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
} }
}, [registerNavigation, registerDemoTourContext, startTour, fixture.firstEntryId]) }, [registerNavigation, registerDemoTourContext, startTour, fixture.firstEntryId])
const toggleLanguage = () => {
cycleAppLanguage(i18n)
}
const { const {
title, title,
@@ -111,10 +108,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
<UserPlus size={14} style={{ marginRight: '4px' }} /> <UserPlus size={14} style={{ marginRight: '4px' }} />
{t('demo.cta_register')} {t('demo.cta_register')}
</button> </button>
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}> <LanguageDropdown variant="secondary-button" align="right" />
<Globe size={14} style={{ marginRight: '4px' }} />
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
</div> </div>
</header> </header>
@@ -156,6 +150,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
preloadedYacht={yacht} preloadedYacht={yacht}
preloadedEntries={entries} preloadedEntries={entries}
preloadedPhotos={photos} preloadedPhotos={photos}
preloadedVoiceMemos={[]}
preloadedGpsTracks={gpsTracks} preloadedGpsTracks={gpsTracks}
controlledSelectedEntryId={tourSelectedEntryId} controlledSelectedEntryId={tourSelectedEntryId}
onSelectedEntryIdChange={setTourSelectedEntryId} onSelectedEntryIdChange={setTourSelectedEntryId}
@@ -171,7 +166,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
payloadId: v.payloadId, payloadId: v.payloadId,
data: v.data as VesselData data: v.data as VesselData
}))} }))}
preloadedSelection={logbookVesselSelection as LogbookVesselSelectionData} preloadedSelection={logbookVesselSelection as unknown as LogbookVesselSelectionData}
/> />
)} )}
+3 -2
View File
@@ -6,6 +6,7 @@ import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js' import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js' import { syncLogbook } from '../services/sync.js'
import { Compass, Save, Check } from 'lucide-react' import { Compass, Save, Check } from 'lucide-react'
import { parseAppDecimalOrZero } from '../utils/numberFormat.js'
interface DeviationFormProps { interface DeviationFormProps {
logbookId: string logbookId: string
@@ -97,8 +98,8 @@ export default function DeviationForm({ logbookId, readOnly = false, preloadedDa
const sanitizedDeviations: Record<number, number> = {} const sanitizedDeviations: Record<number, number> = {}
headings.forEach((h) => { headings.forEach((h) => {
const val = deviations[h] || '' const val = deviations[h] || ''
const parsed = parseFloat(val.replace('+', '').trim()) const parsed = parseAppDecimalOrZero(val.replace('+', '').trim())
sanitizedDeviations[h] = isNaN(parsed) ? 0 : parsed sanitizedDeviations[h] = parsed
}) })
const dataToSave = { const dataToSave = {
+69 -44
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Users } from 'lucide-react' import { Users, ChevronDown, ChevronUp } from 'lucide-react'
import type { EntryCrewFields, PersonSnapshot } from '../types/person.js' import type { EntryCrewFields, PersonSnapshot } from '../types/person.js'
import { loadPersonPool } from '../services/personPool.js' import { loadPersonPool } from '../services/personPool.js'
import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js' import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js'
@@ -24,6 +24,7 @@ export default function EntryCrewSection({
preloadedPool preloadedPool
}: EntryCrewSectionProps) { }: EntryCrewSectionProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [collapsed, setCollapsed] = useState(true)
const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map()) const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map())
useEffect(() => { useEffect(() => {
@@ -90,54 +91,78 @@ export default function EntryCrewSection({
return ( return (
<div className="form-card" data-tour="entry-crew"> <div className="form-card" data-tour="entry-crew">
<div className="form-header"> <div
<Users size={22} className="form-icon" /> className="form-header accordion-header"
<h3>{t('entry_crew.title')}</h3> onClick={() => setCollapsed(!collapsed)}
</div> onKeyDown={(e) => {
<p className="help-text mb-3">{t('entry_crew.subtitle')}</p> if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
<div className="input-group mb-3"> setCollapsed(!collapsed)
<label>{t('entry_crew.day_skipper')}</label> }
{skippers.length === 0 ? ( }}
<p className="help-text">{t('entry_crew.no_skipper')}</p> role="button"
aria-expanded={!collapsed}
tabIndex={0}
>
<div className="accordion-header-title">
<Users size={22} className="form-icon" />
<h3>{t('entry_crew.title')}</h3>
</div>
{collapsed ? (
<ChevronDown size={20} className="accordion-chevron" />
) : ( ) : (
<div className="crew-selection-list"> <ChevronUp size={20} className="accordion-chevron" />
{skippers.map(([id, data]) => (
<label key={id} className="crew-selection-item">
<input
type="radio"
name={`entry-skipper-${logbookId}`}
checked={value.selectedSkipperId === id}
onChange={() => !readOnly && applyChange(id, value.selectedCrewIds)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)} )}
</div> </div>
<div className="input-group"> {!collapsed && (
<label>{t('entry_crew.day_crew')}</label> <>
{crewEntries.length === 0 ? ( <p className="help-text mb-3" style={{ marginTop: '16px' }}>{t('entry_crew.subtitle')}</p>
<p className="help-text">{t('entry_crew.no_crew')}</p>
) : ( <div className="input-group mb-3">
<div className="crew-selection-list"> <label>{t('entry_crew.day_skipper')}</label>
{crewEntries.map(([id, data]) => ( {skippers.length === 0 ? (
<label key={id} className="crew-selection-item"> <p className="help-text">{t('entry_crew.no_skipper')}</p>
<input ) : (
type="checkbox" <div className="crew-selection-list">
checked={value.selectedCrewIds.includes(id)} {skippers.map(([id, data]) => (
onChange={() => toggleCrew(id)} <label key={id} className="crew-selection-item">
disabled={readOnly} <input
/> type="radio"
<span>{data.name || t('logbook_crew.unnamed')}</span> name={`entry-skipper-${logbookId}`}
</label> checked={value.selectedSkipperId === id}
))} onChange={() => !readOnly && applyChange(id, value.selectedCrewIds)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)}
</div> </div>
)}
</div> <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> </div>
) )
} }
+140
View File
@@ -0,0 +1,140 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Mic, Loader2 } from 'lucide-react'
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'
import { useDialog } from './ModalDialog.tsx'
import { updateVoiceMemoTranscript } from '../services/voiceAttachments.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getAiAuthorized } from '../services/userPreferences.js'
interface EventRemarksCellProps {
event: LogEventPayload
logbookId: string
voiceMemoLookup?: Map<string, PreloadedVoiceMemo>
readOnly?: boolean
}
export default function EventRemarksCell({
event,
logbookId,
voiceMemoLookup,
readOnly = false
}: EventRemarksCellProps) {
const { t } = useTranslation()
const { showAlert } = useDialog()
const voiceId = parseLiveVoiceRemark(event.remarks.trim())
const preloaded = voiceId ? voiceMemoLookup?.get(voiceId) : undefined
const [transcribing, setTranscribing] = useState(false)
const [isOnline, setIsOnline] = useState(navigator.onLine)
useEffect(() => {
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
const handleTranscribe = async (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (transcribing || !preloaded?.audio || !voiceId) return
if (!getAiAuthorized()) {
void showAlert(
t('profile.ai_unauthorized_alert_desc'),
t('profile.ai_unauthorized_alert_title')
)
return
}
setTranscribing(true)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 15000)
try {
const res = await fetch('/api/ai/transcribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ audioDataUrl: preloaded.audio }),
signal: controller.signal
})
clearTimeout(timeoutId)
if (!res.ok) {
throw new Error(`Server returned status ${res.status}`)
}
const data = await res.json()
const text = (data.text || '').trim()
if (!text) {
throw new Error('Transcription returned empty text')
}
await updateVoiceMemoTranscript(logbookId, voiceId, text)
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
status: 'success',
mode: 'manual'
})
} catch (err) {
clearTimeout(timeoutId)
console.error('[EventRemarksCell] Transcription failed:', err)
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
status: 'failed',
mode: 'manual'
})
void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn'))
} finally {
setTranscribing(false)
}
}
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 && (
<div style={{ display: 'inline-flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px', marginTop: '4px' }}>
<VoiceMemoPlayer
audioId={voiceId}
logbookId={logbookId}
preloaded={preloaded}
compact
/>
{!readOnly && preloaded && preloaded.transcribed === false && isOnline && (
<button
type="button"
className="btn-icon-text link-sec"
style={{
fontSize: '0.8rem',
padding: '2px 6px',
height: 'auto',
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
margin: 0
}}
onClick={handleTranscribe}
disabled={transcribing}
title={t('logs.live_voice_transcribe_action')}
>
{transcribing ? (
<Loader2 size={12} className="spin" />
) : (
<Mic size={12} />
)}
{transcribing ? t('logs.live_voice_transcribing') : t('logs.live_voice_transcribe_action')}
</button>
)}
</div>
)}
</div>
)
}
@@ -1,5 +1,6 @@
import { useId, useMemo } from 'react' import { useId, useMemo } from 'react'
import { joinTimeHHMM, splitTimeHHMM } from '../utils/logEntryPayload.js' import { joinTimeHHMM, splitTimeHHMM } from '../utils/logEntryPayload.js'
import { preferNativeCameraPicker } from '../utils/captureVideoFrame.js'
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0')) const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'))
const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0')) const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'))
@@ -18,7 +19,29 @@ export default function EventTimeInput24h({
'aria-label': ariaLabel 'aria-label': ariaLabel
}: EventTimeInput24hProps) { }: EventTimeInput24hProps) {
const baseId = useId() const baseId = useId()
const useNativePicker = preferNativeCameraPicker()
const { hours, minutes } = useMemo(() => splitTimeHHMM(value), [value]) const { hours, minutes } = useMemo(() => splitTimeHHMM(value), [value])
const timeValue = useMemo(() => joinTimeHHMM(hours, minutes), [hours, minutes])
if (useNativePicker) {
return (
<div className="time-input-24h">
<input
id={baseId}
type="time"
step={60}
className="input-text time-input-24h__native"
value={timeValue}
onChange={(e) => {
const next = e.target.value
if (next) onChange(next.slice(0, 5))
}}
disabled={disabled}
aria-label={ariaLabel}
/>
</div>
)
}
return ( return (
<div className="time-input-24h"> <div className="time-input-24h">
+47
View File
@@ -0,0 +1,47 @@
import { useTranslation } from 'react-i18next'
import { Signal } from 'lucide-react'
import {
formatGpsAccuracyMeters,
gpsQualityI18nKey,
type GpsSignalQuality
} from '../utils/geolocation.js'
const SIGNAL_BARS: Record<GpsSignalQuality, number> = {
excellent: 4,
good: 3,
fair: 2,
poor: 1,
unknown: 0
}
interface GpsSignalHintProps {
quality: GpsSignalQuality
accuracyM: number | null
className?: string
}
export default function GpsSignalHint({ quality, accuracyM, className = '' }: GpsSignalHintProps) {
const { t } = useTranslation()
const bars = SIGNAL_BARS[quality]
const i18nParams = accuracyM != null ? { accuracy: formatGpsAccuracyMeters(accuracyM) } : undefined
return (
<p
className={`gps-signal-hint gps-signal-${quality} ${className}`.trim()}
role="status"
>
<span className="gps-signal-hint-label">
<Signal size={14} aria-hidden className="gps-signal-icon" />
<span className="gps-signal-bars" aria-hidden>
{[1, 2, 3, 4].map((level) => (
<span
key={level}
className={`gps-signal-bar ${level <= bars ? 'is-active' : ''}`}
/>
))}
</span>
<span>{t(gpsQualityI18nKey(quality), i18nParams)}</span>
</span>
</p>
)
}
+4 -10
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react' import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js' import LanguageDropdown from './LanguageDropdown.tsx'
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight, KeyRound } from 'lucide-react' import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, ArrowRight, KeyRound } from 'lucide-react'
import { import {
getActiveMasterKey, getActiveMasterKey,
registerUser, registerUser,
@@ -50,7 +50,7 @@ const hexToBuffer = (hex: string): ArrayBuffer => {
} }
export default function InvitationAcceptance({ onAccepted, onCancel }: InvitationAcceptanceProps) { export default function InvitationAcceptance({ onAccepted, onCancel }: InvitationAcceptanceProps) {
const { t, i18n } = useTranslation() const { t } = useTranslation()
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [accepting, setAccepting] = useState(false) const [accepting, setAccepting] = useState(false)
@@ -308,9 +308,6 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
setIsLoggedIn(true) setIsLoggedIn(true)
} }
const toggleLanguage = () => {
cycleAppLanguage(i18n)
}
if (recoveryPhrase) { if (recoveryPhrase) {
return ( return (
@@ -510,10 +507,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
)} )}
<div className="auth-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '16px', marginTop: '24px' }}> <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}> <LanguageDropdown variant="text" align="left" />
<Languages size={18} />
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
</div> </div>
</div> </div>
) )
+206
View File
@@ -0,0 +1,206 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Languages, Globe, ChevronDown } from 'lucide-react'
import {
SUPPORTED_LANGUAGES,
changeAppLanguage,
normalizeAppLanguage,
type AppLanguage
} from '../utils/i18nLanguages.js'
function FlagIcon({ lang, className, style }: { lang: string; className?: string; style?: React.CSSProperties }) {
const baseStyle = {
display: 'inline-block',
verticalAlign: 'middle',
borderRadius: '2px',
overflow: 'hidden',
border: '1px solid rgba(255, 255, 255, 0.15)',
boxSizing: 'border-box' as const,
...style
}
switch (lang) {
case 'de':
return (
<svg viewBox="0 0 5 3" className={className} style={baseStyle}>
<rect width="5" height="3" fill="#FFCE00"/>
<rect width="5" height="2" fill="#DD0000"/>
<rect width="5" height="1" fill="#000000"/>
</svg>
)
case 'en':
return (
<svg viewBox="0 0 60 30" className={className} style={baseStyle}>
<clipPath id="union-jack-clip">
<path d="M0,0 L60,30 M60,0 L0,30"/>
</clipPath>
<rect width="60" height="30" fill="#012169"/>
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#fff" strokeWidth="6"/>
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#C8102E" strokeWidth="4" clipPath="url(#union-jack-clip)"/>
<path d="M30,0 v30 M0,15 h60" stroke="#fff" strokeWidth="10"/>
<path d="M30,0 v30 M0,15 h60" stroke="#C8102E" strokeWidth="6"/>
</svg>
)
case 'da':
return (
<svg viewBox="0 0 37 28" className={className} style={baseStyle}>
<rect width="37" height="28" fill="#C8102E"/>
<path d="M12,0 h4 v28 h-4 z M0,12 h37 v4 h-37 z" fill="#FFFFFF"/>
</svg>
)
case 'sv':
return (
<svg viewBox="0 0 16 10" className={className} style={baseStyle}>
<rect width="16" height="10" fill="#006AA7"/>
<path d="M5,0 h2 v10 h-2 z M0,4 h16 v2 h-16 z" fill="#FECC00"/>
</svg>
)
case 'nb':
return (
<svg viewBox="0 0 22 16" className={className} style={baseStyle}>
<rect width="22" height="16" fill="#BA0C2F"/>
<path d="M6,0 h4 v16 h-4 z M0,6 h22 v4 h-22 z" fill="#FFFFFF"/>
<path d="M7,0 h2 v16 h-2 z M0,7 h22 v2 h-22 z" fill="#00205B"/>
</svg>
)
case 'fr':
return (
<svg viewBox="0 0 3 2" className={className} style={baseStyle}>
<rect width="3" height="2" fill="#FFFFFF"/>
<rect width="1" height="2" fill="#002395"/>
<rect x="2" width="1" height="2" fill="#ED2939"/>
</svg>
)
case 'es':
return (
<svg viewBox="0 0 3 2" className={className} style={baseStyle}>
<rect width="3" height="2" fill="#C1272D"/>
<rect y="0.5" width="3" height="1" fill="#FEE100"/>
</svg>
)
default:
return null
}
}
interface LanguageDropdownProps {
variant?: 'icon' | 'text' | 'secondary-button'
align?: 'left' | 'right'
}
export default function LanguageDropdown({
variant = 'icon',
align = 'right'
}: LanguageDropdownProps) {
const { t, i18n } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
const activeLang = normalizeAppLanguage(i18n.language)
useEffect(() => {
if (!isOpen) return
const closeOnOutsideClick = (event: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setIsOpen(false)
}
document.addEventListener('mousedown', closeOnOutsideClick)
document.addEventListener('keydown', closeOnEscape)
return () => {
document.removeEventListener('mousedown', closeOnOutsideClick)
document.removeEventListener('keydown', closeOnEscape)
}
}, [isOpen])
const selectLanguage = (lang: AppLanguage) => {
changeAppLanguage(i18n, lang)
setIsOpen(false)
}
// Trigger button content based on variant
const renderTriggerContent = () => {
const name = t(`languages.${activeLang}`)
if (variant === 'icon') {
return (
<span className="lang-dropdown-trigger-flag" aria-hidden="true">
<FlagIcon lang={activeLang} className="lang-flag-svg trigger-icon-only" />
</span>
)
}
if (variant === 'secondary-button') {
return (
<>
<Globe size={14} style={{ marginRight: '4px' }} />
<FlagIcon lang={activeLang} className="lang-flag-svg" style={{ marginRight: '4px' }} />
<span className="lang-trigger-name">{name}</span>
<ChevronDown size={12} className="lang-dropdown-chevron" />
</>
)
}
// Default or "text" variant (used in footer)
return (
<>
<Languages size={18} />
<FlagIcon lang={activeLang} className="lang-flag-svg" style={{ margin: '0 4px' }} />
<span>{name}</span>
<ChevronDown size={14} className="lang-dropdown-chevron" />
</>
)
}
const triggerClass =
variant === 'icon'
? 'btn-icon'
: variant === 'secondary-button'
? 'btn secondary compact'
: 'btn-icon-text'
return (
<div
className={`lang-dropdown ${isOpen ? 'is-open' : ''} align-${align}`}
ref={rootRef}
>
<button
type="button"
className={triggerClass}
onClick={() => setIsOpen((prev) => !prev)}
aria-haspopup="listbox"
aria-expanded={isOpen}
title="Switch Language"
style={variant === 'secondary-button' ? { width: 'auto', padding: '6px 12px', fontSize: '13px' } : undefined}
>
{renderTriggerContent()}
</button>
{isOpen && (
<ul className="lang-dropdown-menu" role="listbox">
{SUPPORTED_LANGUAGES.map((lang) => {
const isSelected = lang === activeLang
return (
<li
key={lang}
role="option"
aria-selected={isSelected}
className={`lang-dropdown-option ${isSelected ? 'is-selected' : ''}`}
onClick={() => selectLanguage(lang)}
>
<FlagIcon lang={lang} className="lang-flag-svg" />
<span className="lang-option-name">{t(`languages.${lang}`)}</span>
</li>
)
})}
</ul>
)}
</div>
)
}
+45 -15
View File
@@ -1,6 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Camera, X } from 'lucide-react' import { Camera, X } from 'lucide-react'
import {
cameraErrorKeyFromDomException,
probeCameraAvailability
} from '../utils/cameraAvailability.js'
import { import {
captureVideoFrame, captureVideoFrame,
preferNativeCameraPicker preferNativeCameraPicker
@@ -15,7 +19,7 @@ interface LiveCameraCaptureProps {
onCapture: (blob: Blob) => void onCapture: (blob: Blob) => void
} }
type Phase = 'live' | 'preview' | 'native' type Phase = 'checking' | 'live' | 'preview' | 'native'
export default function LiveCameraCapture({ export default function LiveCameraCapture({
open, open,
@@ -34,7 +38,7 @@ export default function LiveCameraCapture({
const [cameraError, setCameraError] = useState<string | null>(null) const [cameraError, setCameraError] = useState<string | null>(null)
const [ready, setReady] = useState(false) const [ready, setReady] = useState(false)
const [capturing, setCapturing] = useState(false) const [capturing, setCapturing] = useState(false)
const [phase, setPhase] = useState<Phase>(() => (preferNativeCameraPicker() ? 'native' : 'live')) const [phase, setPhase] = useState<Phase>('checking')
const [previewUrl, setPreviewUrl] = useState<string | null>(null) const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null) const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
const [streamGeneration, setStreamGeneration] = useState(0) const [streamGeneration, setStreamGeneration] = useState(0)
@@ -87,12 +91,37 @@ export default function LiveCameraCapture({
clearPreview() clearPreview()
setCameraError(null) setCameraError(null)
setCapturing(false) setCapturing(false)
setPhase(preferNativeCameraPicker() ? 'native' : 'live') setPhase('checking')
return return
} }
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
let cancelled = false
clearPreview() clearPreview()
}, [open, stopStream, clearPreview]) setCameraError(null)
setCapturing(false)
setPhase('checking')
const probe = async () => {
const availability = await probeCameraAvailability()
if (cancelled) return
if (availability === 'unsupported') {
setCameraError(t('logs.live_photo_camera_unavailable'))
return
}
if (availability === 'none') {
setCameraError(t('logs.live_photo_no_camera'))
return
}
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
}
void probe()
return () => {
cancelled = true
}
}, [open, clearPreview, stopStream, t])
useEffect(() => { useEffect(() => {
if (!open || phase !== 'live') { if (!open || phase !== 'live') {
@@ -105,11 +134,6 @@ export default function LiveCameraCapture({
const start = async () => { const start = async () => {
setCameraError(null) setCameraError(null)
setReady(false) setReady(false)
if (!navigator.mediaDevices?.getUserMedia) {
setCameraError(t('logs.live_photo_camera_unavailable'))
return
}
try { try {
const stream = await navigator.mediaDevices.getUserMedia({ const stream = await navigator.mediaDevices.getUserMedia({
video: { video: {
@@ -141,7 +165,7 @@ export default function LiveCameraCapture({
} catch (err) { } catch (err) {
console.error('Camera access failed:', err) console.error('Camera access failed:', err)
if (!cancelled) { if (!cancelled) {
setCameraError(t('logs.live_photo_camera_denied')) setCameraError(t(cameraErrorKeyFromDomException(err)))
} }
} }
} }
@@ -216,7 +240,7 @@ export default function LiveCameraCapture({
className="btn secondary live-camera-close" className="btn secondary live-camera-close"
onClick={onClose} onClick={onClose}
disabled={busy} disabled={busy}
aria-label={t('logs.confirm_no')} aria-label={t('logs.live_cancel')}
> >
<X size={18} /> <X size={18} />
</button> </button>
@@ -243,6 +267,12 @@ export default function LiveCameraCapture({
className="live-camera-preview live-camera-preview-still" className="live-camera-preview live-camera-preview-still"
/> />
</div> </div>
) : cameraError ? (
<div className="live-camera-preview-wrap">
<p className="live-camera-loading">{cameraError}</p>
</div>
) : phase === 'checking' ? (
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
) : phase === 'native' ? ( ) : phase === 'native' ? (
<div className="live-camera-native-prompt"> <div className="live-camera-native-prompt">
<p className="live-log-modal-hint">{t('logs.live_photo_native_hint')}</p> <p className="live-log-modal-hint">{t('logs.live_photo_native_hint')}</p>
@@ -256,7 +286,7 @@ export default function LiveCameraCapture({
{t('logs.live_photo_open_camera_btn')} {t('logs.live_photo_open_camera_btn')}
</button> </button>
</div> </div>
) : cameraError && !ready ? null : ( ) : phase === 'live' ? (
<div className="live-camera-preview-wrap"> <div className="live-camera-preview-wrap">
<video <video
ref={videoRef} ref={videoRef}
@@ -269,7 +299,7 @@ export default function LiveCameraCapture({
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p> <p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
)} )}
</div> </div>
)} ) : null}
{onCaptionChange && ( {onCaptionChange && (
<div className="input-group live-camera-caption"> <div className="input-group live-camera-caption">
@@ -287,7 +317,7 @@ export default function LiveCameraCapture({
<div className="live-log-modal-actions live-camera-actions"> <div className="live-log-modal-actions live-camera-actions">
<button type="button" className="btn secondary" onClick={onClose} disabled={busy}> <button type="button" className="btn secondary" onClick={onClose} disabled={busy}>
{t('logs.confirm_no')} {t('logs.live_cancel')}
</button> </button>
{showPreview ? ( {showPreview ? (
+460 -112
View File
@@ -15,31 +15,34 @@ import {
MapPin, MapPin,
MessageSquare, MessageSquare,
Camera, Camera,
Mic,
Radio, Radio,
Sailboat, Sailboat,
Undo2, Undo2,
Zap Zap
} from 'lucide-react' } from 'lucide-react'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getAiAuthorized } from '../services/userPreferences.js'
import { import {
appendQuickEvent, appendQuickEvent as apiAppendQuickEvent,
appendQuickEvents, appendQuickEvents as apiAppendQuickEvents,
appendTankRefill, appendTankRefill as apiAppendTankRefill,
findOrCreateTodayEntry, findOrCreateTodayEntry,
loadEntry, loadEntry,
removeLastEvent removeLastEvent
} from '../services/quickEventLog.js' } from '../services/quickEventLog.js'
import { formatEventSummary } from '../utils/formatEventSummary.js' import CreatorAvatar from './CreatorAvatar.tsx'
import { import {
getLastAutoPositionMs, getLastAutoPositionMs,
getLastPositionFixWithin, getLastLoggedPositionWithin,
getLatestPositionFix, getLatestLoggedPosition,
isMotorRunningFromEvents, isMotorRunningFromEvents,
LIVE_EVENT_CODES, LIVE_EVENT_CODES,
LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS,
liveCommentRemark, liveCommentRemark,
liveFuelRemark, liveFuelRemark,
livePhotoRemark, livePhotoRemark,
liveVoiceRemark,
livePrecipRemark, livePrecipRemark,
liveSailsRemark, liveSailsRemark,
liveSogRemark, liveSogRemark,
@@ -47,12 +50,22 @@ import {
liveTempRemark, liveTempRemark,
liveWaterRemark liveWaterRemark
} from '../utils/liveEventCodes.js' } from '../utils/liveEventCodes.js'
import { formatAppDecimal, formatTankLiters, parseAppDecimal } from '../utils/numberFormat.js'
const formatSpeedKn = (speedKn: number) =>
formatAppDecimal(speedKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js' import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js' import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
import { import {
geolocationErrorI18nKey,
getCurrentPosition, getCurrentPosition,
getGeolocationErrorReason,
hasSeenGeolocationLiveIntro,
markGeolocationLiveIntroSeen,
normalizeGpsCoordinates, normalizeGpsCoordinates,
queryGeolocationPermission queryGeolocationPermission,
type GeolocationErrorReason,
type GpsSignalQuality
} from '../utils/geolocation.js' } from '../utils/geolocation.js'
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js' import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
import { import {
@@ -63,9 +76,15 @@ import {
} from '../utils/sailSelection.js' } from '../utils/sailSelection.js'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import CourseDialInput from './CourseDialInput.tsx' import CourseDialInput from './CourseDialInput.tsx'
import GpsSignalHint from './GpsSignalHint.tsx'
import LiveCameraCapture from './LiveCameraCapture.tsx' import LiveCameraCapture from './LiveCameraCapture.tsx'
import LiveVoiceCapture from './LiveVoiceCapture.tsx'
import EventRemarksCell from './EventRemarksCell.tsx'
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js' import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
import { saveEntryVoiceMemo, deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js' import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
import { blobToAudioDataUrl } from '../utils/audioBlob.js'
import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js'
interface LiveLogViewProps { interface LiveLogViewProps {
logbookId: string logbookId: string
@@ -88,8 +107,9 @@ type LiveModal =
| 'water' | 'water'
| 'sog' | 'sog'
| 'stw' | 'stw'
| 'fix' | 'position'
| 'photo' | 'photo'
| 'voice'
const AUTO_POSITION_INTERVAL_MS = 3 * 60 * 60 * 1000 const AUTO_POSITION_INTERVAL_MS = 3 * 60 * 60 * 1000
const AUTO_POSITION_CHECK_MS = 60_000 const AUTO_POSITION_CHECK_MS = 60_000
@@ -133,18 +153,46 @@ function lastWindDirectionFromEvents(events: LogEventPayload[]): string {
return '' return ''
} }
function gpsFailureAlertBody(
t: (key: string) => string,
reason: GeolocationErrorReason
): string {
return `${t(geolocationErrorI18nKey(reason))}\n\n${t('logs.live_position_manual_hint')}`
}
function findActiveCreatorId(
activeUsername: string | null,
crewSnapshotsById: Record<string, any>,
selectedSkipperId: string | null
): string {
const username = (activeUsername || '').trim()
if (username) {
const matchEntry = Object.entries(crewSnapshotsById).find(
([_, snap]) => (snap?.name || '').trim().toLowerCase() === username.toLowerCase()
)
if (matchEntry) {
return matchEntry[0]
}
return username
}
return selectedSkipperId || 'skipper'
}
export default function LiveLogView({ export default function LiveLogView({
logbookId, logbookId,
onOpenEditor, onOpenEditor,
onSwitchToList onSwitchToList
}: LiveLogViewProps) { }: LiveLogViewProps) {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const { showAlert } = useDialog() const { showAlert, showConfirm } = useDialog()
const [geolocationAccessEpoch, setGeolocationAccessEpoch] = useState(0)
const [entryId, setEntryId] = useState<string | null>(null) const [entryId, setEntryId] = useState<string | null>(null)
const [dayOfTravel, setDayOfTravel] = useState('') const [dayOfTravel, setDayOfTravel] = useState('')
const [date, setDate] = useState('') const [date, setDate] = useState('')
const [events, setEvents] = useState<LogEventPayload[]>([]) const [events, setEvents] = useState<LogEventPayload[]>([])
const [crewSnapshotsById, setCrewSnapshotsById] = useState<Record<string, any>>({})
const [selectedSkipperId, setSelectedSkipperId] = useState<string | null>(null)
const [yachtSails, setYachtSails] = useState<string[]>([]) const [yachtSails, setYachtSails] = useState<string[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
@@ -152,21 +200,30 @@ export default function LiveLogView({
const [modal, setModal] = useState<LiveModal>('none') const [modal, setModal] = useState<LiveModal>('none')
const [weatherExpanded, setWeatherExpanded] = useState(false) const [weatherExpanded, setWeatherExpanded] = useState(false)
const [weatherOwmLoading, setWeatherOwmLoading] = useState(false) const [weatherOwmLoading, setWeatherOwmLoading] = useState(false)
const [isOnline, setIsOnline] = useState(navigator.onLine)
const [commentText, setCommentText] = useState('') const [commentText, setCommentText] = useState('')
const [valueInput, setValueInput] = useState('') const [valueInput, setValueInput] = useState('')
const [valueInputSecondary, setValueInputSecondary] = useState('') const [valueInputSecondary, setValueInputSecondary] = useState('')
const [selectedSails, setSelectedSails] = useState<string[]>([]) const [selectedSails, setSelectedSails] = useState<string[]>([])
const [undoVisible, setUndoVisible] = useState(false) const [undoVisible, setUndoVisible] = useState(false)
const [fixLat, setFixLat] = useState('') const [positionLat, setPositionLat] = useState('')
const [fixLng, setFixLng] = useState('') const [positionLng, setPositionLng] = useState('')
const [fixGpsLoading, setFixGpsLoading] = useState(false) const [positionGpsLoading, setPositionGpsLoading] = useState(false)
const [fixGpsUnavailable, setFixGpsUnavailable] = useState(false) const [positionGpsUnavailable, setPositionGpsUnavailable] = useState(false)
const [positionGpsErrorReason, setPositionGpsErrorReason] = useState<GeolocationErrorReason | null>(null)
const [positionGpsSignal, setPositionGpsSignal] = useState<{
quality: GpsSignalQuality
accuracyM: number | null
} | null>(null)
const [photoCaption, setPhotoCaption] = useState('') const [photoCaption, setPhotoCaption] = useState('')
const [photoSaving, setPhotoSaving] = useState(false) const [photoSaving, setPhotoSaving] = useState(false)
const [undoHint, setUndoHint] = useState<'event' | 'photo'>('event') const [voiceCaption, setVoiceCaption] = useState('')
const [voiceSaving, setVoiceSaving] = useState(false)
const [undoHint, setUndoHint] = useState<'event' | 'photo' | 'voice'>('event')
const streamEndRef = useRef<HTMLDivElement | null>(null) const streamEndRef = useRef<HTMLDivElement | null>(null)
const undoPhotoIdRef = useRef<string | null>(null) const undoPhotoIdRef = useRef<string | null>(null)
const undoVoiceIdRef = useRef<string | null>(null)
const undoTimerRef = useRef<number | null>(null) const undoTimerRef = useRef<number | null>(null)
const autoPositionBusyRef = useRef(false) const autoPositionBusyRef = useRef(false)
const busyRef = useRef(busy) const busyRef = useRef(busy)
@@ -177,6 +234,51 @@ export default function LiveLogView({
dateRef.current = date dateRef.current = date
busyRef.current = busy busyRef.current = busy
const getActiveCreatorId = useCallback(() => {
const activeUsername = localStorage.getItem('active_username')
return findActiveCreatorId(activeUsername, crewSnapshotsById, selectedSkipperId)
}, [crewSnapshotsById, selectedSkipperId])
const appendQuickEvent = useCallback((
logbookId: string,
entryId: string,
partialEvent: Partial<LogEventPayload>,
headerPatch?: { departure?: string; destination?: string }
) => {
return apiAppendQuickEvent(
logbookId,
entryId,
{ creatorId: getActiveCreatorId(), ...partialEvent },
headerPatch
)
}, [getActiveCreatorId])
const appendQuickEvents = useCallback((
logbookId: string,
entryId: string,
partialEvents: Partial<LogEventPayload>[]
) => {
const creatorId = getActiveCreatorId()
const mapped = partialEvents.map((p) => ({ creatorId, ...p }))
return apiAppendQuickEvents(logbookId, entryId, mapped)
}, [getActiveCreatorId])
const appendTankRefill = useCallback((
logbookId: string,
entryId: string,
tank: 'fuel' | 'freshwater',
addLiters: number,
event: Partial<LogEventPayload>
) => {
return apiAppendTankRefill(
logbookId,
entryId,
tank,
addLiters,
{ creatorId: getActiveCreatorId(), ...event }
)
}, [getActiveCreatorId])
const defaultSails = useMemo( const defaultSails = useMemo(
() => (i18n.language === 'de' () => (i18n.language === 'de'
? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker'] ? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker']
@@ -189,16 +291,19 @@ export default function LiveLogView({
) )
const motorRunning = isMotorRunningFromEvents(events) const motorRunning = isMotorRunningFromEvents(events)
const motorLabel = t('logs.motor_propulsion') const motorLabel = t('logs.motor_propulsion')
const hasPositionFix = useMemo( const hasLoggedPosition = useMemo(
() => (date ? getLatestPositionFix(events, date) != null : false), () => (date ? getLatestLoggedPosition(events, date) != null : false),
[events, date] [events, date]
) )
const voiceMemoLookup = useEntryVoiceMemos(logbookId, entryId)
const applyLoadedEntry = useCallback((loaded: NonNullable<Awaited<ReturnType<typeof loadEntry>>>) => { const applyLoadedEntry = useCallback((loaded: NonNullable<Awaited<ReturnType<typeof loadEntry>>>) => {
const entryEvents = (loaded.data.events as LogEventPayload[]) || [] const entryEvents = (loaded.data.events as LogEventPayload[]) || []
setDayOfTravel(String(loaded.data.dayOfTravel || '')) setDayOfTravel(String(loaded.data.dayOfTravel || ''))
setDate(String(loaded.data.date || '')) setDate(String(loaded.data.date || ''))
setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e })))) setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e }))))
setCrewSnapshotsById((loaded.data.crewSnapshotsById as Record<string, any>) || {})
setSelectedSkipperId(typeof loaded.data.selectedSkipperId === 'string' ? loaded.data.selectedSkipperId : null)
}, []) }, [])
const refreshEntry = useCallback(async (id: string) => { const refreshEntry = useCallback(async (id: string) => {
@@ -207,7 +312,7 @@ export default function LiveLogView({
applyLoadedEntry(loaded) applyLoadedEntry(loaded)
}, [logbookId, applyLoadedEntry]) }, [logbookId, applyLoadedEntry])
const showUndo = useCallback((hint: 'event' | 'photo' = 'event') => { const showUndo = useCallback((hint: 'event' | 'photo' | 'voice' = 'event') => {
setUndoHint(hint) setUndoHint(hint)
setUndoVisible(true) setUndoVisible(true)
if (undoTimerRef.current) window.clearTimeout(undoTimerRef.current) if (undoTimerRef.current) window.clearTimeout(undoTimerRef.current)
@@ -215,6 +320,7 @@ export default function LiveLogView({
setUndoVisible(false) setUndoVisible(false)
undoTimerRef.current = null undoTimerRef.current = null
undoPhotoIdRef.current = null undoPhotoIdRef.current = null
undoVoiceIdRef.current = null
}, UNDO_TIMEOUT_MS) }, UNDO_TIMEOUT_MS)
}, []) }, [])
@@ -269,6 +375,17 @@ export default function LiveLogView({
} }
}, [logbookId, applyLoadedEntry, t]) }, [logbookId, applyLoadedEntry, t])
useEffect(() => {
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
useEffect(() => { useEffect(() => {
void runInit() void runInit()
return () => { return () => {
@@ -284,6 +401,56 @@ export default function LiveLogView({
} }
}, [loading, entryId]) }, [loading, entryId])
useEffect(() => {
if (loading || !entryId || !navigator.geolocation) return
let cancelled = false
void (async () => {
const permission = await queryGeolocationPermission()
if (cancelled) return
if (permission === 'granted') {
markGeolocationLiveIntroSeen()
setGeolocationAccessEpoch((n) => n + 1)
return
}
// Only ask when the browser has not granted location yet (state "prompt").
if (permission !== 'prompt' || hasSeenGeolocationLiveIntro()) return
const allow = await showConfirm(
t('logs.gps_live_intro_body'),
t('logs.gps_live_intro_title'),
t('logs.gps_live_intro_allow'),
t('logs.gps_live_intro_later')
)
markGeolocationLiveIntroSeen()
if (cancelled || !allow) return
try {
await getCurrentPosition({
timeoutMs: 15_000,
enableHighAccuracy: false,
maximumAge: 0
})
if (!cancelled) setGeolocationAccessEpoch((n) => n + 1)
} catch (err) {
const reason = getGeolocationErrorReason(err)
if (reason === 'permission_denied') {
await showAlert(
`${t('logs.gps_permission_denied')}\n\n${t('logs.gps_enable_in_settings_hint')}`,
t('logs.live_title')
)
}
}
})()
return () => {
cancelled = true
}
}, [loading, entryId, showAlert, showConfirm, t])
useEffect(() => { useEffect(() => {
streamEndRef.current?.scrollIntoView({ behavior: 'smooth' }) streamEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [events.length]) }, [events.length])
@@ -332,7 +499,7 @@ export default function LiveLogView({
}) })
await refreshEntry(entryId) await refreshEntry(entryId)
} catch { } catch {
// Best-effort; hint banner shows when no position fix exists yet. // Best-effort; hint banner shows when no position has been logged yet.
} finally { } finally {
autoPositionBusyRef.current = false autoPositionBusyRef.current = false
} }
@@ -351,7 +518,7 @@ export default function LiveLogView({
if (startTimer !== undefined) window.clearTimeout(startTimer) if (startTimer !== undefined) window.clearTimeout(startTimer)
if (intervalRef !== undefined) window.clearInterval(intervalRef) if (intervalRef !== undefined) window.clearInterval(intervalRef)
} }
}, [entryId, loading, logbookId, refreshEntry]) }, [entryId, loading, logbookId, refreshEntry, geolocationAccessEpoch])
const runQuickAction = async ( const runQuickAction = async (
action: () => Promise<boolean | void>, action: () => Promise<boolean | void>,
@@ -427,16 +594,26 @@ export default function LiveLogView({
}, 'moor') }, 'moor')
} }
const openFixModal = async () => { const reportPositionGpsFailure = async (reason: GeolocationErrorReason) => {
setFixLat('') setPositionGpsUnavailable(true)
setFixLng('') setPositionGpsErrorReason(reason)
setFixGpsUnavailable(false) setPositionGpsSignal(null)
setFixGpsLoading(true) await showAlert(gpsFailureAlertBody(t, reason), t('logs.live_position'))
setModal('fix') }
const openPositionModal = async () => {
setPositionLat('')
setPositionLng('')
setPositionGpsUnavailable(false)
setPositionGpsErrorReason(null)
setPositionGpsSignal(null)
setPositionGpsLoading(true)
setModal('position')
try { try {
const permission = await queryGeolocationPermission() const permission = await queryGeolocationPermission()
if (permission !== 'granted') { if (permission !== 'granted') {
setFixGpsUnavailable(true) const reason = permission === 'denied' ? 'permission_denied' : 'unavailable'
await reportPositionGpsFailure(reason)
return return
} }
const coords = await getCurrentPosition({ const coords = await getCurrentPosition({
@@ -444,26 +621,26 @@ export default function LiveLogView({
enableHighAccuracy: false, enableHighAccuracy: false,
maximumAge: 60_000 maximumAge: 60_000
}) })
setFixLat(coords.lat) setPositionLat(coords.lat)
setFixLng(coords.lng) setPositionLng(coords.lng)
} catch { setPositionGpsSignal({ quality: coords.signalQuality, accuracyM: coords.accuracyM })
setFixGpsUnavailable(true) } catch (err) {
await reportPositionGpsFailure(getGeolocationErrorReason(err))
} finally { } finally {
setFixGpsLoading(false) setPositionGpsLoading(false)
} }
} }
const retryFixGps = async () => { const retryPositionGps = async () => {
setFixGpsLoading(true) setPositionGpsLoading(true)
setFixGpsUnavailable(false) setPositionGpsUnavailable(false)
setPositionGpsErrorReason(null)
setPositionGpsSignal(null)
try { try {
const permission = await queryGeolocationPermission() const permission = await queryGeolocationPermission()
if (permission !== 'granted') { if (permission !== 'granted') {
setFixGpsUnavailable(true) const reason = permission === 'denied' ? 'permission_denied' : 'unavailable'
await showAlert( await reportPositionGpsFailure(reason)
`${t('logs.live_gps_error')}\n\n${t('logs.live_gps_start_hint')}`,
t('logs.live_fix')
)
return return
} }
const coords = await getCurrentPosition({ const coords = await getCurrentPosition({
@@ -471,23 +648,21 @@ export default function LiveLogView({
enableHighAccuracy: false, enableHighAccuracy: false,
maximumAge: 60_000 maximumAge: 60_000
}) })
setFixLat(coords.lat) setPositionLat(coords.lat)
setFixLng(coords.lng) setPositionLng(coords.lng)
} catch { setPositionGpsUnavailable(false)
setFixGpsUnavailable(true) setPositionGpsSignal({ quality: coords.signalQuality, accuracyM: coords.accuracyM })
await showAlert( } catch (err) {
`${t('logs.live_gps_error')}\n\n${t('logs.live_gps_start_hint')}`, await reportPositionGpsFailure(getGeolocationErrorReason(err))
t('logs.live_fix')
)
} finally { } finally {
setFixGpsLoading(false) setPositionGpsLoading(false)
} }
} }
const confirmFix = () => { const confirmPosition = () => {
const coords = normalizeGpsCoordinates(fixLat, fixLng) const coords = normalizeGpsCoordinates(positionLat, positionLng)
if (!coords) { if (!coords) {
void showAlert(t('logs.live_fix_invalid'), t('logs.live_fix')) void showAlert(t('logs.live_position_invalid'), t('logs.live_position'))
return return
} }
setModal('none') setModal('none')
@@ -496,25 +671,29 @@ export default function LiveLogView({
await appendQuickEvent(logbookId, entryId, { await appendQuickEvent(logbookId, entryId, {
gpsLat: coords.lat, gpsLat: coords.lat,
gpsLng: coords.lng, gpsLng: coords.lng,
remarks: LIVE_EVENT_CODES.FIX remarks: LIVE_EVENT_CODES.POSITION
}) })
}, 'fix') }, 'position')
} }
const handleFetchOwmWeather = () => { const handleFetchOwmWeather = () => {
if (!entryId || busy || weatherOwmLoading) return if (!entryId || busy || weatherOwmLoading) return
if (!isOnline) {
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
return
}
const position = getLastPositionFixWithin( const position = getLastLoggedPositionWithin(
events, events,
date, date,
LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS
) )
if (!position) { if (!position) {
const latest = getLatestPositionFix(events, date) const latest = getLatestLoggedPosition(events, date)
void showAlert( void showAlert(
latest latest
? t('logs.live_weather_fix_stale') ? t('logs.live_weather_position_stale')
: t('logs.live_weather_fix_required'), : t('logs.live_weather_position_required'),
t('logs.live_weather_owm_btn') t('logs.live_weather_owm_btn')
) )
return return
@@ -533,9 +712,27 @@ export default function LiveLogView({
{ analyticsSource: 'live_log' } { analyticsSource: 'live_log' }
) )
} catch (err) { } catch (err) {
if (err instanceof WeatherApiError && err.code === 'NO_KEY') { if (err instanceof WeatherApiError) {
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn')) if (err.code === 'OFFLINE') {
return void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
return
}
if (err.code === 'NO_KEY') {
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
return
}
if (err.code === 'UNAUTHORIZED') {
void showAlert(t('settings.weather_unauthorized'), t('logs.live_weather_owm_btn'))
return
}
if (err.code === 'NOT_FOUND') {
void showAlert(t('settings.weather_not_found'), t('logs.live_weather_owm_btn'))
return
}
if (err.code === 'BAD_REQUEST') {
void showAlert(t('settings.weather_bad_request'), t('logs.live_weather_owm_btn'))
return
}
} }
console.error('Live log OWM weather failed:', err) console.error('Live log OWM weather failed:', err)
void showAlert(t('settings.weather_error'), t('logs.live_weather_owm_btn')) void showAlert(t('settings.weather_error'), t('logs.live_weather_owm_btn'))
@@ -590,8 +787,10 @@ export default function LiveLogView({
const handleUndo = () => { const handleUndo = () => {
if (!entryId || busy) return if (!entryId || busy) return
const photoId = undoPhotoIdRef.current const photoId = undoPhotoIdRef.current
const voiceId = undoVoiceIdRef.current
setUndoVisible(false) setUndoVisible(false)
undoPhotoIdRef.current = null undoPhotoIdRef.current = null
undoVoiceIdRef.current = null
if (undoTimerRef.current) { if (undoTimerRef.current) {
window.clearTimeout(undoTimerRef.current) window.clearTimeout(undoTimerRef.current)
undoTimerRef.current = null undoTimerRef.current = null
@@ -600,6 +799,9 @@ export default function LiveLogView({
if (photoId) { if (photoId) {
await deleteEntryPhoto(logbookId, photoId) await deleteEntryPhoto(logbookId, photoId)
} }
if (voiceId) {
await deleteEntryVoiceMemo(logbookId, voiceId)
}
await removeLastEvent(logbookId, entryId) await removeLastEvent(logbookId, entryId)
}, 'undo', false) }, 'undo', false)
} }
@@ -615,6 +817,110 @@ export default function LiveLogView({
setPhotoCaption('') setPhotoCaption('')
} }
const openVoiceModal = () => {
setVoiceCaption('')
setModal('voice')
}
const closeVoiceModal = () => {
if (voiceSaving) return
setModal('none')
setVoiceCaption('')
}
const handleVoiceSave = (blob: Blob, mimeType: string, durationSec: number) => {
if (!entryId || voiceSaving) return
const caption = voiceCaption.trim()
setVoiceSaving(true)
void (async () => {
try {
const audioDataUrl = await blobToAudioDataUrl(blob)
const authorized = getAiAuthorized()
let transcriptionText = ''
let transcribed = true
let transcriptionError = false
if (authorized) {
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 4000)
const res = await fetch('/api/ai/transcribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ audioDataUrl }),
signal: controller.signal
})
clearTimeout(timeoutId)
if (!res.ok) throw new Error(`Status ${res.status}`)
const data = await res.json()
transcriptionText = (data.text || '').trim()
} catch (err) {
console.warn('[LiveLogView] Automatic transcription failed or timed out:', err)
transcriptionError = true
transcribed = false
}
} else {
transcribed = false
}
let finalCaption = caption
if (transcriptionText) {
finalCaption = caption
? `${caption}\n(Transkript: ${transcriptionText})`
: transcriptionText
}
const voiceId = await saveEntryVoiceMemo({
logbookId,
entryId,
audioDataUrl,
mimeType,
durationSec,
caption: finalCaption,
transcribed,
analyticsContext: 'live_log'
})
await appendQuickEvent(logbookId, entryId, {
remarks: liveVoiceRemark(voiceId)
})
await refreshEntry(entryId)
undoVoiceIdRef.current = voiceId
setModal('none')
setVoiceCaption('')
showUndo('voice')
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'voice' })
if (transcriptionError) {
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
status: 'failed',
mode: 'auto'
})
void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn'))
} else if (authorized) {
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
status: 'success',
mode: 'auto'
})
} else {
void showAlert(
t('profile.ai_unauthorized_alert_desc'),
t('profile.ai_unauthorized_alert_title')
)
}
} catch (err: unknown) {
console.error('Live log voice save failed:', err)
const msg = err instanceof Error && err.message === 'VOICE_MEMO_TOO_LARGE'
? t('logs.live_voice_too_large')
: err instanceof Error
? err.message
: t('logs.live_voice_error')
void showAlert(msg, t('logs.live_voice_btn'))
} finally {
setVoiceSaving(false)
}
})()
}
const handlePhotoCapture = (blob: Blob) => { const handlePhotoCapture = (blob: Blob) => {
if (!entryId || photoSaving) return if (!entryId || photoSaving) return
const caption = photoCaption.trim() const caption = photoCaption.trim()
@@ -754,45 +1060,45 @@ export default function LiveLogView({
break break
} }
case 'fuel': { case 'fuel': {
const liters = parseFloat(primary) const liters = parseAppDecimal(primary)
if (!Number.isFinite(liters) || liters <= 0) return if (liters == null || liters <= 0) return
setModal('none') setModal('none')
void runQuickAction(async () => { void runQuickAction(async () => {
await appendTankRefill(logbookId, entryId, 'fuel', liters, { await appendTankRefill(logbookId, entryId, 'fuel', liters, {
remarks: liveFuelRemark(String(liters)) remarks: liveFuelRemark(formatTankLiters(liters))
}) })
}, 'fuel') }, 'fuel')
break break
} }
case 'water': { case 'water': {
const liters = parseFloat(primary) const liters = parseAppDecimal(primary)
if (!Number.isFinite(liters) || liters <= 0) return if (liters == null || liters <= 0) return
setModal('none') setModal('none')
void runQuickAction(async () => { void runQuickAction(async () => {
await appendTankRefill(logbookId, entryId, 'freshwater', liters, { await appendTankRefill(logbookId, entryId, 'freshwater', liters, {
remarks: liveWaterRemark(String(liters)) remarks: liveWaterRemark(formatTankLiters(liters))
}) })
}, 'water') }, 'water')
break break
} }
case 'sog': { case 'sog': {
const speedKn = parseFloat(primary.replace(',', '.')) const speedKn = parseAppDecimal(primary)
if (!Number.isFinite(speedKn) || speedKn < 0) return if (speedKn == null || speedKn < 0) return
setModal('none') setModal('none')
void runQuickAction(async () => { void runQuickAction(async () => {
await appendQuickEvent(logbookId, entryId, { await appendQuickEvent(logbookId, entryId, {
remarks: liveSogRemark(String(speedKn)) remarks: liveSogRemark(formatSpeedKn(speedKn))
}) })
}, 'sog') }, 'sog')
break break
} }
case 'stw': { case 'stw': {
const speedKn = parseFloat(primary.replace(',', '.')) const speedKn = parseAppDecimal(primary)
if (!Number.isFinite(speedKn) || speedKn < 0) return if (speedKn == null || speedKn < 0) return
setModal('none') setModal('none')
void runQuickAction(async () => { void runQuickAction(async () => {
await appendQuickEvent(logbookId, entryId, { await appendQuickEvent(logbookId, entryId, {
remarks: liveStwRemark(String(speedKn)) remarks: liveStwRemark(formatSpeedKn(speedKn))
}) })
}, 'stw') }, 'stw')
break break
@@ -856,7 +1162,7 @@ export default function LiveLogView({
{error && <div className="auth-error mb-4">{error}</div>} {error && <div className="auth-error mb-4">{error}</div>}
{!hasPositionFix && ( {!hasLoggedPosition && (
<p className="live-log-gps-hint" role="status"> <p className="live-log-gps-hint" role="status">
<MapPin size={16} aria-hidden /> <MapPin size={16} aria-hidden />
{t('logs.live_gps_start_hint')} {t('logs.live_gps_start_hint')}
@@ -947,9 +1253,9 @@ export default function LiveLogView({
)} )}
</div> </div>
<button type="button" className="live-log-action-btn" onClick={() => void openFixModal()} disabled={busy}> <button type="button" className="live-log-action-btn" onClick={() => void openPositionModal()} disabled={busy}>
<MapPin size={18} /> <MapPin size={18} />
{t('logs.live_fix')} {t('logs.live_position')}
</button> </button>
<button type="button" className="live-log-action-btn" onClick={() => { setCommentText(''); setModal('comment') }} disabled={busy}> <button type="button" className="live-log-action-btn" onClick={() => { setCommentText(''); setModal('comment') }} disabled={busy}>
<MessageSquare size={18} /> <MessageSquare size={18} />
@@ -959,6 +1265,10 @@ export default function LiveLogView({
<Camera size={18} /> <Camera size={18} />
{t('logs.live_photo_btn')} {t('logs.live_photo_btn')}
</button> </button>
<button type="button" className="live-log-action-btn" onClick={openVoiceModal} disabled={busy || voiceSaving}>
<Mic size={18} />
{t('logs.live_voice_btn')}
</button>
</aside> </aside>
<section className="live-log-stream-panel" aria-label={t('logs.live_stream_label')}> <section className="live-log-stream-panel" aria-label={t('logs.live_stream_label')}>
@@ -967,12 +1277,26 @@ export default function LiveLogView({
<p className="live-log-empty">{t('logs.live_no_events')}</p> <p className="live-log-empty">{t('logs.live_no_events')}</p>
) : ( ) : (
<ol className="live-log-stream"> <ol className="live-log-stream">
{events.map((event, index) => ( {events.map((event, index) => {
<li key={`${event.time}-${index}`} className="live-log-entry"> return (
<time className="live-log-time">{event.time}</time> <li key={`${event.time}-${index}`} className="live-log-entry">
<span className="live-log-summary">{formatEventSummary(event, t)}</span> <time className="live-log-time">{event.time}</time>
</li> <CreatorAvatar
))} creatorId={event.creatorId}
crewSnapshotsById={crewSnapshotsById}
size={24}
/>
<div className="live-log-summary-block">
<EventRemarksCell
event={event}
logbookId={logbookId}
voiceMemoLookup={voiceMemoLookup}
readOnly={false}
/>
</div>
</li>
)
})}
<div ref={streamEndRef} /> <div ref={streamEndRef} />
</ol> </ol>
)} )}
@@ -986,7 +1310,11 @@ export default function LiveLogView({
<div className="live-log-undo-bar" role="status"> <div className="live-log-undo-bar" role="status">
<div className="live-log-undo-bar-inner"> <div className="live-log-undo-bar-inner">
<span> <span>
{undoHint === 'photo' ? t('logs.live_undo_photo_hint') : t('logs.live_undo_hint')} {undoHint === 'photo'
? t('logs.live_undo_photo_hint')
: undoHint === 'voice'
? t('logs.live_undo_voice_hint')
: t('logs.live_undo_hint')}
</span> </span>
<button type="button" className="btn secondary" onClick={handleUndo} disabled={busy}> <button type="button" className="btn secondary" onClick={handleUndo} disabled={busy}>
<Undo2 size={16} /> <Undo2 size={16} />
@@ -1030,7 +1358,7 @@ export default function LiveLogView({
</p> </p>
)} )}
<div className="live-log-modal-actions"> <div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={closeModal}>{t('logs.confirm_no')}</button> <button type="button" className="btn secondary" onClick={closeModal}>{t('logs.live_cancel')}</button>
<button <button
type="button" type="button"
className="btn primary" className="btn primary"
@@ -1046,68 +1374,79 @@ export default function LiveLogView({
</div> </div>
)} )}
{modal === 'fix' && ( {modal === 'position' && (
<div <div
className="live-log-modal-backdrop" className="live-log-modal-backdrop"
onClick={(e) => { if (e.target === e.currentTarget) closeModal() }} onClick={(e) => { if (e.target === e.currentTarget) closeModal() }}
> >
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}> <div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
<h3>{t('logs.live_fix')}</h3> <h3>{t('logs.live_position')}</h3>
{fixGpsUnavailable && ( {positionGpsUnavailable && (
<> <>
<p className="live-log-modal-hint live-log-gps-hint-modal">{t('logs.live_gps_start_hint')}</p> {positionGpsErrorReason && (
<p className="live-log-modal-hint">{t('logs.live_fix_manual_hint')}</p> <p className="live-log-modal-hint live-log-gps-error-modal" role="alert">
{t(geolocationErrorI18nKey(positionGpsErrorReason))}
</p>
)}
<p className="live-log-modal-hint">{t('logs.live_position_manual_hint')}</p>
</> </>
)} )}
<fieldset className="live-log-fix-coords" disabled={busy}> <fieldset className="live-log-position-coords" disabled={busy}>
<legend className="live-log-fix-label">{t('logs.event_gps')}</legend> <legend className="live-log-position-label">{t('logs.event_gps')}</legend>
<div className="live-log-fix-coords-row"> <div className="live-log-position-coords-row">
<label className="live-log-fix-field"> <label className="live-log-position-field">
<span className="live-log-fix-field-label">{t('logs.live_fix_lat_placeholder')}</span> <span className="live-log-position-field-label">{t('logs.live_position_lat_placeholder')}</span>
<input <input
type="text" type="text"
inputMode="decimal" inputMode="decimal"
className="input-text" className="input-text"
placeholder="54.123456" placeholder="54.123456"
value={fixLat} value={positionLat}
onChange={(e) => setFixLat(e.target.value)} onChange={(e) => { setPositionGpsSignal(null); setPositionLat(e.target.value) }}
autoFocus autoFocus
/> />
</label> </label>
<label className="live-log-fix-field"> <label className="live-log-position-field">
<span className="live-log-fix-field-label">{t('logs.live_fix_lng_placeholder')}</span> <span className="live-log-position-field-label">{t('logs.live_position_lng_placeholder')}</span>
<input <input
type="text" type="text"
inputMode="decimal" inputMode="decimal"
className="input-text" className="input-text"
placeholder="10.654321" placeholder="10.654321"
value={fixLng} value={positionLng}
onChange={(e) => setFixLng(e.target.value)} onChange={(e) => { setPositionGpsSignal(null); setPositionLng(e.target.value) }}
onKeyDown={(e) => { if (e.key === 'Enter') confirmFix() }} onKeyDown={(e) => { if (e.key === 'Enter') confirmPosition() }}
/> />
</label> </label>
</div> </div>
<div className="live-log-fix-gps-row"> {positionGpsSignal && (
<GpsSignalHint
quality={positionGpsSignal.quality}
accuracyM={positionGpsSignal.accuracyM}
className="gps-signal-hint-modal"
/>
)}
<div className="live-log-position-gps-row">
<button <button
type="button" type="button"
className="btn secondary live-log-fix-gps-btn" className="btn secondary live-log-position-gps-btn"
onClick={() => void retryFixGps()} onClick={() => void retryPositionGps()}
title={t('logs.gps_btn')} title={t('logs.gps_btn')}
disabled={fixGpsLoading} disabled={positionGpsLoading}
aria-label={t('logs.gps_btn')} aria-label={t('logs.gps_btn')}
> >
<MapPin size={16} /> <MapPin size={16} />
<span>{fixGpsLoading ? t('logs.live_fix_gps_loading') : t('logs.gps_btn')}</span> <span>{positionGpsLoading ? t('logs.live_position_gps_loading') : t('logs.gps_btn')}</span>
</button> </button>
</div> </div>
</fieldset> </fieldset>
<div className="live-log-modal-actions"> <div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={closeModal}>{t('logs.confirm_no')}</button> <button type="button" className="btn secondary" onClick={closeModal}>{t('logs.live_cancel')}</button>
<button <button
type="button" type="button"
className="btn primary" className="btn primary"
onClick={confirmFix} onClick={confirmPosition}
disabled={busy || !normalizeGpsCoordinates(fixLat, fixLng)} disabled={busy || !normalizeGpsCoordinates(positionLat, positionLng)}
> >
{t('logs.live_sails_confirm')} {t('logs.live_sails_confirm')}
</button> </button>
@@ -1122,7 +1461,7 @@ export default function LiveLogView({
<h3>{t('logs.live_comment_btn')}</h3> <h3>{t('logs.live_comment_btn')}</h3>
<input type="text" className="input-text" value={commentText} onChange={(e) => setCommentText(e.target.value)} placeholder={t('logs.live_comment_placeholder')} autoFocus onKeyDown={(e) => { if (e.key === 'Enter') confirmComment() }} /> <input type="text" className="input-text" value={commentText} onChange={(e) => setCommentText(e.target.value)} placeholder={t('logs.live_comment_placeholder')} autoFocus onKeyDown={(e) => { if (e.key === 'Enter') confirmComment() }} />
<div className="live-log-modal-actions"> <div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button> <button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.live_cancel')}</button>
<button type="button" className="btn primary" onClick={confirmComment} disabled={!commentText.trim()}>{t('logs.live_comment_confirm')}</button> <button type="button" className="btn primary" onClick={confirmComment} disabled={!commentText.trim()}>{t('logs.live_comment_confirm')}</button>
</div> </div>
</div> </div>
@@ -1156,7 +1495,7 @@ export default function LiveLogView({
/> />
</div> </div>
<div className="live-log-modal-actions"> <div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button> <button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.live_cancel')}</button>
<button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button> <button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button>
</div> </div>
</div> </div>
@@ -1178,7 +1517,7 @@ export default function LiveLogView({
/> />
</div> </div>
<div className="live-log-modal-actions"> <div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button> <button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.live_cancel')}</button>
<button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button> <button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button>
</div> </div>
</div> </div>
@@ -1223,7 +1562,7 @@ export default function LiveLogView({
onKeyDown={(e) => { if (e.key === 'Enter') confirmValueModal() }} onKeyDown={(e) => { if (e.key === 'Enter') confirmValueModal() }}
/> />
<div className="live-log-modal-actions"> <div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button> <button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.live_cancel')}</button>
<button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button> <button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button>
</div> </div>
</div> </div>
@@ -1238,6 +1577,15 @@ export default function LiveLogView({
onClose={closePhotoModal} onClose={closePhotoModal}
onCapture={handlePhotoCapture} onCapture={handlePhotoCapture}
/> />
<LiveVoiceCapture
open={modal === 'voice'}
busy={voiceSaving}
caption={voiceCaption}
onCaptionChange={setVoiceCaption}
onClose={closeVoiceModal}
onSave={handleVoiceSave}
/>
</>, </>,
document.body document.body
)} )}
+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>
)
}
+61 -33
View File
@@ -3,18 +3,25 @@ import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js' import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js' import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js' import { getLogbookKey } from '../services/logbookKeys.js'
import { decryptJson, encryptJson } from '../services/crypto.js' import { encryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js' import { syncLogbook } from '../services/sync.js'
import { downloadCsv, shareCsv } from '../services/csvExport.js' import { downloadCsv, shareCsv } from '../services/csvExport.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js' import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js' import { getErrorMessage } from '../utils/errors.js'
import { findTodayEntryId } from '../services/quickEventLog.js' import { findTodayEntryId, pruneEmptyTodayDuplicates, tryDecryptEntryPayload } from '../services/quickEventLog.js'
import { localDateString } from '../utils/logEntryPayload.js'
import LogEntryEditor from './LogEntryEditor.tsx' import LogEntryEditor from './LogEntryEditor.tsx'
import LiveLogView from './LiveLogView.tsx' import LiveLogView from './LiveLogView.tsx'
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx' import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js' import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
import {
buildEntryListCache,
entryListItemFromLocal,
putEntryRecord
} from '../utils/entryListCache.js'
import { forEachInBatches } from '../utils/yieldToMain.js'
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react' import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react'
import { import {
carryOverFromPreviousDay, carryOverFromPreviousDay,
@@ -33,6 +40,7 @@ interface LogEntriesListProps {
preloadedYacht?: any preloadedYacht?: any
preloadedEntries?: any[] preloadedEntries?: any[]
preloadedPhotos?: any[] preloadedPhotos?: any[]
preloadedVoiceMemos?: import('./VoiceMemoPlayer.tsx').PreloadedVoiceMemo[]
preloadedGpsTracks?: any[] preloadedGpsTracks?: any[]
controlledSelectedEntryId?: string | null controlledSelectedEntryId?: string | null
onSelectedEntryIdChange?: (id: string | null) => void onSelectedEntryIdChange?: (id: string | null) => void
@@ -57,6 +65,7 @@ export default function LogEntriesList({
preloadedYacht, preloadedYacht,
preloadedEntries, preloadedEntries,
preloadedPhotos, preloadedPhotos,
preloadedVoiceMemos,
preloadedGpsTracks, preloadedGpsTracks,
controlledSelectedEntryId, controlledSelectedEntryId,
onSelectedEntryIdChange, onSelectedEntryIdChange,
@@ -115,25 +124,40 @@ export default function LogEntriesList({
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.') if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const todayEntryId = await findTodayEntryId(logbookId)
if (todayEntryId) {
await pruneEmptyTodayDuplicates(logbookId, todayEntryId)
}
const local = await db.entries.where({ logbookId }).toArray() const local = await db.entries.where({ logbookId }).toArray()
const list: DecryptedEntryItem[] = [] const list: DecryptedEntryItem[] = []
const needsDecrypt: typeof local = []
for (const entry of local) { for (const entry of local) {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey) const cached = entryListItemFromLocal(entry)
if (decrypted) { if (cached) {
list.push({ list.push(cached)
id: entry.payloadId, } else {
date: decrypted.date || '', needsDecrypt.push(entry)
dayOfTravel: decrypted.dayOfTravel || '',
departure: decrypted.departure || '',
destination: decrypted.destination || '',
updatedAt: entry.updatedAt,
skipperSignStatus: await getSkipperSignStatus(decrypted as Record<string, unknown>)
})
} }
} }
await forEachInBatches(needsDecrypt, 8, async (entry) => {
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
if (!decrypted) return
const listCache = await buildEntryListCache(decrypted as Record<string, unknown>)
list.push({
id: entry.payloadId,
...listCache,
updatedAt: entry.updatedAt
})
void db.entries.update(entry.payloadId, { listCache }).catch((err) => {
console.warn('Failed to persist entry list cache:', err)
})
})
// Sort chronological descending (by date, or dayOfTravel numerical) // Sort chronological descending (by date, or dayOfTravel numerical)
list.sort((a, b) => { list.sort((a, b) => {
const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime() const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime()
@@ -250,7 +274,7 @@ export default function LogEntriesList({
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = [] const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
for (const entry of localEntries) { for (const entry of localEntries) {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey) const decrypted = await tryDecryptEntryPayload(entry, masterKey)
if (decrypted) decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable) if (decrypted) decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable)
} }
@@ -282,7 +306,7 @@ export default function LogEntriesList({
const localId = window.crypto.randomUUID() const localId = window.crypto.randomUUID()
const nowStr = new Date().toISOString() const nowStr = new Date().toISOString()
const todayStr = nowStr.substring(0, 10) const todayStr = localDateString()
const { loadDefaultEntryCrewForNewDay } = await import('./EntryCrewSection.js') const { loadDefaultEntryCrewForNewDay } = await import('./EntryCrewSection.js')
const entryCrew = await loadDefaultEntryCrewForNewDay( const entryCrew = await loadDefaultEntryCrewForNewDay(
@@ -309,14 +333,17 @@ export default function LogEntriesList({
const encrypted = await encryptJson(initialPayload, masterKey) const encrypted = await encryptJson(initialPayload, masterKey)
// Save locally // Save locally
await db.entries.put({ await putEntryRecord(
payloadId: localId, {
logbookId, payloadId: localId,
encryptedData: encrypted.ciphertext, logbookId,
iv: encrypted.iv, encryptedData: encrypted.ciphertext,
tag: encrypted.tag, iv: encrypted.iv,
updatedAt: nowStr tag: encrypted.tag,
}) updatedAt: nowStr
},
initialPayload
)
// Queue for background sync // Queue for background sync
await db.syncQueue.put({ await db.syncQueue.put({
@@ -384,6 +411,7 @@ export default function LogEntriesList({
readOnly={readOnly} readOnly={readOnly}
preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)} preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)}
preloadedPhotos={preloadedPhotos} preloadedPhotos={preloadedPhotos}
preloadedVoiceMemos={preloadedVoiceMemos}
preloadedTrack={preloadedGpsTracks?.find(track => track.entryId === selectedEntryId)} preloadedTrack={preloadedGpsTracks?.find(track => track.entryId === selectedEntryId)}
/> />
) )
@@ -513,17 +541,17 @@ export default function LogEntriesList({
</div> </div>
</div> </div>
<ChevronRight size={18} className="logbook-card-chevron" aria-hidden /> <div className="logbook-card-right-group">
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}> <Download size={18} />
<Download size={18} />
</button>
{!readOnly && (
<button className="btn-delete" onClick={(e) => handleDelete(item.id, e)} title={t('logs.delete_entry')}>
<Trash2 size={18} />
</button> </button>
)} {!readOnly && (
<button className="btn-delete" onClick={(e) => handleDelete(item.id, e)} title={t('logs.delete_entry')}>
<Trash2 size={18} />
</button>
)}
<ChevronRight size={18} className="logbook-card-chevron" aria-hidden />
</div>
</div> </div>
))} ))}
</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 { import {
downloadBackupBlob, downloadBackupBlob,
exportLogbookBackup, exportLogbookBackup,
formatBackupBytes,
parseLogbookBackupFile, parseLogbookBackupFile,
previewLogbookBackup, previewLogbookBackup,
restoreLogbookBackup, restoreLogbookBackup,
type LogbookBackupFile, BACKUP_SIZE_CONFIRM_BYTES,
type ParsedLogbookBackup,
type LogbookBackupPreview type LogbookBackupPreview
} from '../services/logbookBackup.js' } from '../services/logbookBackup.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
@@ -27,6 +29,12 @@ function mapBackupError(code: string, t: (key: string) => string): string {
return t('settings.backup_not_owner') return t('settings.backup_not_owner')
case 'BACKUP_INVALID_JSON': case 'BACKUP_INVALID_JSON':
return t('settings.backup_invalid_json') return t('settings.backup_invalid_json')
case 'BACKUP_INVALID_ARCHIVE':
return t('settings.backup_invalid_archive')
case 'BACKUP_VERSION_UNSUPPORTED':
return t('settings.backup_version_unsupported')
case 'BACKUP_WRONG_PASSPHRASE':
return t('settings.backup_wrong_passphrase')
case 'BACKUP_INVALID_FORMAT': case 'BACKUP_INVALID_FORMAT':
return t('settings.backup_invalid_format') return t('settings.backup_invalid_format')
case 'BACKUP_NOT_AUTHENTICATED': case 'BACKUP_NOT_AUTHENTICATED':
@@ -53,12 +61,16 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
const [importPassphrase, setImportPassphrase] = useState('') const [importPassphrase, setImportPassphrase] = useState('')
const [importFile, setImportFile] = useState<File | null>(null) const [importFile, setImportFile] = useState<File | null>(null)
const [importPreview, setImportPreview] = useState<LogbookBackupPreview | null>(null) const [importPreview, setImportPreview] = useState<LogbookBackupPreview | null>(null)
const [parsedBackup, setParsedBackup] = useState<LogbookBackupFile | null>(null) const [parsedBackup, setParsedBackup] = useState<ParsedLogbookBackup | null>(null)
const [importing, setImporting] = useState(false) const [importing, setImporting] = useState(false)
const [previewing, setPreviewing] = useState(false) const [previewing, setPreviewing] = useState(false)
const [exportProgress, setExportProgress] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null) const [success, setSuccess] = useState<string | null>(null)
const exportPassphrasesMatch =
exportPassphrase.length >= 8 && exportPassphrase === exportConfirm
const handleExportSubmit = async (e: React.FormEvent) => { const handleExportSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
await handleExport() await handleExport()
@@ -83,21 +95,36 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
} }
setExporting(true) setExporting(true)
setExportProgress(null)
try { try {
const { blob, filename, backup } = await exportLogbookBackup(logbookId, exportPassphrase) const { blob, filename, manifest } = await exportLogbookBackup(logbookId, exportPassphrase, {
onProgress: (p) => {
if (p.phase === 'pack') {
setExportProgress(
t('settings.backup_export_progress', {
current: p.current,
total: p.total
})
)
}
}
})
downloadBackupBlob(blob, filename) downloadBackupBlob(blob, filename)
setSuccess(t('settings.backup_export_success', { count: backup.counts.entries })) setSuccess(t('settings.backup_export_success', { count: manifest.counts.entries }))
setExportPassphrase('') setExportPassphrase('')
setExportConfirm('') setExportConfirm('')
trackPlausibleEvent(PlausibleEvents.BACKUP_EXPORTED, { trackPlausibleEvent(PlausibleEvents.BACKUP_EXPORTED, {
entries: backup.counts.entries, entries: manifest.counts.entries,
photos: backup.counts.photos photos: manifest.counts.photos,
voiceMemos: manifest.counts.voiceMemos,
bytes: manifest.totalUncompressedBytes
}) })
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
setError(mapBackupError(message, t)) setError(mapBackupError(message, t))
} finally { } finally {
setExporting(false) setExporting(false)
setExportProgress(null)
} }
} }
@@ -138,6 +165,18 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => { const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => {
if (!parsedBackup || !importPassphrase) return if (!parsedBackup || !importPassphrase) return
if (parsedBackup.manifest.totalUncompressedBytes > BACKUP_SIZE_CONFIRM_BYTES) {
const ok = await showConfirm(
t('settings.backup_import_size_confirm', {
size: formatBackupBytes(parsedBackup.manifest.totalUncompressedBytes)
}),
t('settings.backup_restore_title'),
t('logs.confirm_yes'),
t('logs.confirm_no')
)
if (!ok) return
}
setImporting(true) setImporting(true)
setError(null) setError(null)
try { try {
@@ -149,8 +188,10 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
setParsedBackup(null) setParsedBackup(null)
if (fileInputRef.current) fileInputRef.current.value = '' if (fileInputRef.current) fileInputRef.current.value = ''
trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, { trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, {
entries: parsedBackup.counts.entries, entries: parsedBackup.manifest.counts.entries,
photos: parsedBackup.counts.photos, photos: parsedBackup.manifest.counts.photos,
voiceMemos: parsedBackup.manifest.counts.voiceMemos,
bytes: parsedBackup.manifest.totalUncompressedBytes,
mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id' mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id'
}) })
onRestored?.(result.logbookId, result.title) onRestored?.(result.logbookId, result.title)
@@ -253,11 +294,16 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
<button <button
type="submit" type="submit"
className="btn primary" className="btn primary"
disabled={exporting || !exportPassphrase || !exportConfirm} disabled={exporting || !exportPassphrasesMatch}
> >
<Download size={16} /> <Download size={16} />
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')} {exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')}
</button> </button>
{exportProgress && (
<p className="text-muted backup-export-progress" role="status">
{exportProgress}
</p>
)}
</form> </form>
</section> </section>
@@ -275,7 +321,7 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
id="backup-import-file" id="backup-import-file"
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept=".daagbok.json,application/json" accept=".daagbok,application/zip"
className="input-text" className="input-text"
onChange={handleFileChange} onChange={handleFileChange}
disabled={importing} disabled={importing}
@@ -330,8 +376,14 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
<ul className="backup-preview-stats"> <ul className="backup-preview-stats">
<li>{t('settings.backup_stat_entries', { count: importPreview.counts.entries })}</li> <li>{t('settings.backup_stat_entries', { count: importPreview.counts.entries })}</li>
<li>{t('settings.backup_stat_photos', { count: importPreview.counts.photos })}</li> <li>{t('settings.backup_stat_photos', { count: importPreview.counts.photos })}</li>
<li>{t('settings.backup_stat_voice', { count: importPreview.counts.voiceMemos })}</li>
<li>{t('settings.backup_stat_crew', { count: importPreview.counts.crews })}</li> <li>{t('settings.backup_stat_crew', { count: importPreview.counts.crews })}</li>
<li>{t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}</li> <li>{t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}</li>
<li className="text-muted">
{t('settings.backup_stat_size', {
size: formatBackupBytes(importPreview.totalUncompressedBytes)
})}
</li>
</ul> </ul>
<p className="text-muted backup-preview-date"> <p className="text-muted backup-preview-date">
{t('settings.backup_exported_at', { {t('settings.backup_exported_at', {
+21 -15
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef, useMemo } from 'react' import React, { useState, useEffect, useRef, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cycleAppLanguage } from '../utils/i18nLanguages.js' import LanguageDropdown from './LanguageDropdown.tsx'
import { useSyncIndicator } from '../hooks/useSyncIndicator.js' import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js' import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
import { loadLogbookSearchFieldsBatch } from '../services/logbookSearchIndex.js' import { loadLogbookSearchFieldsBatch } from '../services/logbookSearchIndex.js'
@@ -11,15 +11,17 @@ import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js' import { getErrorMessage } from '../utils/errors.js'
import { logoutUser } from '../services/auth.js' import { logoutUser } from '../services/auth.js'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react' import { BookOpen, Plus, Trash2, LogOut, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx' import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx' import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
import ProfileHeaderButton from './ProfileHeaderButton.tsx' import ProfileHeaderButton from './ProfileHeaderButton.tsx'
import AdminHeaderButton from './AdminHeaderButton.tsx'
interface LogbookDashboardProps { interface LogbookDashboardProps {
onSelectLogbook: (id: string, title: string) => void onSelectLogbook: (id: string, title: string) => void
onLogout: () => void onLogout: () => void
onOpenProfile: () => void onOpenProfile: () => void
onOpenAdmin?: () => void
} }
type LogbookSortKey = 'name' | 'date' type LogbookSortKey = 'name' | 'date'
@@ -33,16 +35,20 @@ function sortLogbooks(
): DecryptedLogbook[] { ): DecryptedLogbook[] {
const sorted = [...items] const sorted = [...items]
sorted.sort((a, b) => { sorted.sort((a, b) => {
const cmp = let cmp = 0
sortBy === 'name' if (sortBy === 'name') {
? a.title.localeCompare(b.title, locale, { sensitivity: 'base' }) cmp = a.title.localeCompare(b.title, locale, { sensitivity: 'base' })
: new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime() } else {
const timeA = a.lastTravelDate ? new Date(a.lastTravelDate).getTime() : new Date(a.updatedAt).getTime()
const timeB = b.lastTravelDate ? new Date(b.lastTravelDate).getTime() : new Date(b.updatedAt).getTime()
cmp = timeA - timeB
}
return direction === 'asc' ? cmp : -cmp return direction === 'asc' ? cmp : -cmp
}) })
return sorted return sorted
} }
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile }: LogbookDashboardProps) { export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile, onOpenAdmin }: LogbookDashboardProps) {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const { showConfirm } = useDialog() const { showConfirm } = useDialog()
const [logbooks, setLogbooks] = useState<DecryptedLogbook[]>([]) const [logbooks, setLogbooks] = useState<DecryptedLogbook[]>([])
@@ -196,9 +202,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
onLogout() onLogout()
} }
const toggleLanguage = () => {
cycleAppLanguage(i18n)
}
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared) const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
const sharedLogbooks = logbooks.filter((lb) => lb.isShared) const sharedLogbooks = logbooks.filter((lb) => lb.isShared)
@@ -289,8 +292,12 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
{lb.isDemo && ( {lb.isDemo && (
<span className="demo-badge">{t('demo.badge')}</span> <span className="demo-badge">{t('demo.badge')}</span>
)} )}
<span className="entry-count-badge" title={t('dashboard.travel_days_count', { count: lb.entryCount ?? 0 })}>
<CalendarDays size={12} style={{ marginRight: '4px' }} />
{lb.entryCount ?? 0}
</span>
<span className="date-badge"> <span className="date-badge">
{new Date(lb.updatedAt).toLocaleDateString(i18n.language, { {new Date(lb.lastTravelDate || lb.updatedAt).toLocaleDateString(i18n.language, {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric' day: 'numeric'
@@ -388,10 +395,9 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
<ProfileHeaderButton onClick={onOpenProfile} /> <ProfileHeaderButton onClick={onOpenProfile} />
{/* Lang toggle */} {onOpenAdmin && <AdminHeaderButton onClick={onOpenAdmin} />}
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
<Languages size={18} /> <LanguageDropdown variant="icon" align="right" />
</button>
<DisclaimerHeaderButton /> <DisclaimerHeaderButton />
+1 -1
View File
@@ -147,7 +147,7 @@ export default function PersonPoolForm() {
</button> </button>
<button <button
type="button" type="button"
className="btn-icon logout" className="btn-icon danger"
onClick={() => void handleDelete(person.payloadId)} onClick={() => void handleDelete(person.payloadId)}
title="Delete" title="Delete"
> >
+207 -66
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js' import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js' import { getActiveMasterKey } from '../services/auth.js'
@@ -8,7 +9,8 @@ import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.j
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js' import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
import { useLiveQuery } from 'dexie-react-hooks' import { useLiveQuery } from 'dexie-react-hooks'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import { Camera, Trash2 } from 'lucide-react' import { Camera, Image, Trash2, X, ChevronDown, ChevronUp } from 'lucide-react'
import { probeCameraAvailability } from '../utils/cameraAvailability.js'
interface PhotoCaptureProps { interface PhotoCaptureProps {
entryId: string entryId: string
@@ -27,12 +29,43 @@ interface DecryptedPhoto {
export default function PhotoCapture({ entryId, logbookId, readOnly = false, preloadedPhotos }: PhotoCaptureProps) { export default function PhotoCapture({ entryId, logbookId, readOnly = false, preloadedPhotos }: PhotoCaptureProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { showConfirm } = useDialog() const { showConfirm } = useDialog()
const [collapsed, setCollapsed] = useState(true)
const [caption, setCaption] = useState('') const [caption, setCaption] = useState('')
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [decryptedPhotos, setDecryptedPhotos] = useState<DecryptedPhoto[]>([]) const [decryptedPhotos, setDecryptedPhotos] = useState<DecryptedPhoto[]>([])
const [hasCamera, setHasCamera] = useState(false)
const [maximizedPhoto, setMaximizedPhoto] = useState<DecryptedPhoto | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const cameraInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!maximizedPhoto) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setMaximizedPhoto(null)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [maximizedPhoto])
useEffect(() => {
let cancelled = false
probeCameraAvailability().then((avail) => {
if (!cancelled) {
setHasCamera(avail === 'available')
}
})
return () => {
cancelled = true
}
}, [])
// Reactively query local photos database // Reactively query local photos database
const localPhotos = useLiveQuery( const localPhotos = useLiveQuery(
@@ -119,93 +152,201 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
} }
} }
const triggerSelect = () => { const triggerGallerySelect = () => {
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.click() fileInputRef.current.click()
} }
} }
const triggerCameraSelect = () => {
if (cameraInputRef.current) {
cameraInputRef.current.click()
}
}
return ( return (
<div className="form-card mt-6"> <div className="form-card mt-6">
<div className="form-header mb-4"> <div
<Camera size={20} className="form-icon" /> className="form-header accordion-header"
<h3>{t('logs.photos_title')}</h3> onClick={() => setCollapsed(!collapsed)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setCollapsed(!collapsed)
}
}}
role="button"
aria-expanded={!collapsed}
tabIndex={0}
>
<div className="accordion-header-title">
<Camera size={20} className="form-icon" />
<h3>{t('logs.photos_title')}</h3>
</div>
{collapsed ? (
<ChevronDown size={20} className="accordion-chevron" />
) : (
<ChevronUp size={20} className="accordion-chevron" />
)}
</div> </div>
{error && <div className="auth-error mb-4">{error}</div>} {!collapsed && (
<div style={{ marginTop: '16px' }}>
{error && <div className="auth-error mb-4">{error}</div>}
{/* Upload area */} {/* Upload area */}
{/* Upload Form */} {/* Upload Form */}
{!readOnly && ( {!readOnly && (
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}> <div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}> <div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}>
<label>{t('logs.photo_caption_label')}</label> <label>{t('logs.photo_caption_label')}</label>
<input <input
type="text" type="text"
placeholder={t('logs.photo_caption_placeholder')} placeholder={t('logs.photo_caption_placeholder')}
className="input-text" className="input-text"
value={caption} value={caption}
onChange={(e) => setCaption(e.target.value)} onChange={(e) => setCaption(e.target.value)}
disabled={uploading} disabled={uploading}
/> />
</div> </div>
<input <input
type="file" type="file"
accept="image/*" accept="image/*"
ref={fileInputRef} ref={fileInputRef}
onChange={handleFileChange} onChange={handleFileChange}
style={{ display: 'none' }} style={{ display: 'none' }}
/> />
<button <input
type="button" type="file"
className="btn primary" accept="image/*"
onClick={triggerSelect} capture="environment"
disabled={uploading} ref={cameraInputRef}
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }} onChange={handleFileChange}
> style={{ display: 'none' }}
{uploading ? ( />
<span className="spin"></span>
) : (
<Camera size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
</button>
</div>
</div>
)}
{/* Photo Grid */} {hasCamera ? (
{decryptedPhotos.length === 0 ? ( <>
<div className="dashboard-status-msg">{t('logs.no_photos')}</div> <button
) : ( type="button"
<div className="photo-attachments-grid"> className="btn primary"
{decryptedPhotos.map((photo) => ( onClick={triggerCameraSelect}
<div key={photo.payloadId} className="photo-card glass"> disabled={uploading}
<div className="photo-container"> style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" /> >
{!readOnly && ( {uploading ? (
<span className="spin"></span>
) : (
<Camera size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_camera_btn')}
</button>
<button
type="button"
className="btn secondary"
onClick={triggerGallerySelect}
disabled={uploading}
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
>
{uploading ? (
<span className="spin"></span>
) : (
<Image size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_gallery_btn')}
</button>
</>
) : (
<button <button
type="button" type="button"
className="photo-btn-delete" className="btn primary"
onClick={() => handleDelete(photo.payloadId)} onClick={triggerGallerySelect}
title="Remove photo" disabled={uploading}
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
> >
<Trash2 size={16} /> {uploading ? (
<span className="spin"></span>
) : (
<Camera size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
</button> </button>
)} )}
</div> </div>
{photo.caption && (
<div className="photo-caption-bar">
<span>{photo.caption}</span>
</div>
)}
</div> </div>
))} )}
{/* Photo Grid */}
{decryptedPhotos.length === 0 ? (
<div className="dashboard-status-msg">{t('logs.no_photos')}</div>
) : (
<div className="photo-attachments-grid">
{decryptedPhotos.map((photo) => (
<div
key={photo.payloadId}
className="photo-card glass"
onClick={() => setMaximizedPhoto(photo)}
style={{ cursor: 'pointer' }}
>
<div className="photo-container">
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
{!readOnly && (
<button
type="button"
className="photo-btn-delete"
onClick={(e) => {
e.stopPropagation()
handleDelete(photo.payloadId)
}}
title="Remove photo"
>
<Trash2 size={16} />
</button>
)}
</div>
{photo.caption && (
<div className="photo-caption-bar">
<span>{photo.caption}</span>
</div>
)}
</div>
))}
</div>
)}
</div> </div>
)} )}
{maximizedPhoto && createPortal(
<div
className="photo-maximized-overlay"
onClick={() => setMaximizedPhoto(null)}
>
<div className="photo-maximized-container" onClick={(e) => e.stopPropagation()}>
<button
type="button"
className="photo-maximized-close"
onClick={() => setMaximizedPhoto(null)}
aria-label={t('common.close') || 'Close'}
>
<X size={24} />
</button>
<img
src={maximizedPhoto.image}
alt={maximizedPhoto.caption || 'Maximized Attachment'}
className="photo-maximized-img"
/>
{maximizedPhoto.caption && (
<div className="photo-maximized-caption">
{maximizedPhoto.caption}
</div>
)}
</div>
</div>,
document.body
)}
</div> </div>
) )
} }
@@ -6,7 +6,8 @@ import {
enableCollaboratorChangePush, enableCollaboratorChangePush,
fetchPushPrefs, fetchPushPrefs,
getNotificationPermission, getNotificationPermission,
isPushSupported isPushSupported,
preloadPushService
} from '../services/pushNotifications.js' } from '../services/pushNotifications.js'
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js' import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
@@ -28,6 +29,7 @@ export default function PushNotificationSettings() {
setLoading(false) setLoading(false)
return return
} }
void preloadPushService()
try { try {
const prefs = await fetchPushPrefs() const prefs = await fetchPushPrefs()
setEnabled(prefs.collaboratorChangesEnabled) setEnabled(prefs.collaboratorChangesEnabled)
@@ -56,7 +58,8 @@ export default function PushNotificationSettings() {
trackPlausibleEvent(PlausibleEvents.PUSH_DISABLED) trackPlausibleEvent(PlausibleEvents.PUSH_DISABLED)
} }
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : t('profile.push_error') console.error('Failed to toggle push notifications:', err)
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
showAlert(message) showAlert(message)
void loadPrefs() void loadPrefs()
} finally { } finally {
+23 -9
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18nLanguages.js' import { isGermanLocale } from '../utils/i18nLanguages.js'
import LanguageDropdown from './LanguageDropdown.tsx'
import { decryptJson } from '../services/crypto.js' import { decryptJson } from '../services/crypto.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import LogbookVesselPicker from './LogbookVesselPicker.tsx' import LogbookVesselPicker from './LogbookVesselPicker.tsx'
@@ -12,7 +13,7 @@ import { emptyLogbookCrewSelection } from '../types/person.js'
import { legacyCrewRecordsToLogbookSelection } from '../utils/personSnapshots.js' import { legacyCrewRecordsToLogbookSelection } from '../utils/personSnapshots.js'
import type { PersonData } from '../types/person.js' import type { PersonData } from '../types/person.js'
import LogEntriesList from './LogEntriesList.tsx' import LogEntriesList from './LogEntriesList.tsx'
import { Ship, Users, FileText, Lock, AlertCircle, Globe } from 'lucide-react' import { Ship, Users, FileText, Lock, AlertCircle } from 'lucide-react'
interface ReadOnlyViewerProps { interface ReadOnlyViewerProps {
token: string token: string
@@ -46,6 +47,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
const [legacyCrews, setLegacyCrews] = useState<any[]>([]) const [legacyCrews, setLegacyCrews] = useState<any[]>([])
const [entries, setEntries] = useState<any[]>([]) const [entries, setEntries] = useState<any[]>([])
const [photos, setPhotos] = useState<any[]>([]) const [photos, setPhotos] = useState<any[]>([])
const [voiceMemos, setVoiceMemos] = useState<any[]>([])
const [gpsTracks, setGpsTracks] = useState<any[]>([]) const [gpsTracks, setGpsTracks] = useState<any[]>([])
useEffect(() => { useEffect(() => {
@@ -174,6 +176,23 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
} }
setPhotos(decPhotos) setPhotos(decPhotos)
const decVoiceMemos = []
if (data.voiceMemos) {
for (const v of data.voiceMemos) {
const dec = await decryptJson(v.encryptedData, v.iv, v.tag, keyBuffer)
if (dec) {
decVoiceMemos.push({
payloadId: v.payloadId,
audio: dec.audio,
mimeType: dec.mimeType,
durationSec: dec.durationSec,
caption: dec.caption || ''
})
}
}
}
setVoiceMemos(decVoiceMemos)
// Decrypt GPS Tracks // Decrypt GPS Tracks
const decGpsTracks = [] const decGpsTracks = []
if (data.gpsTracks) { if (data.gpsTracks) {
@@ -197,9 +216,6 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
} }
} }
const toggleLanguage = () => {
cycleAppLanguage(i18n)
}
if (loading) { if (loading) {
return ( return (
@@ -240,10 +256,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
</div> </div>
<div className="header-actions"> <div className="header-actions">
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}> <LanguageDropdown variant="secondary-button" align="right" />
<Globe size={14} style={{ marginRight: '4px' }} />
{t(`languages.${getNextLanguage(i18n.language)}`)}
</button>
</div> </div>
</header> </header>
@@ -282,6 +295,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
preloadedYacht={yacht} preloadedYacht={yacht}
preloadedEntries={entries} preloadedEntries={entries}
preloadedPhotos={photos} preloadedPhotos={photos}
preloadedVoiceMemos={voiceMemos}
preloadedGpsTracks={gpsTracks} preloadedGpsTracks={gpsTracks}
/> />
)} )}
+6 -3
View File
@@ -10,7 +10,8 @@ import { apiFetch } from '../services/api.js'
import { import {
enableCollaboratorChangePush, enableCollaboratorChangePush,
isCollaboratorPushActive, isCollaboratorPushActive,
isPushSupported isPushSupported,
preloadPushService
} from '../services/pushNotifications.js' } from '../services/pushNotifications.js'
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js' import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
@@ -55,6 +56,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
loadCollaborators() loadCollaborators()
loadShareLink() loadShareLink()
} }
void preloadPushService()
}, [logbookId]) }, [logbookId])
const loadShareLink = async () => { const loadShareLink = async () => {
@@ -191,7 +193,8 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED) trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED)
} catch (err: unknown) { } catch (err: unknown) {
console.error('Failed to enable push after invite:', err) console.error('Failed to enable push after invite:', err)
await showAlert(err instanceof Error ? err.message : t('profile.push_error')) const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
await showAlert(message)
} }
} }
@@ -426,7 +429,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
<td> <td>
<button <button
type="button" type="button"
className="btn-icon logout" className="btn-icon danger"
onClick={() => handleRevoke(c.id, c.username)} onClick={() => handleRevoke(c.id, c.username)}
title="Revoke access" title="Revoke access"
> >
+3 -2
View File
@@ -14,6 +14,7 @@ import {
} from '../services/statsAggregation.js' } from '../services/statsAggregation.js'
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js' import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
import { formatFuelPerMotorHour } from '../utils/fuelStats.js' import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
import { formatAppDecimal } from '../utils/numberFormat.js'
import { import {
loadLogbookEventSeries, loadLogbookEventSeries,
type EventSeriesPoint, type EventSeriesPoint,
@@ -211,8 +212,8 @@ function PropulsionBreakdown({ totals }: { totals: StatsTotals }) {
)} )}
</div> </div>
<div className="stats-propulsion-labels"> <div className="stats-propulsion-labels">
<span>{t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({sailPct.toFixed(0)}%)</span> <span>{t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({formatAppDecimal(sailPct, { maximumFractionDigits: 0 })}%)</span>
<span>{t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({motorPct.toFixed(0)}%)</span> <span>{t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({formatAppDecimal(motorPct, { maximumFractionDigits: 0 })}%)</span>
{totals.unknownPropulsionNm > 0 && ( {totals.unknownPropulsionNm > 0 && (
<span>{t('stats.unknown_propulsion')}: {formatNm(totals.unknownPropulsionNm)} {t('stats.unit_nm')}</span> <span>{t('stats.unknown_propulsion')}: {formatNm(totals.unknownPropulsionNm)} {t('stats.unit_nm')}</span>
)} )}
+4 -6
View File
@@ -1,6 +1,7 @@
import React, { useCallback } from 'react' import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { clampTankLiters } from '../utils/tankCapacity.js' import { clampTankLiters } from '../utils/tankCapacity.js'
import { formatTankLiters, parseAppDecimalOrZero } from '../utils/numberFormat.js'
interface TankLiterInputProps { interface TankLiterInputProps {
id?: string id?: string
@@ -13,10 +14,8 @@ interface TankLiterInputProps {
} }
function parseInputLiters(value: string): number { function parseInputLiters(value: string): number {
const trimmed = value.trim().replace(',', '.') if (!value.trim()) return 0
if (!trimmed) return 0 return parseAppDecimalOrZero(value)
const parsed = Number(trimmed)
return Number.isFinite(parsed) ? parsed : 0
} }
export default function TankLiterInput({ export default function TankLiterInput({
@@ -34,8 +33,7 @@ export default function TankLiterInput({
const emitValue = useCallback( const emitValue = useCallback(
(liters: number) => { (liters: number) => {
const clamped = clampTankLiters(liters, useSlider ? maxLiters : undefined) const clamped = clampTankLiters(liters, useSlider ? maxLiters : undefined)
const str = const str = formatTankLiters(clamped)
Number.isInteger(clamped) ? String(clamped) : String(Number(clamped.toFixed(1)))
onChange(str) onChange(str)
}, },
[onChange, maxLiters, useSlider] [onChange, maxLiters, useSlider]
@@ -1,6 +1,6 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Compass, Palette, Save, Check, Cloud } from 'lucide-react' import { Compass, Palette, Save, Check, Cloud, Brain } from 'lucide-react'
import ThemedSelect from './ThemedSelect.tsx' import ThemedSelect from './ThemedSelect.tsx'
import PushNotificationSettings from './PushNotificationSettings.tsx' import PushNotificationSettings from './PushNotificationSettings.tsx'
import PwaInstallPrompt from './PwaInstallPrompt.tsx' import PwaInstallPrompt from './PwaInstallPrompt.tsx'
@@ -13,7 +13,9 @@ import {
getThemePreference, getThemePreference,
setColorSchemePreference, setColorSchemePreference,
setOwmApiKey, setOwmApiKey,
setThemePreference setThemePreference,
getAiAuthorized,
setAiAuthorized
} from '../services/userPreferences.js' } from '../services/userPreferences.js'
interface UserProfilePreferencesProps { interface UserProfilePreferencesProps {
@@ -28,12 +30,25 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
const [colorScheme, setColorScheme] = useState(() => getColorSchemePreference(userId)) const [colorScheme, setColorScheme] = useState(() => getColorSchemePreference(userId))
const [savingOwm, setSavingOwm] = useState(false) const [savingOwm, setSavingOwm] = useState(false)
const [owmSaved, setOwmSaved] = useState(false) const [owmSaved, setOwmSaved] = useState(false)
const [aiAuthorized, setAiAuthorizedState] = useState(() => getAiAuthorized(userId))
useEffect(() => {
const handleChanged = () => {
setTheme(getThemePreference(userId))
setColorScheme(getColorSchemePreference(userId))
setAiAuthorizedState(getAiAuthorized(userId))
}
window.addEventListener('appearance-changed', handleChanged)
return () => {
window.removeEventListener('appearance-changed', handleChanged)
}
}, [userId])
const persistAppearance = (nextTheme: string, nextColorScheme: string) => { const persistAppearance = (nextTheme: string, nextColorScheme: string) => {
setThemePreference(userId, nextTheme) setThemePreference(userId, nextTheme)
setColorSchemePreference(userId, nextColorScheme) setColorSchemePreference(userId, nextColorScheme)
notifyAppearanceChanged() notifyAppearanceChanged()
void saveAppearancePrefsToServer(nextTheme, nextColorScheme).catch((err) => { void saveAppearancePrefsToServer(nextTheme, nextColorScheme, aiAuthorized, userId).catch((err) => {
console.warn('Failed to save appearance prefs to server:', err) console.warn('Failed to save appearance prefs to server:', err)
}) })
} }
@@ -58,6 +73,15 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
window.setTimeout(() => setOwmSaved(false), 3000) window.setTimeout(() => setOwmSaved(false), 3000)
} }
const handleAiToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
const nextVal = e.target.checked
setAiAuthorizedState(nextVal)
setAiAuthorized(userId, nextVal)
void saveAppearancePrefsToServer(theme, colorScheme, nextVal, userId).catch((err) => {
console.warn('Failed to save ai preference to server:', err)
})
}
return ( return (
<> <>
<section className="member-editor-card glass"> <section className="member-editor-card glass">
@@ -152,6 +176,42 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
</form> </form>
</section> </section>
<section className="member-editor-card glass">
<div className="profile-section-header">
<Brain size={20} style={{ color: 'var(--app-accent-light)' }} />
<h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}>
{t('profile.ai_title')}
</h3>
</div>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 12px 0' }}>
{t('profile.ai_desc')}
</p>
<p className="text-muted" style={{ fontSize: '13px', lineHeight: '145%', margin: '0 0 16px 0', whiteSpace: 'pre-line' }}>
{t('profile.ai_help')}
</p>
<label
className="switch-label"
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
cursor: 'pointer',
fontSize: '14px',
color: '#f1f5f9'
}}
>
<input
id="profile-ai-authorize"
type="checkbox"
checked={aiAuthorized}
onChange={handleAiToggle}
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
/>
<span>{t('profile.ai_enable_label')}</span>
</label>
</section>
<PushNotificationSettings /> <PushNotificationSettings />
<PwaInstallPrompt variant="inline" /> <PwaInstallPrompt variant="inline" />
</> </>
+1 -1
View File
@@ -193,7 +193,7 @@ export default function VesselPoolForm() {
</button> </button>
<button <button
type="button" type="button"
className="btn-icon logout" className="btn-icon danger"
onClick={() => void handleDelete(v.payloadId)} onClick={() => void handleDelete(v.payloadId)}
> >
<Trash2 size={14} /> <Trash2 size={14} />
+114
View File
@@ -0,0 +1,114 @@
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
transcribed?: boolean
}
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>
)
}
+67
View File
@@ -0,0 +1,67 @@
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) : '',
transcribed: decrypted.transcribed !== false
})
} catch {
// skip corrupt memo
}
}
if (!cancelled) setLookup(map)
})()
return () => {
cancelled = true
}
}, [localMemos, entryId, logbookId, preloaded])
return lookup
}
+4 -2
View File
@@ -42,12 +42,14 @@ function scheduleUpdateChecks(
const onVisibilityChange = () => { const onVisibilityChange = () => {
if (document.visibilityState === 'visible') { if (document.visibilityState === 'visible') {
checkForUpdate() // Delay check on wake-up to allow the mobile network stack to stabilize
setTimeout(checkForUpdate, 2000)
} }
} }
const onOnline = () => { const onOnline = () => {
checkForUpdate() // Small delay to ensure connection is fully established
setTimeout(checkForUpdate, 500)
} }
document.addEventListener('visibilitychange', onVisibilityChange) document.addEventListener('visibilitychange', onVisibilityChange)
+5 -1
View File
@@ -6,6 +6,8 @@ import deJson from './locales/de.json'
import daJson from './locales/da.json' import daJson from './locales/da.json'
import svJson from './locales/sv.json' import svJson from './locales/sv.json'
import nbJson from './locales/nb.json' import nbJson from './locales/nb.json'
import frJson from './locales/fr.json'
import esJson from './locales/es.json'
import { initSeo } from '../utils/seo.js' import { initSeo } from '../utils/seo.js'
import { SUPPORTED_LANGUAGES } from '../utils/i18nLanguages.js' import { SUPPORTED_LANGUAGES } from '../utils/i18nLanguages.js'
@@ -15,7 +17,9 @@ const resources = {
de: { translation: deJson.translation }, de: { translation: deJson.translation },
da: { translation: daJson.translation }, da: { translation: daJson.translation },
sv: { translation: svJson.translation }, sv: { translation: svJson.translation },
nb: { translation: nbJson.translation } nb: { translation: nbJson.translation },
fr: { translation: frJson.translation },
es: { translation: esJson.translation }
} }
i18n i18n
+5 -1
View File
@@ -4,6 +4,8 @@ import enJson from '../i18n/locales/en.json'
import daJson from '../i18n/locales/da.json' import daJson from '../i18n/locales/da.json'
import svJson from '../i18n/locales/sv.json' import svJson from '../i18n/locales/sv.json'
import nbJson from '../i18n/locales/nb.json' import nbJson from '../i18n/locales/nb.json'
import frJson from '../i18n/locales/fr.json'
import esJson from '../i18n/locales/es.json'
function collectKeys(obj: Record<string, unknown>, prefix = ''): string[] { function collectKeys(obj: Record<string, unknown>, prefix = ''): string[] {
const keys: string[] = [] const keys: string[] = []
@@ -23,7 +25,9 @@ const bundles = {
en: enJson.translation, en: enJson.translation,
da: daJson.translation, da: daJson.translation,
sv: svJson.translation, sv: svJson.translation,
nb: nbJson.translation nb: nbJson.translation,
fr: frJson.translation,
es: esJson.translation
} as const } as const
describe('i18n locale key parity', () => { describe('i18n locale key parity', () => {
File diff suppressed because it is too large Load Diff
+116 -23
View File
@@ -6,12 +6,18 @@
"beta": "Beta", "beta": "Beta",
"beta_hint": "Beta-Version — Funktionen können sich noch ändern" "beta_hint": "Beta-Version — Funktionen können sich noch ändern"
}, },
"footer": {
"kofi_label": "Ko-fi",
"kofi_title": "Projekt, Weiterentwicklung und Betriebskosten auf Ko-fi unterstützen"
},
"languages": { "languages": {
"de": "Deutsch", "de": "Deutsch",
"en": "English", "en": "English",
"da": "Dansk", "da": "Dansk",
"sv": "Svenska", "sv": "Svenska",
"nb": "Norsk" "nb": "Norsk",
"fr": "Français",
"es": "Español"
}, },
"dialog": { "dialog": {
"ok": "OK", "ok": "OK",
@@ -39,7 +45,8 @@
"deviation": "Ablenkungstabelle", "deviation": "Ablenkungstabelle",
"logs": "Logbucheinträge", "logs": "Logbucheinträge",
"stats": "Statistik", "stats": "Statistik",
"settings": "Einstellungen" "settings": "Einstellungen",
"admin": "Admin"
}, },
"auth": { "auth": {
"welcome": "Willkommen bei Kapteins Daagbok", "welcome": "Willkommen bei Kapteins Daagbok",
@@ -86,7 +93,15 @@
"use_localhost_link": "Zu localhost wechseln", "use_localhost_link": "Zu localhost wechseln",
"error_passkey_cancelled": "Passkey-Anmeldung abgebrochen oder abgelaufen. Bitte erneut versuchen.", "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_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." "error_session_incomplete": "Anmeldung unvollständig. Bitte erneut mit Passkey anmelden.",
"restore_checking": "Session wird geprüft…",
"restore_title": "Session wiederherstellen",
"restore_subtitle": "Deine Anmeldung ist noch aktiv. Entsperre dein Logbuch mit Passkey oder PIN.",
"restore_unlocking": "Wird entsperrt…",
"restore_with_passkey": "Mit Passkey entsperren ({{name}})",
"restore_with_pin": "Mit PIN entsperren",
"restore_pin_warning": "Gib deine lokale PIN ein, um dein Logbuch nach dem Neuladen zu entsperren.",
"restore_other_account": "Anderer Account anmelden"
}, },
"pwa": { "pwa": {
"title": "App installieren", "title": "App installieren",
@@ -173,6 +188,9 @@
"departure": "Start-Hafen (Reise von)", "departure": "Start-Hafen (Reise von)",
"destination": "Ziel-Hafen (nach)", "destination": "Ziel-Hafen (nach)",
"route": "Reise von/nach", "route": "Reise von/nach",
"tanks": "Tanks",
"customize_columns": "Spalten anpassen",
"column_selector_title": "Anzuzeigende Spalten",
"freshwater": "Frischwasser (Liter)", "freshwater": "Frischwasser (Liter)",
"fuel": "Treibstoff / Fuel (Liter)", "fuel": "Treibstoff / Fuel (Liter)",
"greywater": "Grauwasser (Liter)", "greywater": "Grauwasser (Liter)",
@@ -245,13 +263,13 @@
"live_sails_confirm": "Eintragen", "live_sails_confirm": "Eintragen",
"live_sails_confirm_count": "Eintragen ({{count}})", "live_sails_confirm_count": "Eintragen ({{count}})",
"live_sails": "Segel: {{sails}}", "live_sails": "Segel: {{sails}}",
"live_fix": "Fix", "live_position": "Position",
"live_fix_coords": "Fix {{lat}}, {{lng}}", "live_position_coords": "Position {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS nicht verfügbar. Breiten- und Längengrad manuell eingeben oder erneut per GPS-Knopf versuchen.", "live_position_manual_hint": "GPS nicht verfügbar. Breiten- und Längengrad manuell eingeben oder erneut per GPS-Knopf versuchen.",
"live_fix_gps_loading": "GPS-Position wird ermittelt…", "live_position_gps_loading": "GPS-Position wird ermittelt…",
"live_fix_invalid": "Bitte gültige Koordinaten eingeben (Breite 90…90, Länge 180…180).", "live_position_invalid": "Bitte gültige Koordinaten eingeben (Breite 90…90, Länge 180…180).",
"live_fix_lat_placeholder": "Breite (Lat)", "live_position_lat_placeholder": "Breite (Lat)",
"live_fix_lng_placeholder": "Länge (Lng)", "live_position_lng_placeholder": "Länge (Lng)",
"live_photo_btn": "Foto (Kamera)", "live_photo_btn": "Foto (Kamera)",
"live_photo_capture_btn": "Aufnehmen", "live_photo_capture_btn": "Aufnehmen",
"live_photo_save_btn": "Speichern", "live_photo_save_btn": "Speichern",
@@ -262,21 +280,43 @@
"live_photo_camera_starting": "Kamera wird gestartet…", "live_photo_camera_starting": "Kamera wird gestartet…",
"live_photo_camera_denied": "Kamerazugriff verweigert oder nicht verfügbar.", "live_photo_camera_denied": "Kamerazugriff verweigert oder nicht verfügbar.",
"live_photo_camera_unavailable": "Kamera wird von diesem Browser nicht unterstützt.", "live_photo_camera_unavailable": "Kamera wird von diesem Browser nicht unterstützt.",
"live_photo_no_camera": "Auf diesem Gerät ist keine Kamera verfügbar.",
"live_photo_error": "Foto konnte nicht gespeichert werden.", "live_photo_error": "Foto konnte nicht gespeichert werden.",
"live_photo_entry": "Foto: {{caption}}", "live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto aufgenommen", "live_photo_entry_plain": "Foto aufgenommen",
"live_undo_photo_hint": "Foto gespeichert", "live_undo_photo_hint": "Foto gespeichert",
"live_voice_btn": "Sprachnotiz",
"live_voice_hint": "Kurze Sprachnotiz aufnehmen (max. 60 Sekunden).",
"live_voice_record": "Aufnahme starten",
"live_voice_stop": "Aufnahme beenden",
"live_voice_recording": "Aufnahme {{time}}",
"live_voice_save": "Speichern",
"live_voice_saving": "Wird gespeichert…",
"live_voice_retake": "Neu aufnehmen",
"live_voice_mic_denied": "Mikrofonzugriff verweigert oder nicht verfügbar.",
"live_voice_record_failed": "Aufnahme fehlgeschlagen. Bitte erneut versuchen.",
"live_voice_unavailable": "Sprachnotiz nicht verfügbar",
"live_voice_too_large": "Aufnahme ist zu groß. Bitte kürzer aufnehmen.",
"live_voice_error": "Sprachnotiz konnte nicht gespeichert werden.",
"live_voice_entry": "Sprachnotiz: {{caption}}",
"live_voice_entry_plain": "Sprachnotiz",
"live_voice_caption_label": "Beschriftung (optional)",
"live_voice_caption_placeholder": "z. B. Funkverkehr mit Hafenmeister",
"live_voice_transcribe_action": "Transkribieren",
"live_voice_transcribing": "Transkribiere...",
"live_voice_transcribe_failed": "Sprachmemo gespeichert, aber Transkription fehlgeschlagen.",
"live_undo_voice_hint": "Sprachnotiz gespeichert",
"live_comment_btn": "Kommentar", "live_comment_btn": "Kommentar",
"live_comment_placeholder": "Freitext eingeben…", "live_comment_placeholder": "Freitext eingeben…",
"live_comment_confirm": "Eintragen", "live_comment_confirm": "Eintragen",
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.", "live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
"live_gps_start_hint": "Beginne deine Tagesreise immer mit einem Standort.", "live_gps_start_hint": "Beginne deine Tagesreise immer mit einer Position.",
"live_event_generic": "Ereignis", "live_event_generic": "Ereignis",
"live_weather_btn": "Wetter", "live_weather_btn": "Wetter",
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen", "live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
"live_weather_owm_loading": "Wetter wird geladen…", "live_weather_owm_loading": "Wetter wird geladen…",
"live_weather_fix_required": "Für Wetter von OpenWeatherMap zuerst einen GPS-Fix eintragen (Schaltfläche „Fix“). Die Position darf höchstens 6 Stunden alt sein.", "live_weather_position_required": "Für Wetter von OpenWeatherMap zuerst eine Position eintragen (Schaltfläche „Position“). Die Position darf höchstens 6 Stunden alt sein.",
"live_weather_fix_stale": "Der letzte GPS-Fix ist älter als 6 Stunden. Bitte erneut einen Fix loggen, bevor du Wetter abrufst.", "live_weather_position_stale": "Die letzte Position ist älter als 6 Stunden. Bitte erneut eine Position loggen, bevor du Wetter abrufst.",
"live_wind_btn": "Wind", "live_wind_btn": "Wind",
"live_temp_btn": "T °C", "live_temp_btn": "T °C",
"live_pressure_btn": "Luftdruck", "live_pressure_btn": "Luftdruck",
@@ -284,8 +324,8 @@
"live_sea_state_btn": "Seegang", "live_sea_state_btn": "Seegang",
"live_visibility_btn": "Sichtweite", "live_visibility_btn": "Sichtweite",
"live_course_btn": "Kurs", "live_course_btn": "Kurs",
"live_fuel_btn": "Diesel", "live_fuel_btn": "+ Diesel",
"live_water_btn": "Wasser", "live_water_btn": "+ Wasser",
"live_wind_entry": "Wind {{value}}", "live_wind_entry": "Wind {{value}}",
"live_temp_entry": "Temperatur {{temp}} °C", "live_temp_entry": "Temperatur {{temp}} °C",
"live_pressure_entry": "Luftdruck {{value}} hPa", "live_pressure_entry": "Luftdruck {{value}} hPa",
@@ -298,6 +338,7 @@
"live_auto_position": "Auto-Position", "live_auto_position": "Auto-Position",
"live_undo_hint": "Eintrag gespeichert", "live_undo_hint": "Eintrag gespeichert",
"live_undo_btn": "Rückgängig", "live_undo_btn": "Rückgängig",
"live_cancel": "Abbruch",
"live_pressure_placeholder": "z. B. 1013", "live_pressure_placeholder": "z. B. 1013",
"live_temp_placeholder": "z. B. 18", "live_temp_placeholder": "z. B. 18",
"live_precip_placeholder": "z. B. leichter Regen", "live_precip_placeholder": "z. B. leichter Regen",
@@ -320,6 +361,7 @@
"carry_over_tanks_yes": "Übernehmen", "carry_over_tanks_yes": "Übernehmen",
"carry_over_tanks_no": "Mit 0 starten", "carry_over_tanks_no": "Mit 0 starten",
"event_title": "Chronologisches Ereignisprotokoll", "event_title": "Chronologisches Ereignisprotokoll",
"event_creator": "Eingetragen von",
"no_events": "Noch keine Ereignisse für diesen Reisetag eingetragen.", "no_events": "Noch keine Ereignisse für diesen Reisetag eingetragen.",
"event_time": "Uhrzeit", "event_time": "Uhrzeit",
"event_mgk": "MgK Kurs", "event_mgk": "MgK Kurs",
@@ -357,7 +399,26 @@
"event_location_placeholder": "z. B. Kiel", "event_location_placeholder": "z. B. Kiel",
"event_remarks": "Bemerkungen / Vorkommnisse", "event_remarks": "Bemerkungen / Vorkommnisse",
"gps_btn": "GPS-Koordinaten abrufen", "gps_btn": "GPS-Koordinaten abrufen",
"gps_permission_denied": "Standortzugriff wurde verweigert. Bitte in den Browser- oder Geräteeinstellungen erlauben und erneut versuchen.",
"gps_timeout": "GPS-Zeitüberschreitung. Bitte erneut versuchen am besten im Freien mit gutem Empfang.",
"gps_position_unavailable": "Kein GPS-Signal verfügbar. Bitte warten oder Koordinaten manuell eingeben.",
"gps_unavailable": "GPS wird von diesem Browser oder Gerät nicht unterstützt.",
"gps_failed": "GPS-Position konnte nicht ermittelt werden.",
"gps_fallback_no_location": "GPS fehlgeschlagen. Bitte einen Ort unter „Ort / Hafen“, Start- oder Zielhafen eintragen, oder Koordinaten manuell eingeben.",
"gps_fallback_success": "Koordinaten für „{{location}}“ über den Ortsnamen ermittelt (nicht per GPS).",
"gps_fallback_failed": "GPS und Ortsnamen-Suche sind fehlgeschlagen. Bitte Koordinaten manuell eingeben.",
"gps_quality_excellent": "Starker GPS-Empfang (±{{accuracy}} m)",
"gps_quality_good": "Guter GPS-Empfang (±{{accuracy}} m)",
"gps_quality_fair": "Mäßiger GPS-Empfang (±{{accuracy}} m) für besseren Empfang ins Freie gehen.",
"gps_quality_poor": "Schwacher GPS-Empfang (±{{accuracy}} m) vermutlich wenig Satelliten. Im Freien erneut versuchen oder Position prüfen.",
"gps_quality_unknown": "GPS-Position übernommen (Genauigkeit vom Gerät nicht gemeldet).",
"gps_live_intro_title": "Standort für Live-Log",
"gps_live_intro_body": "Für automatische Positions-Einträge und den GPS-Knopf braucht die App Zugriff auf deinen Standort.\n\nTippe auf „Standort erlauben“ im nächsten Dialog die Freigabe bestätigen. Du kannst jederzeit manuell unter „Position“ eintragen.",
"gps_live_intro_allow": "Standort erlauben",
"gps_live_intro_later": "Später",
"gps_enable_in_settings_hint": "Standortzugriff ist blockiert. In den Browser- oder Geräteeinstellungen (Website / App → Standort) kannst du die Freigabe nachträglich erlauben.",
"weather_btn": "OpenWeatherMap Wetter abrufen", "weather_btn": "OpenWeatherMap Wetter abrufen",
"weather_offline": "OpenWeatherMap erfordert eine Internetverbindung. Du bist derzeit offline.",
"event_wind_pressure": "Luftdruck (hPa)", "event_wind_pressure": "Luftdruck (hPa)",
"event_heel": "Krängung (°)", "event_heel": "Krängung (°)",
"event_sails": "Segelführung / Motor", "event_sails": "Segelführung / Motor",
@@ -371,10 +432,24 @@
"share_csv": "CSV teilen", "share_csv": "CSV teilen",
"export_pdf": "PDF herunterladen", "export_pdf": "PDF herunterladen",
"exporting_pdf": "PDF wird generiert...", "exporting_pdf": "PDF wird generiert...",
"photos_title": "Foto-Anhänge (E2E-verschlüsselt)", "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",
"photo_caption_label": "Foto-Beschreibung / Label (Optional)", "photo_caption_label": "Foto-Beschreibung / Label (Optional)",
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt", "photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
"photo_btn": "Foto aufnehmen / Hochladen", "photo_btn": "Foto aufnehmen / Hochladen",
"photo_camera_btn": "Foto aufnehmen",
"photo_gallery_btn": "Aus Galerie wählen",
"photo_processing": "Wird verarbeitet...", "photo_processing": "Wird verarbeitet...",
"no_photos": "Noch keine Fotos an diesen Reisetag angehängt.", "no_photos": "Noch keine Fotos an diesen Reisetag angehängt.",
"photo_delete_confirm": "Bist du sicher, dass du dieses Foto unwiderruflich löschen möchtest?", "photo_delete_confirm": "Bist du sicher, dass du dieses Foto unwiderruflich löschen möchtest?",
@@ -439,8 +514,8 @@
"nmea_change_engine_stop": "Motor aus", "nmea_change_engine_stop": "Motor aus",
"nmea_change_autopilot_on": "Autopilot ein", "nmea_change_autopilot_on": "Autopilot ein",
"nmea_change_autopilot_off": "Autopilot aus", "nmea_change_autopilot_off": "Autopilot aus",
"nmea_change_gps_lost": "GPS-Fix verloren", "nmea_change_gps_lost": "GPS-Position verloren",
"nmea_change_gps_regained": "GPS-Fix wiederhergestellt", "nmea_change_gps_regained": "GPS-Position wiederhergestellt",
"nmea_change_water_temp": "Wassertemp. {{from}} → {{to}} °C", "nmea_change_water_temp": "Wassertemp. {{from}} → {{to}} °C",
"nmea_change_departure": "Abfahrt / Fahrtbeginn", "nmea_change_departure": "Abfahrt / Fahrtbeginn",
"nmea_change_anchor": "Ankern / Stop", "nmea_change_anchor": "Ankern / Stop",
@@ -464,9 +539,12 @@
"new_logbook_placeholder": "Name des Logbuchs oder der Yacht", "new_logbook_placeholder": "Name des Logbuchs oder der Yacht",
"logout": "Abmelden", "logout": "Abmelden",
"logged_in_as": "Angemeldet als {{name}}", "logged_in_as": "Angemeldet als {{name}}",
"delete_confirm": "Bist du sicher, dass du dieses Logbuch unwiderruflich löschen möchtest? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstelle vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok.json), falls du die Daten später behalten möchtest.", "delete_confirm": "Bist du sicher, dass du dieses Logbuch unwiderruflich löschen möchtest? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstelle vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok), falls du die Daten später behalten möchtest.",
"no_logbooks": "Keine Logbücher gefunden. Erstelle dein erstes Logbuch, um zu beginnen!", "no_logbooks": "Keine Logbücher gefunden. Erstelle dein erstes Logbuch, um zu beginnen!",
"loading": "Logbücher werden geladen...", "loading": "Logbücher werden geladen...",
"travel_days_count_zero": "Keine Reisetage",
"travel_days_count_one": "1 Reisetag",
"travel_days_count_other": "{{count}} Reisetage",
"status_synced": "Synchronisiert", "status_synced": "Synchronisiert",
"status_local": "Nur lokaler Cache", "status_local": "Nur lokaler Cache",
"delete_btn": "Logbuch löschen", "delete_btn": "Logbuch löschen",
@@ -604,6 +682,12 @@
"integrations_title": "Integrationen", "integrations_title": "Integrationen",
"owm_key": "OpenWeatherMap API-Schlüssel", "owm_key": "OpenWeatherMap API-Schlüssel",
"owm_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.", "owm_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.",
"ai_title": "KI-Funktionen & Datenschutz",
"ai_desc": "Autorisiere die Nutzung von künstlicher Intelligenz (lokale/Cloud-Integrationen) für deine Logbücher.",
"ai_help": "Die Aktivierung ermöglicht es, Reiseberichte automatisch zusammenzufassen und Sprachnotizen zu transkribieren. Zur Verarbeitung werden Sprachaufnahmen und Logbucheinträge verschlüsselt an OpenRouter übertragen. Die Daten werden dort nicht dauerhaft gespeichert.\n\nDa der Betrieb dieser Cloud-Ressourcen Kosten verursacht, freuen wir uns über eine freiwillige Unterstützung über den Ko-fi-Spenden-Link im Footer, um diese Funktionen dauerhaft für alle kostenlos anbieten zu können.",
"ai_enable_label": "Transkribierung und Tageszusammenfassungen aktivieren",
"ai_unauthorized_alert_title": "KI-Funktionen nicht autorisiert",
"ai_unauthorized_alert_desc": "Um Sprachnotizen zu transkribieren oder Reiseberichte zusammenzufassen, musst du der Datenübermittlung an OpenRouter in deinem Benutzerprofil unter 'KI-Funktionen & Datenschutz' zustimmen.",
"prefs_save": "Speichern", "prefs_save": "Speichern",
"prefs_saving": "Wird gespeichert…", "prefs_saving": "Wird gespeichert…",
"prefs_saved": "Gespeichert", "prefs_saved": "Gespeichert",
@@ -725,6 +809,9 @@
"no_key": "Kein OpenWeatherMap-API-Schlüssel verfügbar. Hinterlege einen eigenen Schlüssel im Benutzerprofil oder kontaktiere den Betreiber.", "no_key": "Kein OpenWeatherMap-API-Schlüssel verfügbar. Hinterlege einen eigenen Schlüssel im Benutzerprofil oder kontaktiere den Betreiber.",
"weather_success": "Wetterdaten erfolgreich abgerufen!", "weather_success": "Wetterdaten erfolgreich abgerufen!",
"weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfe den API-Schlüssel und die Verbindung.", "weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfe den API-Schlüssel und die Verbindung.",
"weather_unauthorized": "Wetterdatenabruf fehlgeschlagen. Der API-Schlüssel ist ungültig oder nicht autorisiert.",
"weather_not_found": "Wetterdatenabruf fehlgeschlagen. Der angegebene Ort oder die Koordinaten wurden nicht gefunden.",
"weather_bad_request": "Wetterdatenabruf fehlgeschlagen. Es wurde kein Ort und keine GPS-Position angegeben.",
"weather_date_mismatch": "Wetterdaten können nur für den heutigen Tag ({{today}}) abgerufen werden. Dieser Logbucheintrag ist auf den {{date}} datiert.", "weather_date_mismatch": "Wetterdaten können nur für den heutigen Tag ({{today}}) abgerufen werden. Dieser Logbucheintrag ist auf den {{date}} datiert.",
"gps_error": "Bitte gib einen Ort an oder ermittle die GPS-Koordinaten.", "gps_error": "Bitte gib einen Ort an oder ermittle die GPS-Koordinaten.",
"share_title": "Logbuch teilen (Schreibgeschützt)", "share_title": "Logbuch teilen (Schreibgeschützt)",
@@ -743,7 +830,7 @@
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen", "delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
"delete_account_confirm_no": "Abbrechen", "delete_account_confirm_no": "Abbrechen",
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.", "delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.",
"delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok.json) in den Einstellungen jedes Logbuchs.", "delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok) in den Einstellungen jedes Logbuchs.",
"deleting_account": "Konto wird gelöscht…", "deleting_account": "Konto wird gelöscht…",
"invite_push_prompt_title": "Push-Benachrichtigungen aktivieren?", "invite_push_prompt_title": "Push-Benachrichtigungen aktivieren?",
"invite_push_prompt_message": "Sobald eingeladene Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Es werden keine Logbuch-Inhalte im Klartext gesendet.", "invite_push_prompt_message": "Sobald eingeladene Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Es werden keine Logbuch-Inhalte im Klartext gesendet.",
@@ -752,9 +839,9 @@
"invite_push_prompt_later": "Später", "invite_push_prompt_later": "Später",
"invite_push_prompt_success": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.", "invite_push_prompt_success": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
"backup_title": "Backup & Wiederherstellung", "backup_title": "Backup & Wiederherstellung",
"backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.", "backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, Sprachnotizen, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.",
"backup_export_title": "Backup erstellen", "backup_export_title": "Backup erstellen",
"backup_export_desc": "Lädt alle lokalen Daten als .daagbok.json herunter. Bewahre Datei und Passphrase getrennt und sicher auf.", "backup_export_desc": "Lädt alle lokalen Daten als komprimierte .daagbok-Datei herunter. Bewahre Datei und Passphrase getrennt und sicher auf.",
"backup_restore_title": "Backup wiederherstellen", "backup_restore_title": "Backup wiederherstellen",
"backup_restore_desc": "Stellt ein Backup in deinem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.", "backup_restore_desc": "Stellt ein Backup in deinem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.",
"backup_passphrase": "Backup-Passphrase", "backup_passphrase": "Backup-Passphrase",
@@ -766,7 +853,13 @@
"backup_export_btn": "Backup herunterladen", "backup_export_btn": "Backup herunterladen",
"backup_exporting": "Backup wird erstellt…", "backup_exporting": "Backup wird erstellt…",
"backup_export_success": "Backup erstellt ({{count}} Reisetage).", "backup_export_success": "Backup erstellt ({{count}} Reisetage).",
"backup_file_label": "Backup-Datei (.daagbok.json)", "backup_file_label": "Backup-Datei (.daagbok)",
"backup_export_progress": "Packe Dateien {{current}} / {{total}}…",
"backup_invalid_archive": "Die Datei ist kein gültiges Backup-Archiv.",
"backup_version_unsupported": "Altes Backup-Format (v1). Bitte ein aktuelles .daagbok-Backup verwenden.",
"backup_import_size_confirm": "Dieses Backup ist etwa {{size}} groß. Wiederherstellung kann auf dem Gerät länger dauern und viel Speicher belegen. Fortfahren?",
"backup_stat_voice": "{{count}} Sprachnotizen",
"backup_stat_size": "Unkomprimiert ca. {{size}}",
"backup_preview_btn": "Inhalt prüfen", "backup_preview_btn": "Inhalt prüfen",
"backup_previewing": "Prüfe…", "backup_previewing": "Prüfe…",
"backup_restore_btn": "Wiederherstellen", "backup_restore_btn": "Wiederherstellen",
+116 -23
View File
@@ -6,12 +6,18 @@
"beta": "Beta", "beta": "Beta",
"beta_hint": "Beta release — features may still change" "beta_hint": "Beta release — features may still change"
}, },
"footer": {
"kofi_label": "Ko-fi",
"kofi_title": "Support the project, development, and running costs on Ko-fi"
},
"languages": { "languages": {
"de": "Deutsch", "de": "Deutsch",
"en": "English", "en": "English",
"da": "Dansk", "da": "Dansk",
"sv": "Svenska", "sv": "Svenska",
"nb": "Norsk" "nb": "Norsk",
"fr": "French",
"es": "Spanish"
}, },
"dialog": { "dialog": {
"ok": "OK", "ok": "OK",
@@ -39,7 +45,8 @@
"deviation": "Deviation Table", "deviation": "Deviation Table",
"logs": "Logbook Entries", "logs": "Logbook Entries",
"stats": "Statistics", "stats": "Statistics",
"settings": "Settings" "settings": "Settings",
"admin": "Admin"
}, },
"auth": { "auth": {
"welcome": "Welcome to Kapteins Daagbok", "welcome": "Welcome to Kapteins Daagbok",
@@ -86,7 +93,15 @@
"use_localhost_link": "Switch to localhost", "use_localhost_link": "Switch to localhost",
"error_passkey_cancelled": "Passkey sign-in was cancelled or timed out. Please try again.", "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_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." "error_session_incomplete": "Sign-in incomplete. Please sign in with your passkey again.",
"restore_checking": "Checking session…",
"restore_title": "Restore session",
"restore_subtitle": "You are still signed in. Unlock your logbook with passkey or PIN.",
"restore_unlocking": "Unlocking…",
"restore_with_passkey": "Unlock with passkey ({{name}})",
"restore_with_pin": "Unlock with PIN",
"restore_pin_warning": "Enter your local PIN to unlock your logbook after reload.",
"restore_other_account": "Sign in with another account"
}, },
"pwa": { "pwa": {
"title": "Install app", "title": "Install app",
@@ -173,6 +188,9 @@
"departure": "Departure Port (von)", "departure": "Departure Port (von)",
"destination": "Destination Port (nach)", "destination": "Destination Port (nach)",
"route": "Route / Journey", "route": "Route / Journey",
"tanks": "Tanks",
"customize_columns": "Customize columns",
"column_selector_title": "Columns to Show",
"freshwater": "Freshwater (Liters)", "freshwater": "Freshwater (Liters)",
"fuel": "Fuel (Liters)", "fuel": "Fuel (Liters)",
"greywater": "Greywater (Liters)", "greywater": "Greywater (Liters)",
@@ -245,13 +263,13 @@
"live_sails_confirm": "Log entry", "live_sails_confirm": "Log entry",
"live_sails_confirm_count": "Log entry ({{count}})", "live_sails_confirm_count": "Log entry ({{count}})",
"live_sails": "Sails: {{sails}}", "live_sails": "Sails: {{sails}}",
"live_fix": "Fix", "live_position": "Position",
"live_fix_coords": "Fix {{lat}}, {{lng}}", "live_position_coords": "Position {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS unavailable. Enter latitude and longitude manually, or try again with the GPS button.", "live_position_manual_hint": "GPS unavailable. Enter latitude and longitude manually, or try again with the GPS button.",
"live_fix_gps_loading": "Getting GPS position…", "live_position_gps_loading": "Getting GPS position…",
"live_fix_invalid": "Please enter valid coordinates (latitude 90…90, longitude 180…180).", "live_position_invalid": "Please enter valid coordinates (latitude 90…90, longitude 180…180).",
"live_fix_lat_placeholder": "Latitude (Lat)", "live_position_lat_placeholder": "Latitude (Lat)",
"live_fix_lng_placeholder": "Longitude (Lng)", "live_position_lng_placeholder": "Longitude (Lng)",
"live_photo_btn": "Photo (camera)", "live_photo_btn": "Photo (camera)",
"live_photo_capture_btn": "Capture", "live_photo_capture_btn": "Capture",
"live_photo_save_btn": "Save", "live_photo_save_btn": "Save",
@@ -262,21 +280,43 @@
"live_photo_camera_starting": "Starting camera…", "live_photo_camera_starting": "Starting camera…",
"live_photo_camera_denied": "Camera access denied or unavailable.", "live_photo_camera_denied": "Camera access denied or unavailable.",
"live_photo_camera_unavailable": "Camera is not supported in this browser.", "live_photo_camera_unavailable": "Camera is not supported in this browser.",
"live_photo_no_camera": "No camera is available on this device.",
"live_photo_error": "Could not save photo.", "live_photo_error": "Could not save photo.",
"live_photo_entry": "Photo: {{caption}}", "live_photo_entry": "Photo: {{caption}}",
"live_photo_entry_plain": "Photo captured", "live_photo_entry_plain": "Photo captured",
"live_undo_photo_hint": "Photo saved", "live_undo_photo_hint": "Photo saved",
"live_voice_btn": "Voice memo",
"live_voice_hint": "Record a short voice memo (max. 60 seconds).",
"live_voice_record": "Start recording",
"live_voice_stop": "Stop recording",
"live_voice_recording": "Recording {{time}}",
"live_voice_save": "Save",
"live_voice_saving": "Saving…",
"live_voice_retake": "Record again",
"live_voice_mic_denied": "Microphone access denied or unavailable.",
"live_voice_record_failed": "Recording failed. Please try again.",
"live_voice_unavailable": "Voice memo unavailable",
"live_voice_too_large": "Recording is too large. Please record a shorter memo.",
"live_voice_error": "Could not save voice memo.",
"live_voice_entry": "Voice memo: {{caption}}",
"live_voice_entry_plain": "Voice memo",
"live_voice_caption_label": "Caption (optional)",
"live_voice_caption_placeholder": "e.g. radio call with harbour master",
"live_voice_transcribe_action": "Transcribe",
"live_voice_transcribing": "Transcribing…",
"live_voice_transcribe_failed": "Voice memo saved, but transcription failed.",
"live_undo_voice_hint": "Voice memo saved",
"live_comment_btn": "Comment", "live_comment_btn": "Comment",
"live_comment_placeholder": "Enter text…", "live_comment_placeholder": "Enter text…",
"live_comment_confirm": "Log entry", "live_comment_confirm": "Log entry",
"live_gps_error": "Could not determine GPS position.", "live_gps_error": "Could not determine GPS position.",
"live_gps_start_hint": "Always start your day's voyage with a position fix.", "live_gps_start_hint": "Always start your day's voyage with a position.",
"live_event_generic": "Event", "live_event_generic": "Event",
"live_weather_btn": "Weather", "live_weather_btn": "Weather",
"live_weather_owm_btn": "Fetch OpenWeatherMap weather", "live_weather_owm_btn": "Fetch OpenWeatherMap weather",
"live_weather_owm_loading": "Loading weather…", "live_weather_owm_loading": "Loading weather…",
"live_weather_fix_required": "Log a GPS fix first (Fix button) to fetch OpenWeatherMap weather. The position must be at most 6 hours old.", "live_weather_position_required": "Log a position first (Position button) to fetch OpenWeatherMap weather. The position must be at most 6 hours old.",
"live_weather_fix_stale": "The last GPS fix is older than 6 hours. Log a new fix before fetching weather.", "live_weather_position_stale": "The last position is older than 6 hours. Log a new position before fetching weather.",
"live_wind_btn": "Wind", "live_wind_btn": "Wind",
"live_temp_btn": "Temp °C", "live_temp_btn": "Temp °C",
"live_pressure_btn": "Pressure", "live_pressure_btn": "Pressure",
@@ -284,8 +324,8 @@
"live_sea_state_btn": "Sea state", "live_sea_state_btn": "Sea state",
"live_visibility_btn": "Visibility", "live_visibility_btn": "Visibility",
"live_course_btn": "Course", "live_course_btn": "Course",
"live_fuel_btn": "Fuel", "live_fuel_btn": "+ Fuel",
"live_water_btn": "Water", "live_water_btn": "+ Water",
"live_wind_entry": "Wind {{value}}", "live_wind_entry": "Wind {{value}}",
"live_temp_entry": "Temperature {{temp}} °C", "live_temp_entry": "Temperature {{temp}} °C",
"live_pressure_entry": "Pressure {{value}} hPa", "live_pressure_entry": "Pressure {{value}} hPa",
@@ -298,6 +338,7 @@
"live_auto_position": "Auto position", "live_auto_position": "Auto position",
"live_undo_hint": "Entry saved", "live_undo_hint": "Entry saved",
"live_undo_btn": "Undo", "live_undo_btn": "Undo",
"live_cancel": "Cancel",
"live_pressure_placeholder": "e.g. 1013", "live_pressure_placeholder": "e.g. 1013",
"live_temp_placeholder": "e.g. 18", "live_temp_placeholder": "e.g. 18",
"live_precip_placeholder": "e.g. light rain", "live_precip_placeholder": "e.g. light rain",
@@ -320,6 +361,7 @@
"carry_over_tanks_yes": "Carry over", "carry_over_tanks_yes": "Carry over",
"carry_over_tanks_no": "Start at 0", "carry_over_tanks_no": "Start at 0",
"event_title": "Chronological Event Logbook", "event_title": "Chronological Event Logbook",
"event_creator": "Entered by",
"no_events": "No events logged for this travel day yet.", "no_events": "No events logged for this travel day yet.",
"event_time": "Time", "event_time": "Time",
"event_mgk": "MgK Course", "event_mgk": "MgK Course",
@@ -357,7 +399,26 @@
"event_location_placeholder": "e.g. Kiel", "event_location_placeholder": "e.g. Kiel",
"event_remarks": "Remarks / Events", "event_remarks": "Remarks / Events",
"gps_btn": "Get GPS Location", "gps_btn": "Get GPS Location",
"gps_permission_denied": "Location access was denied. Allow it in your browser or device settings and try again.",
"gps_timeout": "GPS timed out. Try again outdoors with a clear view of the sky.",
"gps_position_unavailable": "No GPS signal available. Wait and retry, or enter coordinates manually.",
"gps_unavailable": "GPS is not supported by this browser or device.",
"gps_failed": "Could not determine GPS position.",
"gps_fallback_no_location": "GPS failed. Enter a place under Location / harbour, departure, or destination, or type coordinates manually.",
"gps_fallback_success": "Coordinates for \"{{location}}\" resolved from place name (not GPS).",
"gps_fallback_failed": "GPS and place-name lookup both failed. Please enter coordinates manually.",
"gps_quality_excellent": "Strong GPS reception (±{{accuracy}} m)",
"gps_quality_good": "Good GPS reception (±{{accuracy}} m)",
"gps_quality_fair": "Fair GPS reception (±{{accuracy}} m) — move outdoors for a better fix.",
"gps_quality_poor": "Weak GPS reception (±{{accuracy}} m) — likely few satellites. Retry outdoors or verify the position.",
"gps_quality_unknown": "GPS position applied (accuracy not reported by device).",
"gps_live_intro_title": "Location for Live Log",
"gps_live_intro_body": "The app needs your location for automatic position entries and the GPS button.\n\nTap “Allow location” and confirm in the next dialog. You can always enter a position manually via “Position”.",
"gps_live_intro_allow": "Allow location",
"gps_live_intro_later": "Later",
"gps_enable_in_settings_hint": "Location access is blocked. You can allow it later in your browser or device settings (site / app → Location).",
"weather_btn": "Fetch OpenWeatherMap Weather", "weather_btn": "Fetch OpenWeatherMap Weather",
"weather_offline": "OpenWeatherMap requires an internet connection. You are currently offline.",
"event_wind_pressure": "Barometer (hPa)", "event_wind_pressure": "Barometer (hPa)",
"event_heel": "Heel Angle (°)", "event_heel": "Heel Angle (°)",
"event_sails": "Sails / Motor Status", "event_sails": "Sails / Motor Status",
@@ -371,10 +432,24 @@
"share_csv": "Share CSV", "share_csv": "Share CSV",
"export_pdf": "Download PDF", "export_pdf": "Download PDF",
"exporting_pdf": "Generating PDF...", "exporting_pdf": "Generating PDF...",
"photos_title": "Photo Attachments (E2E Encrypted)", "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",
"photo_caption_label": "Photo Caption / Label (Optional)", "photo_caption_label": "Photo Caption / Label (Optional)",
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance", "photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
"photo_btn": "Take Photo / Upload", "photo_btn": "Take Photo / Upload",
"photo_camera_btn": "Take Photo",
"photo_gallery_btn": "Choose from Gallery",
"photo_processing": "Processing...", "photo_processing": "Processing...",
"no_photos": "No photos attached to this journal entry yet.", "no_photos": "No photos attached to this journal entry yet.",
"photo_delete_confirm": "Are you sure you want to permanently delete this photo?", "photo_delete_confirm": "Are you sure you want to permanently delete this photo?",
@@ -439,8 +514,8 @@
"nmea_change_engine_stop": "Engine off", "nmea_change_engine_stop": "Engine off",
"nmea_change_autopilot_on": "Autopilot on", "nmea_change_autopilot_on": "Autopilot on",
"nmea_change_autopilot_off": "Autopilot off", "nmea_change_autopilot_off": "Autopilot off",
"nmea_change_gps_lost": "GPS fix lost", "nmea_change_gps_lost": "GPS position lost",
"nmea_change_gps_regained": "GPS fix restored", "nmea_change_gps_regained": "GPS position restored",
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C", "nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway", "nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop", "nmea_change_anchor": "Anchored / stop",
@@ -464,9 +539,12 @@
"new_logbook_placeholder": "Logbook or Yacht Name", "new_logbook_placeholder": "Logbook or Yacht Name",
"logout": "Logout", "logout": "Logout",
"logged_in_as": "Signed in as {{name}}", "logged_in_as": "Signed in as {{name}}",
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok.json) if you may need the data later.", "delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok) if you may need the data later.",
"no_logbooks": "No logbooks found. Create your first logbook to begin!", "no_logbooks": "No logbooks found. Create your first logbook to begin!",
"loading": "Loading logbooks...", "loading": "Loading logbooks...",
"travel_days_count_zero": "No travel days",
"travel_days_count_one": "1 travel day",
"travel_days_count_other": "{{count}} travel days",
"status_synced": "Synced", "status_synced": "Synced",
"status_local": "Local Cache Only", "status_local": "Local Cache Only",
"delete_btn": "Delete logbook", "delete_btn": "Delete logbook",
@@ -604,6 +682,12 @@
"integrations_title": "Integrations", "integrations_title": "Integrations",
"owm_key": "OpenWeatherMap API key", "owm_key": "OpenWeatherMap API key",
"owm_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.", "owm_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.",
"ai_title": "AI Features & Privacy",
"ai_desc": "Authorize artificial intelligence integrations for your logbooks.",
"ai_help": "Enabling AI features allows the app to summarize travel days and transcribe recorded voice memos. To process these requests, raw voice data and travel logs are sent securely on-the-fly to OpenRouter. No data is stored permanently by the AI model.\n\nThese cloud resources cost money to run; if you enjoy using them, please consider supporting the project voluntarily with a donation via the Ko-fi link in the footer to keep them free and sustainable for everyone.",
"ai_enable_label": "Enable transcription and travel day summaries",
"ai_unauthorized_alert_title": "AI Features Not Authorized",
"ai_unauthorized_alert_desc": "To use transcription or travel day summaries, please authorize the data transmission to OpenRouter in your User Profile under 'AI Features & Privacy'.",
"prefs_save": "Save", "prefs_save": "Save",
"prefs_saving": "Saving…", "prefs_saving": "Saving…",
"prefs_saved": "Saved", "prefs_saved": "Saved",
@@ -725,6 +809,9 @@
"no_key": "No OpenWeatherMap API key available. Add your own key in your user profile or contact the operator.", "no_key": "No OpenWeatherMap API key available. Add your own key in your user profile or contact the operator.",
"weather_success": "Weather details fetched successfully!", "weather_success": "Weather details fetched successfully!",
"weather_error": "Failed to fetch weather. Check your API key and connection.", "weather_error": "Failed to fetch weather. Check your API key and connection.",
"weather_unauthorized": "Failed to fetch weather. The API key is invalid or unauthorized.",
"weather_not_found": "Failed to fetch weather. The specified location or coordinates were not found.",
"weather_bad_request": "Failed to fetch weather. No location or GPS position was specified.",
"weather_date_mismatch": "Weather data can only be fetched for today ({{today}}). This logbook entry is dated {{date}}.", "weather_date_mismatch": "Weather data can only be fetched for today ({{today}}). This logbook entry is dated {{date}}.",
"gps_error": "Please enter a location or fetch GPS coordinates first.", "gps_error": "Please enter a location or fetch GPS coordinates first.",
"share_title": "Share Logbook (Read-Only)", "share_title": "Share Logbook (Read-Only)",
@@ -743,7 +830,7 @@
"delete_account_confirm_yes": "Yes, Delete Account and All Data", "delete_account_confirm_yes": "Yes, Delete Account and All Data",
"delete_account_confirm_no": "Cancel", "delete_account_confirm_no": "Cancel",
"delete_account_failed": "Failed to delete account. Please try again.", "delete_account_failed": "Failed to delete account. Please try again.",
"delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok.json) in each logbook's settings.", "delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok) in each logbook's settings.",
"deleting_account": "Deleting account…", "deleting_account": "Deleting account…",
"invite_push_prompt_title": "Enable push notifications?", "invite_push_prompt_title": "Enable push notifications?",
"invite_push_prompt_message": "When invited crew members sync changes, you can be notified via push. No logbook content is sent in plain text.", "invite_push_prompt_message": "When invited crew members sync changes, you can be notified via push. No logbook content is sent in plain text.",
@@ -752,9 +839,9 @@
"invite_push_prompt_later": "Later", "invite_push_prompt_later": "Later",
"invite_push_prompt_success": "Push notifications are active on this device.", "invite_push_prompt_success": "Push notifications are active on this device.",
"backup_title": "Backup & restore", "backup_title": "Backup & restore",
"backup_desc": "Full encrypted backup of this logbook (entries, photos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.", "backup_desc": "Full encrypted backup of this logbook (entries, photos, voice memos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.",
"backup_export_title": "Create backup", "backup_export_title": "Create backup",
"backup_export_desc": "Downloads all local data as a .daagbok.json file. Keep the file and passphrase separate and secure.", "backup_export_desc": "Downloads all local data as a compressed .daagbok archive. Keep the file and passphrase separate and secure.",
"backup_restore_title": "Restore backup", "backup_restore_title": "Restore backup",
"backup_restore_desc": "Restores a backup into your current account — including after registering a new account.", "backup_restore_desc": "Restores a backup into your current account — including after registering a new account.",
"backup_passphrase": "Backup passphrase", "backup_passphrase": "Backup passphrase",
@@ -766,7 +853,13 @@
"backup_export_btn": "Download backup", "backup_export_btn": "Download backup",
"backup_exporting": "Creating backup…", "backup_exporting": "Creating backup…",
"backup_export_success": "Backup created ({{count}} travel days).", "backup_export_success": "Backup created ({{count}} travel days).",
"backup_file_label": "Backup file (.daagbok.json)", "backup_file_label": "Backup file (.daagbok)",
"backup_export_progress": "Packing files {{current}} / {{total}}…",
"backup_invalid_archive": "The file is not a valid backup archive.",
"backup_version_unsupported": "Legacy backup format (v1). Please use a current .daagbok backup.",
"backup_import_size_confirm": "This backup is about {{size}} uncompressed. Restore may take longer and use significant memory. Continue?",
"backup_stat_voice": "{{count}} voice memos",
"backup_stat_size": "Approx. {{size}} uncompressed",
"backup_preview_btn": "Verify contents", "backup_preview_btn": "Verify contents",
"backup_previewing": "Verifying…", "backup_previewing": "Verifying…",
"backup_restore_btn": "Restore", "backup_restore_btn": "Restore",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+59
View File
@@ -18,3 +18,62 @@ body {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
} }
/* Scrollbars — auf Touch-Geräten breiter und besser sichtbar */
:root {
--app-scrollbar-size: 10px;
}
@media (hover: none), (pointer: coarse), (max-width: 768px) {
:root {
--app-scrollbar-size: 14px;
}
}
html {
scrollbar-width: auto;
scrollbar-color: var(--app-accent-light) var(--app-surface-inset);
-webkit-overflow-scrolling: touch;
}
html::-webkit-scrollbar,
body::-webkit-scrollbar,
*::-webkit-scrollbar {
width: var(--app-scrollbar-size);
height: var(--app-scrollbar-size);
}
html::-webkit-scrollbar-track,
body::-webkit-scrollbar-track,
*::-webkit-scrollbar-track {
background: var(--app-surface-inset);
border-radius: calc(var(--app-scrollbar-size) / 2);
}
html::-webkit-scrollbar-thumb,
body::-webkit-scrollbar-thumb,
*::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--app-accent-light) 55%, transparent);
border-radius: calc(var(--app-scrollbar-size) / 2);
min-height: 48px;
}
html::-webkit-scrollbar-thumb:hover,
body::-webkit-scrollbar-thumb:hover,
*::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--app-accent-light) 80%, transparent);
}
@media (hover: none), (pointer: coarse), (max-width: 768px) {
html::-webkit-scrollbar-thumb,
body::-webkit-scrollbar-thumb,
*::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--app-accent-light) 70%, transparent);
}
html::-webkit-scrollbar-thumb:active,
body::-webkit-scrollbar-thumb:active,
*::-webkit-scrollbar-thumb:active {
background: var(--app-accent-light);
}
}
+11
View File
@@ -73,6 +73,17 @@ async function bootstrap(): Promise<void> {
return return
} }
if ('serviceWorker' in navigator && !import.meta.env.DEV) {
navigator.serviceWorker
.register('/sw.js', { scope: '/' })
.then((reg) => {
console.log('Service Worker registered successfully with scope:', reg.scope)
})
.catch((err) => {
console.error('Service Worker registration failed:', err)
})
}
const rootEl = document.getElementById('root') const rootEl = document.getElementById('root')
if (!rootEl) { if (!rootEl) {
throw new Error('Missing #root element') throw new Error('Missing #root element')
+75
View File
@@ -0,0 +1,75 @@
import { ApiError, apiJson } from './api.js'
const ADMIN_BASE = '/api/admin'
export interface AdminMe {
isAdmin: boolean
userId: string
}
export interface AdminSummary {
totalUsers: number
totalLogbooks: number
totalPhotos: number
totalVoiceMemos: number
totalGpsTracks: number
totalCollaborations: number
totalInvitations: number
aiSummaryEntries: number
dbSize: number
}
export type AdminTimeBucket = 'day' | 'week' | 'month'
export interface AdminTimeSeriesPoint {
date: string
count: number
}
export interface AdminTimeSeriesMetric {
metric: string
points: AdminTimeSeriesPoint[]
}
export interface AdminTimeSeriesResponse {
bucket: AdminTimeBucket
windowDays: number
series: AdminTimeSeriesMetric[]
}
export async function fetchAdminMe(): Promise<AdminMe> {
return await apiJson<AdminMe>(`${ADMIN_BASE}/me`)
}
/** Returns true only for users listed in server ADMIN_USER_IDS. */
export async function checkAdminAccess(): Promise<boolean> {
try {
await fetchAdminMe()
return true
} catch (err) {
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
return false
}
return false
}
}
export async function fetchAdminSummary(): Promise<AdminSummary> {
return await apiJson<AdminSummary>(`${ADMIN_BASE}/summary`)
}
export async function fetchAdminTimeSeries(
params: { bucket?: AdminTimeBucket; windowDays?: number } = {}
): Promise<AdminTimeSeriesResponse> {
const search = new URLSearchParams()
if (params.bucket) {
search.set('bucket', params.bucket)
}
if (params.windowDays && Number.isFinite(params.windowDays)) {
search.set('window', String(params.windowDays))
}
const query = search.toString()
const url = query ? `${ADMIN_BASE}/timeseries?${query}` : `${ADMIN_BASE}/timeseries`
return await apiJson<AdminTimeSeriesResponse>(url)
}
+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 }
}
+4 -1
View File
@@ -26,6 +26,7 @@ export const PlausibleEvents = {
PUSH_ENABLED: 'Push Enabled', PUSH_ENABLED: 'Push Enabled',
PUSH_DISABLED: 'Push Disabled', PUSH_DISABLED: 'Push Disabled',
FOOTER_LINK_CLICKED: 'Footer Link Clicked', FOOTER_LINK_CLICKED: 'Footer Link Clicked',
KOFI_LINK_CLICKED: 'Ko-fi Link Clicked',
PROFILE_OPENED: 'Profile Opened', PROFILE_OPENED: 'Profile Opened',
PASSKEY_ADDED: 'Passkey Added', PASSKEY_ADDED: 'Passkey Added',
PASSKEY_REMOVED: 'Passkey Removed', PASSKEY_REMOVED: 'Passkey Removed',
@@ -40,8 +41,10 @@ export const PlausibleEvents = {
NMEA_UPLOADED: 'NMEA Uploaded', NMEA_UPLOADED: 'NMEA Uploaded',
LIVE_LOG_OPENED: 'Live Log Opened', LIVE_LOG_OPENED: 'Live Log Opened',
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged', LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
LIVE_LOG_PHOTO_UPLOADED: 'Live Log Photo Uploaded', VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded',
VOICE_MEMO_TRANSCRIBED: 'Voice Memo Transcribed',
OWM_WEATHER_FETCHED: 'OWM Weather Fetched', OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
AI_SUMMARY_GENERATED: 'AI Summary Generated',
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft', PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
PWA_BOOT_WATCHDOG_HARD: 'PWA Boot Watchdog Hard', PWA_BOOT_WATCHDOG_HARD: 'PWA Boot Watchdog Hard',
PWA_BOOT_WATCHDOG_FALLBACK: 'PWA Boot Watchdog Fallback', PWA_BOOT_WATCHDOG_FALLBACK: 'PWA Boot Watchdog Fallback',
+29 -8
View File
@@ -10,22 +10,43 @@ export class ApiError extends Error {
export async function apiFetch( export async function apiFetch(
input: string, input: string,
init: RequestInit = {} init: RequestInit = {},
timeoutMs = 15000
): Promise<Response> { ): Promise<Response> {
const headers = new Headers(init.headers) const headers = new Headers(init.headers)
if (init.body !== undefined && !headers.has('Content-Type')) { if (init.body !== undefined && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json') headers.set('Content-Type', 'application/json')
} }
return fetch(input, { const controller = new AbortController()
...init, const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
headers,
credentials: 'include' if (init.signal) {
}) if (init.signal.aborted) {
controller.abort()
} else {
init.signal.addEventListener('abort', () => controller.abort())
}
}
try {
return await fetch(input, {
...init,
headers,
credentials: 'include',
signal: controller.signal
})
} finally {
clearTimeout(timeoutId)
}
} }
export async function apiJson<T>(input: string, init: RequestInit = {}): Promise<T> { export async function apiJson<T>(
const res = await apiFetch(input, init) input: string,
init: RequestInit = {},
timeoutMs = 15000
): Promise<T> {
const res = await apiFetch(input, init, timeoutMs)
const data = await res.json().catch(() => ({})) const data = await res.json().catch(() => ({}))
if (!res.ok) { if (!res.ok) {
const message = const message =
+8 -4
View File
@@ -26,6 +26,7 @@ describe('appearancePrefs', () => {
await expect(fetchAppearancePrefs()).resolves.toEqual({ await expect(fetchAppearancePrefs()).resolves.toEqual({
theme: 'auto', theme: 'auto',
colorScheme: 'auto', colorScheme: 'auto',
aiAuthorized: false,
persisted: false persisted: false
}) })
expect(mockedApiJson).not.toHaveBeenCalled() expect(mockedApiJson).not.toHaveBeenCalled()
@@ -36,6 +37,7 @@ describe('appearancePrefs', () => {
mockedApiJson.mockResolvedValueOnce({ mockedApiJson.mockResolvedValueOnce({
theme: 'ocean', theme: 'ocean',
colorScheme: 'dark', colorScheme: 'dark',
aiAuthorized: true,
persisted: true persisted: true
}) })
@@ -46,6 +48,7 @@ describe('appearancePrefs', () => {
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean') expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean')
expect(localStorage.getItem(`user_pref_color_scheme_${USER_ID}`)).toBe('dark') expect(localStorage.getItem(`user_pref_color_scheme_${USER_ID}`)).toBe('dark')
expect(localStorage.getItem(`user_pref_ai_authorized_${USER_ID}`)).toBe('true')
expect(changed).toHaveBeenCalledTimes(1) expect(changed).toHaveBeenCalledTimes(1)
}) })
@@ -53,20 +56,20 @@ describe('appearancePrefs', () => {
localStorage.setItem('active_userid', USER_ID) localStorage.setItem('active_userid', USER_ID)
setThemePreference(USER_ID, 'material') setThemePreference(USER_ID, 'material')
mockedApiJson mockedApiJson
.mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', persisted: false }) .mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', aiAuthorized: false, persisted: false })
.mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', persisted: true }) .mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', aiAuthorized: false, persisted: true })
await syncAppearancePrefs(USER_ID) await syncAppearancePrefs(USER_ID)
expect(mockedApiJson).toHaveBeenCalledTimes(2) expect(mockedApiJson).toHaveBeenCalledTimes(2)
expect(mockedApiJson).toHaveBeenLastCalledWith('/api/auth/appearance-prefs', { expect(mockedApiJson).toHaveBeenLastCalledWith('/api/auth/appearance-prefs', {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ theme: 'material', colorScheme: 'auto' }) body: JSON.stringify({ theme: 'material', colorScheme: 'auto', aiAuthorized: false })
}) })
}) })
it('saveAppearancePrefsToServer skips when not authenticated', async () => { it('saveAppearancePrefsToServer skips when not authenticated', async () => {
await saveAppearancePrefsToServer('ocean', 'light') await saveAppearancePrefsToServer('ocean', 'light', true)
expect(mockedApiJson).not.toHaveBeenCalled() expect(mockedApiJson).not.toHaveBeenCalled()
}) })
@@ -76,6 +79,7 @@ describe('appearancePrefs', () => {
mockedApiJson.mockResolvedValue({ mockedApiJson.mockResolvedValue({
theme: 'material', theme: 'material',
colorScheme: 'dark', colorScheme: 'dark',
aiAuthorized: false,
persisted: true persisted: true
}) })
+16 -5
View File
@@ -5,7 +5,9 @@ import {
getColorSchemePreference, getColorSchemePreference,
getThemePreference, getThemePreference,
setColorSchemePreference, setColorSchemePreference,
setThemePreference setThemePreference,
getAiAuthorized,
setAiAuthorized
} from './userPreferences.js' } from './userPreferences.js'
const API_BASE = '/api/auth/appearance-prefs' const API_BASE = '/api/auth/appearance-prefs'
@@ -13,13 +15,15 @@ const API_BASE = '/api/auth/appearance-prefs'
export interface AppearancePrefs { export interface AppearancePrefs {
theme: string theme: string
colorScheme: string colorScheme: string
aiAuthorized: boolean
persisted: boolean persisted: boolean
} }
function hasLocalAppearancePrefs(userId: string): boolean { function hasLocalAppearancePrefs(userId: string): boolean {
return ( return (
localStorage.getItem(`user_pref_theme_${userId}`) != null || localStorage.getItem(`user_pref_theme_${userId}`) != null ||
localStorage.getItem(`user_pref_color_scheme_${userId}`) != null localStorage.getItem(`user_pref_color_scheme_${userId}`) != null ||
localStorage.getItem(`user_pref_ai_authorized_${userId}`) != null
) )
} }
@@ -35,7 +39,7 @@ function resolveSyncedUserId(userId?: string | null): string | null {
export async function fetchAppearancePrefs(userId?: string | null): Promise<AppearancePrefs> { export async function fetchAppearancePrefs(userId?: string | null): Promise<AppearancePrefs> {
if (!resolveSyncedUserId(userId)) { if (!resolveSyncedUserId(userId)) {
return { theme: 'auto', colorScheme: 'auto', persisted: false } return { theme: 'auto', colorScheme: 'auto', aiAuthorized: false, persisted: false }
} }
return apiJson<AppearancePrefs>(API_BASE) return apiJson<AppearancePrefs>(API_BASE)
@@ -44,13 +48,14 @@ export async function fetchAppearancePrefs(userId?: string | null): Promise<Appe
export async function saveAppearancePrefsToServer( export async function saveAppearancePrefsToServer(
theme: string, theme: string,
colorScheme: string, colorScheme: string,
aiAuthorized: boolean,
userId?: string | null userId?: string | null
): Promise<void> { ): Promise<void> {
if (!resolveSyncedUserId(userId)) return if (!resolveSyncedUserId(userId)) return
await apiJson<AppearancePrefs>(API_BASE, { await apiJson<AppearancePrefs>(API_BASE, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ theme, colorScheme }) body: JSON.stringify({ theme, colorScheme, aiAuthorized })
}) })
} }
@@ -65,8 +70,14 @@ export async function syncAppearancePrefs(userId?: string | null): Promise<void>
if (server.persisted) { if (server.persisted) {
setThemePreference(id, server.theme) setThemePreference(id, server.theme)
setColorSchemePreference(id, server.colorScheme) setColorSchemePreference(id, server.colorScheme)
setAiAuthorized(id, server.aiAuthorized)
} else if (hasLocalAppearancePrefs(id)) { } else if (hasLocalAppearancePrefs(id)) {
await saveAppearancePrefsToServer(getThemePreference(id), getColorSchemePreference(id), id) await saveAppearancePrefsToServer(
getThemePreference(id),
getColorSchemePreference(id),
getAiAuthorized(id),
id
)
} }
} catch (err) { } catch (err) {
console.warn('Failed to sync appearance preferences:', err) console.warn('Failed to sync appearance preferences:', err)
+10
View File
@@ -64,6 +64,15 @@ export function persistSessionUserId(userId: string | undefined): void {
} }
} }
/** Username to use when re-unlocking after reload (active account or sole remembered user). */
export function resolveRestoreUsername(): string | null {
const stored = localStorage.getItem('active_username')
if (stored) return stored
const known = getKnownUsernames()
if (known.length === 1) return known[0]
return null
}
export async function reauthWithPasskey(): Promise<boolean> { export async function reauthWithPasskey(): Promise<boolean> {
const options = await apiJson<any>(`${API_BASE}/reauth-options`, { const options = await apiJson<any>(`${API_BASE}/reauth-options`, {
method: 'POST' method: 'POST'
@@ -556,6 +565,7 @@ export async function deleteAccount(): Promise<boolean> {
db.deviations.clear(), db.deviations.clear(),
db.entries.clear(), db.entries.clear(),
db.photos.clear(), db.photos.clear(),
db.voiceMemos.clear(),
db.gpsTracks.clear(), db.gpsTracks.clear(),
db.syncQueue.clear(), db.syncQueue.clear(),
db.logbookKeys.clear(), db.logbookKeys.clear(),
+23
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
import { import {
hasUnlockedLocalCrypto, hasUnlockedLocalCrypto,
hasUnlockedLocalSession, hasUnlockedLocalSession,
resolveRestoreUsername,
setActiveMasterKey setActiveMasterKey
} from './auth.js' } from './auth.js'
@@ -33,6 +34,28 @@ describe('local session unlock checks', () => {
}) })
}) })
describe('resolveRestoreUsername', () => {
beforeEach(() => {
localStorage.clear()
})
it('prefers active_username from storage', () => {
localStorage.setItem('active_username', 'captain')
localStorage.setItem('daagbox_known_users', JSON.stringify(['other']))
expect(resolveRestoreUsername()).toBe('captain')
})
it('falls back to a single remembered user', () => {
localStorage.setItem('daagbox_known_users', JSON.stringify(['solo']))
expect(resolveRestoreUsername()).toBe('solo')
})
it('returns null when multiple users and no active username', () => {
localStorage.setItem('daagbox_known_users', JSON.stringify(['alpha', 'beta']))
expect(resolveRestoreUsername()).toBeNull()
})
})
describe('persistSessionUserId', () => { describe('persistSessionUserId', () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear() localStorage.clear()
+18 -6
View File
@@ -74,10 +74,10 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
// Headers matching the requested event fields & metadata // Headers matching the requested event fields & metadata
const headers = [ const headers = [
'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'AI Summary',
'Skipper Signature', 'Crew Signature', 'Skipper Signature', 'Crew Signature',
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)', 'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
'Event Time', 'MgK Course', 'RwK Course', 'Event Time', 'Event Creator', 'MgK Course', 'RwK Course',
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Visibility', 'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Visibility',
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)', 'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
'Latitude', 'Longitude', 'Remarks', 'Latitude', 'Longitude', 'Remarks',
@@ -120,15 +120,17 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const fuelE = entry.fuel?.evening ?? ''; const fuelE = entry.fuel?.evening ?? '';
const fuelCons = entry.fuel?.consumption ?? ''; const fuelCons = entry.fuel?.consumption ?? '';
const greywaterLevel = entry.greywater?.level ?? ''; const greywaterLevel = entry.greywater?.level ?? '';
const aiSummary = entry.aiSummary ?? '';
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {};
const eventsList = entry.events || []; const eventsList = entry.events || [];
if (eventsList.length === 0) { if (eventsList.length === 0) {
// Create one row even if there are no events for the day // Create one row even if there are no events for the day
rows.push([ rows.push([
dateVal, travelDay, dep, dest, dateVal, travelDay, dep, dest, aiSummary,
signS, signC, signS, signC,
trackDist, trackMax, trackAvg, motorH, trackDist, trackMax, trackAvg, motorH,
'', '', '', '', '', '', '',
'', '', '', '', '', '', '', '', '', '',
'', '', '', '', '', '', '', '', '', '',
'', '', '', '', '', '',
@@ -141,11 +143,21 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
// Sort events chronologically by time // Sort events chronologically by time
const sortedEvents = sortLogEventsByTime(eventsList); const sortedEvents = sortLogEventsByTime(eventsList);
for (const ev of sortedEvents) { 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([ rows.push([
dateVal, travelDay, dep, dest, dateVal, travelDay, dep, dest, aiSummary,
signS, signC, signS, signC,
trackDist, trackMax, trackAvg, motorH, trackDist, trackMax, trackAvg, motorH,
ev.time || '', ev.mgk || '', ev.rwk || '', ev.time || '', creatorName, ev.mgk || '', ev.rwk || '',
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '', ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
ev.visibility || '', ev.visibility || '',
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '', ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
+41
View File
@@ -35,6 +35,14 @@ export interface LocalDeviation {
updatedAt: string updatedAt: string
} }
export interface EntryListCache {
date: string
dayOfTravel: string
departure: string
destination: string
skipperSignStatus: 'none' | 'valid' | 'invalid'
}
export interface LocalEntry { export interface LocalEntry {
payloadId: string payloadId: string
logbookId: string logbookId: string
@@ -42,6 +50,8 @@ export interface LocalEntry {
iv: string iv: string
tag: string tag: string
updatedAt: string updatedAt: string
/** Plaintext list fields — avoids full decrypt when opening the journal list. */
listCache?: EntryListCache
} }
export interface LocalPhoto { export interface LocalPhoto {
@@ -55,6 +65,16 @@ export interface LocalPhoto {
updatedAt: string updatedAt: string
} }
export interface LocalVoiceMemo {
payloadId: string
entryId: string
logbookId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface LocalGpsTrack { export interface LocalGpsTrack {
entryId: string // one track per daily journal entry entryId: string // one track per daily journal entry
logbookId: string logbookId: string
@@ -122,6 +142,7 @@ export interface SyncQueueItem {
| 'entry' | 'entry'
| 'logbook' | 'logbook'
| 'photo' | 'photo'
| 'voiceMemo'
| 'gpsTrack' | 'gpsTrack'
| 'logbookCrew' | 'logbookCrew'
| 'logbookVessel' | 'logbookVessel'
@@ -156,6 +177,7 @@ class DaagboxDatabase extends Dexie {
deviations!: Table<LocalDeviation> deviations!: Table<LocalDeviation>
entries!: Table<LocalEntry> entries!: Table<LocalEntry>
photos!: Table<LocalPhoto> photos!: Table<LocalPhoto>
voiceMemos!: Table<LocalVoiceMemo>
gpsTracks!: Table<LocalGpsTrack> gpsTracks!: Table<LocalGpsTrack>
nmeaArchives!: Table<LocalNmeaArchive> nmeaArchives!: Table<LocalNmeaArchive>
logbookKeys!: Table<LocalLogbookKey> logbookKeys!: Table<LocalLogbookKey>
@@ -279,6 +301,25 @@ class DaagboxDatabase extends Dexie {
userSyncQueue: '++id, action, type, payloadId', userSyncQueue: '++id, action, type, payloadId',
entryDrafts: '[logbookId+entryId], updatedAt' entryDrafts: '[logbookId+entryId], updatedAt'
}) })
this.version(10).stores({
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
yachts: 'logbookId, updatedAt',
crews: 'payloadId, logbookId, updatedAt',
deviations: 'logbookId, updatedAt',
entries: 'payloadId, logbookId, updatedAt',
syncQueue: '++id, action, type, payloadId, logbookId',
photos: 'payloadId, entryId, logbookId, updatedAt',
voiceMemos: 'payloadId, entryId, logbookId, updatedAt',
gpsTracks: 'entryId, logbookId, updatedAt',
nmeaArchives: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId',
personPool: 'payloadId, updatedAt',
vesselPool: 'payloadId, updatedAt',
logbookCrewSelections: 'logbookId, updatedAt',
logbookVesselSelections: 'logbookId, updatedAt',
userSyncQueue: '++id, action, type, payloadId',
entryDrafts: '[logbookId+entryId], updatedAt'
})
} }
} }
+12 -8
View File
@@ -4,6 +4,7 @@ import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js' import { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js' import { encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js' import { syncLogbook } from './sync.js'
import { putEntryRecord } from '../utils/entryListCache.js'
import { syncPersonPool } from './personPoolSync.js' import { syncPersonPool } from './personPoolSync.js'
import i18n from '../i18n/index.js' import i18n from '../i18n/index.js'
import type { PersonData } from '../types/person.js' import type { PersonData } from '../types/person.js'
@@ -35,14 +36,17 @@ async function putEncryptedRecord(
const encrypted = await encryptJson(data, key) const encrypted = await encryptJson(data, key)
if (type === 'entry') { if (type === 'entry') {
await db.entries.put({ await putEntryRecord(
payloadId, {
logbookId, payloadId,
encryptedData: encrypted.ciphertext, logbookId,
iv: encrypted.iv, encryptedData: encrypted.ciphertext,
tag: encrypted.tag, iv: encrypted.iv,
updatedAt: now tag: encrypted.tag,
}) updatedAt: now
},
data as Record<string, unknown>
)
} else if (type === 'yacht') { } else if (type === 'yacht') {
await db.yachts.put({ await db.yachts.put({
logbookId, logbookId,
+21 -2
View File
@@ -34,6 +34,8 @@ export interface DecryptedLogbook {
isShared: boolean isShared: boolean
accessRole: LogbookAccessRole accessRole: LogbookAccessRole
isDemo?: boolean isDemo?: boolean
lastTravelDate?: string
entryCount?: number
} }
// Helper to decrypt a logbook's title using the active logbook key or master key // Helper to decrypt a logbook's title using the active logbook key or master key
@@ -142,10 +144,24 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
// Retrieve all from Dexie cache // Retrieve all from Dexie cache
const cachedLogbooks = await db.logbooks.toArray() const cachedLogbooks = await db.logbooks.toArray()
// Decrypt titles // Decrypt titles and query last travel dates
const decrypted: DecryptedLogbook[] = [] const decrypted: DecryptedLogbook[] = []
for (const lb of cachedLogbooks) { for (const lb of cachedLogbooks) {
const title = await decryptLogbookTitle(lb.id, lb.encryptedTitle) const title = await decryptLogbookTitle(lb.id, lb.encryptedTitle)
// Find latest travel date from local entries cache
const entries = await db.entries.where({ logbookId: lb.id }).toArray()
let lastTravelDate: string | undefined = undefined
if (entries.length > 0) {
const dates = entries
.map((e) => e.listCache?.date)
.filter((d): d is string => typeof d === 'string' && d.length > 0)
if (dates.length > 0) {
dates.sort()
lastTravelDate = dates[dates.length - 1]
}
}
decrypted.push({ decrypted.push({
id: lb.id, id: lb.id,
title, title,
@@ -155,7 +171,9 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
accessRole: lb.isShared === 1 accessRole: lb.isShared === 1
? parseCollaborationRole(lb.collaborationRole, `cached logbook ${lb.id}`) ? parseCollaborationRole(lb.collaborationRole, `cached logbook ${lb.id}`)
: 'OWNER', : 'OWNER',
isDemo: lb.isDemo === 1 isDemo: lb.isDemo === 1,
lastTravelDate,
entryCount: entries.length
}) })
} }
@@ -283,6 +301,7 @@ export async function deleteLocalLogbookCache(id: string): Promise<void> {
await db.deviations.where({ logbookId: id }).delete() await db.deviations.where({ logbookId: id }).delete()
await db.entries.where({ logbookId: id }).delete() await db.entries.where({ logbookId: id }).delete()
await db.photos.where({ logbookId: id }).delete() await db.photos.where({ logbookId: id }).delete()
await db.voiceMemos.where({ logbookId: id }).delete()
await db.gpsTracks.where({ logbookId: id }).delete() await db.gpsTracks.where({ logbookId: id }).delete()
await db.syncQueue.where({ logbookId: id }).delete() await db.syncQueue.where({ logbookId: id }).delete()
await db.logbookKeys.where({ logbookId: id }).delete() await db.logbookKeys.where({ logbookId: id }).delete()
+360 -311
View File
@@ -1,3 +1,4 @@
import { formatAppDecimal } from '../utils/numberFormat.js'
import { db } from './db.js' import { db } from './db.js'
import { getActiveMasterKey } from './auth.js' import { getActiveMasterKey } from './auth.js'
import { import {
@@ -9,89 +10,54 @@ import { decryptLogbookTitle, deleteLocalLogbookCache } from './logbook.js'
import { ensureLogbookKey, getLogbookKey, saveLogbookKey } from './logbookKeys.js' import { ensureLogbookKey, getLogbookKey, saveLogbookKey } from './logbookKeys.js'
import { syncLogbook } from './sync.js' import { syncLogbook } from './sync.js'
import type { SyncQueueItem } from './db.js' import type { SyncQueueItem } from './db.js'
import { getAppVersion } from './pwaVersion.js'
import { dexieFieldsFromEncBytes, encBytesFromDexieFields } from './logbookBackup/encBlob.js'
import {
BACKUP_FORMAT,
BACKUP_VERSION,
type BackupManifestCounts,
type BackupManifestV2,
type LogbookMetaJson
} from './logbookBackup/manifest.js'
import {
buildArchiveFromCollected,
collectLogbookBackupData,
type BackupExportProgress
} from './logbookBackup/collector.js'
import {
isZipArchive,
readBinaryFile,
readManifestFromArchive,
readTextFile,
unzipArchive
} from './logbookBackup/zipArchive.js'
export const BACKUP_FORMAT = 'kapteins-daagbok-backup' as const export { BACKUP_FORMAT, BACKUP_VERSION }
export const BACKUP_VERSION = 1 as const export type { BackupExportProgress, BackupManifestCounts, BackupManifestV2 }
export interface LogbookBackupFile {
format: typeof BACKUP_FORMAT
version: typeof BACKUP_VERSION
exportedAt: string
logbook: {
id: string
encryptedTitle: string
updatedAt: string
isDemo?: boolean
}
logbookKey: {
ciphertext: string
iv: string
tag: string
}
payloads: {
yacht: {
encryptedData: string
iv: string
tag: string
updatedAt: string
} | null
deviation: {
encryptedData: string
iv: string
tag: string
updatedAt: string
} | null
crews: Array<{
payloadId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}>
entries: Array<{
payloadId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}>
photos: Array<{
payloadId: string
entryId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}>
gpsTracks: Array<{
entryId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}>
}
counts: {
entries: number
photos: number
crews: number
gpsTracks: number
hasYacht: boolean
hasDeviation: boolean
}
}
export interface LogbookBackupPreview { export interface LogbookBackupPreview {
title: string title: string
exportedAt: string exportedAt: string
sourceLogbookId: string sourceLogbookId: string
counts: LogbookBackupFile['counts'] counts: BackupManifestCounts
totalUncompressedBytes: number
} }
export interface ParsedLogbookBackup {
manifest: BackupManifestV2
files: Record<string, Uint8Array>
}
export interface ExportLogbookBackupOptions {
onProgress?: (progress: BackupExportProgress) => void
}
const BACKUP_PASSPHRASE_SALT = 'KapteinsDaagbokBackupFileSalt_v1'
async function deriveBackupPassphraseKey(passphrase: string): Promise<CryptoKey> { async function deriveBackupPassphraseKey(passphrase: string): Promise<CryptoKey> {
const encoder = new TextEncoder() const encoder = new TextEncoder()
const passphraseBytes = encoder.encode(passphrase.trim()) const passphraseBytes = encoder.encode(passphrase.trim())
const saltBytes = encoder.encode('KapteinsDaagbokBackupFileSalt_v1') const saltBytes = encoder.encode(BACKUP_PASSPHRASE_SALT)
const baseKey = await window.crypto.subtle.importKey( const baseKey = await window.crypto.subtle.importKey(
'raw', 'raw',
@@ -120,26 +86,17 @@ async function wrapLogbookKey(logbookKey: ArrayBuffer, passphrase: string) {
return encryptBuffer(logbookKey, key) return encryptBuffer(logbookKey, key)
} }
async function unwrapLogbookKey( async function unwrapLogbookKeyFromEnc(
wrapped: LogbookBackupFile['logbookKey'], keyEnc: Uint8Array,
passphrase: string passphrase: string
): Promise<ArrayBuffer> { ): Promise<ArrayBuffer> {
const key = await deriveBackupPassphraseKey(passphrase) try {
return decryptBuffer(wrapped.ciphertext, wrapped.iv, wrapped.tag, key) const fields = dexieFieldsFromEncBytes(keyEnc)
} const cryptoKey = await deriveBackupPassphraseKey(passphrase)
return decryptBuffer(fields.encryptedData, fields.iv, fields.tag, cryptoKey)
function isBackupFile(value: unknown): value is LogbookBackupFile { } catch {
if (!value || typeof value !== 'object') return false throw new Error('BACKUP_WRONG_PASSPHRASE')
const obj = value as Partial<LogbookBackupFile> }
return (
obj.format === BACKUP_FORMAT &&
obj.version === BACKUP_VERSION &&
typeof obj.exportedAt === 'string' &&
!!obj.logbook?.id &&
!!obj.logbook?.encryptedTitle &&
!!obj.logbookKey?.ciphertext &&
!!obj.payloads
)
} }
function encryptedPayloadData( function encryptedPayloadData(
@@ -156,96 +113,12 @@ function encryptedPayloadData(
}) })
} }
async function collectLogbookPayloads(logbookId: string): Promise<LogbookBackupFile['payloads']> {
const [yacht, deviation, crews, entries, photos, gpsTracks] = await Promise.all([
db.yachts.get(logbookId),
db.deviations.get(logbookId),
db.crews.where({ logbookId }).toArray(),
db.entries.where({ logbookId }).toArray(),
db.photos.where({ logbookId }).toArray(),
db.gpsTracks.where({ logbookId }).toArray()
])
return {
yacht: yacht
? {
encryptedData: yacht.encryptedData,
iv: yacht.iv,
tag: yacht.tag,
updatedAt: yacht.updatedAt
}
: null,
deviation: deviation
? {
encryptedData: deviation.encryptedData,
iv: deviation.iv,
tag: deviation.tag,
updatedAt: deviation.updatedAt
}
: null,
crews: crews.map((c) => ({
payloadId: c.payloadId,
encryptedData: c.encryptedData,
iv: c.iv,
tag: c.tag,
updatedAt: c.updatedAt
})),
entries: entries.map((e) => ({
payloadId: e.payloadId,
encryptedData: e.encryptedData,
iv: e.iv,
tag: e.tag,
updatedAt: e.updatedAt
})),
photos: photos.map((p) => ({
payloadId: p.payloadId,
entryId: p.entryId,
encryptedData: p.encryptedData,
iv: p.iv,
tag: p.tag,
updatedAt: p.updatedAt
})),
gpsTracks: gpsTracks.map((t) => ({
entryId: t.entryId,
encryptedData: t.encryptedData,
iv: t.iv,
tag: t.tag,
updatedAt: t.updatedAt
}))
}
}
function remapBackup(
backup: LogbookBackupFile,
newLogbookId: string
): LogbookBackupFile {
return {
...backup,
logbook: {
...backup.logbook,
id: newLogbookId
},
payloads: {
...backup.payloads,
yacht: backup.payloads.yacht
? { ...backup.payloads.yacht, updatedAt: backup.payloads.yacht.updatedAt }
: null,
deviation: backup.payloads.deviation
? { ...backup.payloads.deviation, updatedAt: backup.payloads.deviation.updatedAt }
: null,
crews: backup.payloads.crews.map((c) => ({ ...c })),
entries: backup.payloads.entries.map((e) => ({ ...e })),
photos: backup.payloads.photos.map((p) => ({ ...p })),
gpsTracks: backup.payloads.gpsTracks.map((t) => ({ ...t }))
}
}
}
async function queueRestoredLogbookForSync( async function queueRestoredLogbookForSync(
logbookId: string, logbookId: string,
encryptedTitle: string, encryptedTitle: string,
logbookKey: ArrayBuffer, logbookKey: ArrayBuffer,
payloads: LogbookBackupFile['payloads'] manifest: BackupManifestV2,
files: Record<string, Uint8Array>
): Promise<void> { ): Promise<void> {
const masterKey = getActiveMasterKey() const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found') if (!masterKey) throw new Error('Master key not found')
@@ -276,78 +149,123 @@ async function queueRestoredLogbookForSync(
} }
] ]
if (payloads.yacht) { const readFields = (path: string | null) => {
if (!path) return null
return dexieFieldsFromEncBytes(readBinaryFile(files, path))
}
const yacht = readFields(manifest.files.yacht)
if (yacht) {
items.push({ items.push({
action: 'update', action: 'update',
type: 'yacht', type: 'yacht',
payloadId: logbookId, payloadId: logbookId,
logbookId, logbookId,
data: encryptedPayloadData( data: encryptedPayloadData(yacht.encryptedData, yacht.iv, yacht.tag),
payloads.yacht.encryptedData, updatedAt: now
payloads.yacht.iv,
payloads.yacht.tag
),
updatedAt: payloads.yacht.updatedAt
}) })
} }
if (payloads.deviation) { const deviation = readFields(manifest.files.deviation)
if (deviation) {
items.push({ items.push({
action: 'update', action: 'update',
type: 'deviation', type: 'deviation',
payloadId: logbookId, payloadId: logbookId,
logbookId, logbookId,
data: encryptedPayloadData( data: encryptedPayloadData(deviation.encryptedData, deviation.iv, deviation.tag),
payloads.deviation.encryptedData, updatedAt: now
payloads.deviation.iv,
payloads.deviation.tag
),
updatedAt: payloads.deviation.updatedAt
}) })
} }
for (const crew of payloads.crews) { const logbookCrew = readFields(manifest.files.logbookCrewSelection)
if (logbookCrew) {
items.push({
action: 'update',
type: 'logbookCrew',
payloadId: logbookId,
logbookId,
data: encryptedPayloadData(logbookCrew.encryptedData, logbookCrew.iv, logbookCrew.tag),
updatedAt: now
})
}
const logbookVessel = readFields(manifest.files.logbookVesselSelection)
if (logbookVessel) {
items.push({
action: 'update',
type: 'logbookVessel',
payloadId: logbookId,
logbookId,
data: encryptedPayloadData(
logbookVessel.encryptedData,
logbookVessel.iv,
logbookVessel.tag
),
updatedAt: now
})
}
for (const crew of manifest.files.crews) {
const f = readFields(crew.path)
items.push({ items.push({
action: 'create', action: 'create',
type: 'crew', type: 'crew',
payloadId: crew.payloadId, payloadId: crew.payloadId,
logbookId, logbookId,
data: encryptedPayloadData(crew.encryptedData, crew.iv, crew.tag), data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
updatedAt: crew.updatedAt updatedAt: crew.updatedAt
}) })
} }
for (const entry of payloads.entries) { for (const entry of manifest.files.entries) {
const f = readFields(entry.path)
items.push({ items.push({
action: 'create', action: 'create',
type: 'entry', type: 'entry',
payloadId: entry.payloadId, payloadId: entry.payloadId,
logbookId, logbookId,
data: encryptedPayloadData(entry.encryptedData, entry.iv, entry.tag), data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
updatedAt: entry.updatedAt updatedAt: entry.updatedAt
}) })
} }
for (const photo of payloads.photos) { for (const photo of manifest.files.photos) {
const f = readFields(photo.path)
items.push({ items.push({
action: 'create', action: 'create',
type: 'photo', type: 'photo',
payloadId: photo.payloadId, payloadId: photo.payloadId,
logbookId, logbookId,
data: encryptedPayloadData(photo.encryptedData, photo.iv, photo.tag, { data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag, {
entryId: photo.entryId entryId: photo.entryId
}), }),
updatedAt: photo.updatedAt updatedAt: photo.updatedAt
}) })
} }
for (const track of payloads.gpsTracks) { for (const voice of manifest.files.voiceMemos) {
const f = readFields(voice.path)
items.push({
action: 'create',
type: 'voiceMemo',
payloadId: voice.payloadId,
logbookId,
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag, {
entryId: voice.entryId
}),
updatedAt: voice.updatedAt
})
}
for (const track of manifest.files.gpsTracks) {
const f = readFields(track.path)
items.push({ items.push({
action: 'create', action: 'create',
type: 'gpsTrack', type: 'gpsTrack',
payloadId: track.entryId, payloadId: track.entryId,
logbookId, logbookId,
data: encryptedPayloadData(track.encryptedData, track.iv, track.tag), data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
updatedAt: track.updatedAt updatedAt: track.updatedAt
}) })
} }
@@ -357,101 +275,190 @@ async function queueRestoredLogbookForSync(
async function writeBackupToDexie( async function writeBackupToDexie(
logbookId: string, logbookId: string,
backup: LogbookBackupFile, logbookMeta: LogbookMetaJson,
logbookKey: ArrayBuffer logbookKey: ArrayBuffer,
manifest: BackupManifestV2,
files: Record<string, Uint8Array>
): Promise<void> { ): Promise<void> {
const { logbook, payloads } = backup
await db.logbooks.put({ await db.logbooks.put({
id: logbookId, id: logbookId,
encryptedTitle: logbook.encryptedTitle, encryptedTitle: logbookMeta.encryptedTitle,
updatedAt: logbook.updatedAt, updatedAt: logbookMeta.updatedAt,
isSynced: 0, isSynced: 0,
isShared: 0, isShared: 0,
isDemo: logbook.isDemo ? 1 : 0 isDemo: logbookMeta.isDemo ? 1 : 0
}) })
await saveLogbookKey(logbookId, logbookKey) await saveLogbookKey(logbookId, logbookKey)
if (payloads.yacht) { const readFields = (path: string | null) => {
if (!path) return null
return dexieFieldsFromEncBytes(readBinaryFile(files, path))
}
const yacht = readFields(manifest.files.yacht)
if (yacht) {
await db.yachts.put({ await db.yachts.put({
logbookId, logbookId,
encryptedData: payloads.yacht.encryptedData, encryptedData: yacht.encryptedData,
iv: payloads.yacht.iv, iv: yacht.iv,
tag: payloads.yacht.tag, tag: yacht.tag,
updatedAt: payloads.yacht.updatedAt updatedAt: logbookMeta.updatedAt
}) })
} }
if (payloads.deviation) { const deviation = readFields(manifest.files.deviation)
if (deviation) {
await db.deviations.put({ await db.deviations.put({
logbookId, logbookId,
encryptedData: payloads.deviation.encryptedData, encryptedData: deviation.encryptedData,
iv: payloads.deviation.iv, iv: deviation.iv,
tag: payloads.deviation.tag, tag: deviation.tag,
updatedAt: payloads.deviation.updatedAt updatedAt: logbookMeta.updatedAt
}) })
} }
if (payloads.crews.length > 0) { const logbookCrew = readFields(manifest.files.logbookCrewSelection)
if (logbookCrew) {
await db.logbookCrewSelections.put({
logbookId,
encryptedData: logbookCrew.encryptedData,
iv: logbookCrew.iv,
tag: logbookCrew.tag,
updatedAt: logbookMeta.updatedAt
})
}
const logbookVessel = readFields(manifest.files.logbookVesselSelection)
if (logbookVessel) {
await db.logbookVesselSelections.put({
logbookId,
encryptedData: logbookVessel.encryptedData,
iv: logbookVessel.iv,
tag: logbookVessel.tag,
updatedAt: logbookMeta.updatedAt
})
}
if (manifest.files.crews.length > 0) {
await db.crews.bulkPut( await db.crews.bulkPut(
payloads.crews.map((c) => ({ manifest.files.crews.map((c) => {
payloadId: c.payloadId, const f = dexieFieldsFromEncBytes(readBinaryFile(files, c.path))
logbookId, return {
encryptedData: c.encryptedData, payloadId: c.payloadId,
iv: c.iv, logbookId,
tag: c.tag, encryptedData: f.encryptedData,
updatedAt: c.updatedAt iv: f.iv,
})) tag: f.tag,
updatedAt: c.updatedAt
}
})
) )
} }
if (payloads.entries.length > 0) { if (manifest.files.entries.length > 0) {
await db.entries.bulkPut( await db.entries.bulkPut(
payloads.entries.map((e) => ({ manifest.files.entries.map((e) => {
payloadId: e.payloadId, const f = dexieFieldsFromEncBytes(readBinaryFile(files, e.path))
logbookId, return {
encryptedData: e.encryptedData, payloadId: e.payloadId,
iv: e.iv, logbookId,
tag: e.tag, encryptedData: f.encryptedData,
updatedAt: e.updatedAt iv: f.iv,
})) tag: f.tag,
updatedAt: e.updatedAt
}
})
) )
} }
if (payloads.photos.length > 0) { if (manifest.files.photos.length > 0) {
await db.photos.bulkPut( await db.photos.bulkPut(
payloads.photos.map((p) => ({ manifest.files.photos.map((p) => {
payloadId: p.payloadId, const f = dexieFieldsFromEncBytes(readBinaryFile(files, p.path))
entryId: p.entryId, return {
logbookId, payloadId: p.payloadId,
encryptedData: p.encryptedData, entryId: p.entryId,
iv: p.iv, logbookId,
tag: p.tag, encryptedData: f.encryptedData,
caption: '', iv: f.iv,
updatedAt: p.updatedAt tag: f.tag,
})) caption: '',
updatedAt: p.updatedAt
}
})
) )
} }
if (payloads.gpsTracks.length > 0) { if (manifest.files.voiceMemos.length > 0) {
await db.gpsTracks.bulkPut( await db.voiceMemos.bulkPut(
payloads.gpsTracks.map((t) => ({ manifest.files.voiceMemos.map((v) => {
entryId: t.entryId, const f = dexieFieldsFromEncBytes(readBinaryFile(files, v.path))
logbookId, return {
encryptedData: t.encryptedData, payloadId: v.payloadId,
iv: t.iv, entryId: v.entryId,
tag: t.tag, logbookId,
updatedAt: t.updatedAt encryptedData: f.encryptedData,
})) iv: f.iv,
tag: f.tag,
updatedAt: v.updatedAt
}
})
) )
} }
if (manifest.files.gpsTracks.length > 0) {
await db.gpsTracks.bulkPut(
manifest.files.gpsTracks.map((t) => {
const f = dexieFieldsFromEncBytes(readBinaryFile(files, t.path))
return {
entryId: t.entryId,
logbookId,
encryptedData: f.encryptedData,
iv: f.iv,
tag: f.tag,
updatedAt: t.updatedAt
}
})
)
}
if (manifest.files.nmeaArchives.length > 0) {
await db.nmeaArchives.bulkPut(
manifest.files.nmeaArchives.map((n) => {
const f = dexieFieldsFromEncBytes(readBinaryFile(files, n.path))
return {
entryId: n.entryId,
logbookId,
encryptedData: f.encryptedData,
iv: f.iv,
tag: f.tag,
updatedAt: n.updatedAt
}
})
)
}
}
function remapParsedBackup(
parsed: ParsedLogbookBackup,
newLogbookId: string
): ParsedLogbookBackup {
const logbookMeta = JSON.parse(readTextFile(parsed.files, parsed.manifest.files.logbook)) as LogbookMetaJson
logbookMeta.id = newLogbookId
const newFiles = { ...parsed.files }
newFiles[parsed.manifest.files.logbook] = new TextEncoder().encode(JSON.stringify(logbookMeta))
return {
manifest: { ...parsed.manifest, logbookId: newLogbookId },
files: newFiles
}
} }
export async function exportLogbookBackup( export async function exportLogbookBackup(
logbookId: string, logbookId: string,
passphrase: string passphrase: string,
): Promise<{ blob: Blob; filename: string; backup: LogbookBackupFile }> { options: ExportLogbookBackupOptions = {}
): Promise<{ blob: Blob; filename: string; manifest: BackupManifestV2 }> {
if (!passphrase.trim() || passphrase.length < 8) { if (!passphrase.trim() || passphrase.length < 8) {
throw new Error('BACKUP_PASSPHRASE_TOO_SHORT') throw new Error('BACKUP_PASSPHRASE_TOO_SHORT')
} }
@@ -467,70 +474,84 @@ export async function exportLogbookBackup(
}) })
} }
options.onProgress?.({ phase: 'collect', current: 0, total: 1, bytesPacked: 0 })
const collected = await collectLogbookBackupData(logbookId)
const logbookKey = (await getLogbookKey(logbookId)) ?? (await ensureLogbookKey(logbookId)) const logbookKey = (await getLogbookKey(logbookId)) ?? (await ensureLogbookKey(logbookId))
const payloads = await collectLogbookPayloads(logbookId) const wrapped = await wrapLogbookKey(logbookKey, passphrase)
const wrappedKey = await wrapLogbookKey(logbookKey, passphrase) const keyEnc = encBytesFromDexieFields({
encryptedData: wrapped.ciphertext,
iv: wrapped.iv,
tag: wrapped.tag
})
const backup: LogbookBackupFile = { const { zipBytes, manifest } = buildArchiveFromCollected(collected, keyEnc, {
format: BACKUP_FORMAT,
version: BACKUP_VERSION,
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
logbook: { appVersion: getAppVersion(),
id: logbook.id, onProgress: options.onProgress
encryptedTitle: logbook.encryptedTitle, })
updatedAt: logbook.updatedAt,
isDemo: logbook.isDemo === 1
},
logbookKey: wrappedKey,
payloads,
counts: {
entries: payloads.entries.length,
photos: payloads.photos.length,
crews: payloads.crews.length,
gpsTracks: payloads.gpsTracks.length,
hasYacht: !!payloads.yacht,
hasDeviation: !!payloads.deviation
}
}
const title = await decryptLogbookTitle(logbookId, logbook.encryptedTitle) const title = await decryptLogbookTitle(logbookId, logbook.encryptedTitle)
const safeTitle = title.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook' const safeTitle = title.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook'
const datePart = new Date().toISOString().slice(0, 10) const datePart = new Date().toISOString().slice(0, 10)
const filename = `${safeTitle}-${datePart}.daagbok.json` const filename = `${safeTitle}-${datePart}.daagbok`
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }) const blob = new Blob([zipBytes.slice()], { type: 'application/zip' })
return { blob, filename, backup } return { blob, filename, manifest }
} }
export async function parseLogbookBackupFile(file: File): Promise<LogbookBackupFile> { function detectLegacyJsonV1(text: string): boolean {
const text = await file.text() const trimmed = text.trimStart()
let parsed: unknown if (!trimmed.startsWith('{')) return false
try { try {
parsed = JSON.parse(text) const parsed = JSON.parse(trimmed) as { format?: string; version?: number }
return parsed.format === BACKUP_FORMAT && parsed.version === 1
} catch { } catch {
throw new Error('BACKUP_INVALID_JSON') return false
}
}
export async function parseLogbookBackupFile(file: File): Promise<ParsedLogbookBackup> {
const buffer = await file.arrayBuffer()
const bytes = new Uint8Array(buffer)
if (!isZipArchive(bytes)) {
const text = new TextDecoder().decode(bytes)
if (detectLegacyJsonV1(text)) {
throw new Error('BACKUP_VERSION_UNSUPPORTED')
}
throw new Error('BACKUP_INVALID_ARCHIVE')
} }
if (!isBackupFile(parsed)) { const files = unzipArchive(bytes)
throw new Error('BACKUP_INVALID_FORMAT') const manifest = readManifestFromArchive(files)
} return { manifest, files }
return parsed
} }
export async function previewLogbookBackup( export async function previewLogbookBackup(
backup: LogbookBackupFile, backup: ParsedLogbookBackup,
passphrase: string passphrase: string
): Promise<LogbookBackupPreview> { ): Promise<LogbookBackupPreview> {
const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase) const logbookKey = await unwrapLogbookKeyFromEnc(
const parsed = JSON.parse(backup.logbook.encryptedTitle) readBinaryFile(backup.files, backup.manifest.files.key),
const title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey) passphrase
)
const logbookMeta = JSON.parse(
readTextFile(backup.files, backup.manifest.files.logbook)
) as LogbookMetaJson
const parsed = JSON.parse(logbookMeta.encryptedTitle)
let title: string
try {
title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey)
} catch {
throw new Error('BACKUP_WRONG_PASSPHRASE')
}
return { return {
title, title,
exportedAt: backup.exportedAt, exportedAt: backup.manifest.exportedAt,
sourceLogbookId: backup.logbook.id, sourceLogbookId: backup.manifest.logbookId,
counts: backup.counts counts: backup.manifest.counts,
totalUncompressedBytes: backup.manifest.totalUncompressedBytes
} }
} }
@@ -540,7 +561,7 @@ export interface RestoreLogbookOptions {
} }
export async function restoreLogbookBackup( export async function restoreLogbookBackup(
backup: LogbookBackupFile, backup: ParsedLogbookBackup,
passphrase: string, passphrase: string,
options: RestoreLogbookOptions = {} options: RestoreLogbookOptions = {}
): Promise<{ logbookId: string; title: string }> { ): Promise<{ logbookId: string; title: string }> {
@@ -548,16 +569,22 @@ export async function restoreLogbookBackup(
throw new Error('BACKUP_NOT_AUTHENTICATED') throw new Error('BACKUP_NOT_AUTHENTICATED')
} }
const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase) const logbookKey = await unwrapLogbookKeyFromEnc(
const parsedTitle = JSON.parse(backup.logbook.encryptedTitle) readBinaryFile(backup.files, backup.manifest.files.key),
const title = await decryptJson( passphrase
parsedTitle.ciphertext,
parsedTitle.iv,
parsedTitle.tag,
logbookKey
) )
const logbookMeta = JSON.parse(
readTextFile(backup.files, backup.manifest.files.logbook)
) as LogbookMetaJson
const parsedTitle = JSON.parse(logbookMeta.encryptedTitle)
let title: string
try {
title = await decryptJson(parsedTitle.ciphertext, parsedTitle.iv, parsedTitle.tag, logbookKey)
} catch {
throw new Error('BACKUP_WRONG_PASSPHRASE')
}
let targetId = backup.logbook.id let targetId = backup.manifest.logbookId
const existing = await db.logbooks.get(targetId) const existing = await db.logbooks.get(targetId)
if (existing && !options.overwrite && !options.assignNewId) { if (existing && !options.overwrite && !options.assignNewId) {
@@ -568,18 +595,29 @@ export async function restoreLogbookBackup(
await deleteLocalLogbookCache(targetId) await deleteLocalLogbookCache(targetId)
} }
let prepared = backup
if (options.assignNewId || (existing && !options.overwrite)) { if (options.assignNewId || (existing && !options.overwrite)) {
targetId = crypto.randomUUID() targetId = crypto.randomUUID()
prepared = remapParsedBackup(backup, targetId)
} }
const prepared = targetId === backup.logbook.id ? backup : remapBackup(backup, targetId) const finalMeta = JSON.parse(
readTextFile(prepared.files, prepared.manifest.files.logbook)
) as LogbookMetaJson
await writeBackupToDexie(targetId, prepared, logbookKey) await writeBackupToDexie(
targetId,
finalMeta,
logbookKey,
prepared.manifest,
prepared.files
)
await queueRestoredLogbookForSync( await queueRestoredLogbookForSync(
targetId, targetId,
prepared.logbook.encryptedTitle, finalMeta.encryptedTitle,
logbookKey, logbookKey,
prepared.payloads prepared.manifest,
prepared.files
) )
if (navigator.onLine) { if (navigator.onLine) {
@@ -599,3 +637,14 @@ export function downloadBackupBlob(blob: Blob, filename: string): void {
anchor.click() anchor.click()
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
/** Human-readable size for UI warnings. */
export function formatBackupBytes(bytes: number): string {
const fmt = (n: number) => formatAppDecimal(n, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${fmt(bytes / 1024)} KB`
return `${fmt(bytes / (1024 * 1024))} MB`
}
export const BACKUP_SIZE_WARN_BYTES = 50_000_000
export const BACKUP_SIZE_CONFIRM_BYTES = 150_000_000
@@ -0,0 +1,355 @@
import { db } from '../db.js'
import { encBytesFromDexieFields, type DexieEncFields } from './encBlob.js'
import { buildZipArchive, utf8Bytes } from './zipArchive.js'
import {
BACKUP_FORMAT,
BACKUP_VERSION,
type BackupIndexedEntryFile,
type BackupIndexedPayloadFile,
type BackupIndexedTrackFile,
type BackupManifestCounts,
type BackupManifestFiles,
type BackupManifestV2,
type LogbookMetaJson
} from './manifest.js'
export interface CollectedBackupData {
logbookMeta: LogbookMetaJson
yacht: DexieEncFields | null
deviation: DexieEncFields | null
logbookCrewSelection: DexieEncFields | null
logbookVesselSelection: DexieEncFields | null
crews: Array<DexieEncFields & { payloadId: string; updatedAt: string }>
entries: Array<DexieEncFields & { payloadId: string; updatedAt: string }>
photos: Array<DexieEncFields & { payloadId: string; entryId: string; updatedAt: string }>
voiceMemos: Array<DexieEncFields & { payloadId: string; entryId: string; updatedAt: string }>
gpsTracks: Array<DexieEncFields & { entryId: string; updatedAt: string }>
nmeaArchives: Array<DexieEncFields & { entryId: string; updatedAt: string }>
}
function pickEnc(row: {
encryptedData: string
iv: string
tag: string
}): DexieEncFields {
return {
encryptedData: row.encryptedData,
iv: row.iv,
tag: row.tag
}
}
export async function collectLogbookBackupData(
logbookId: string
): Promise<CollectedBackupData> {
const [
logbook,
yacht,
deviation,
logbookCrewSelection,
logbookVesselSelection,
crews,
entries,
photos,
voiceMemos,
gpsTracks,
nmeaArchives
] = await Promise.all([
db.logbooks.get(logbookId),
db.yachts.get(logbookId),
db.deviations.get(logbookId),
db.logbookCrewSelections.get(logbookId),
db.logbookVesselSelections.get(logbookId),
db.crews.where({ logbookId }).toArray(),
db.entries.where({ logbookId }).toArray(),
db.photos.where({ logbookId }).toArray(),
db.voiceMemos.where({ logbookId }).toArray(),
db.gpsTracks.where({ logbookId }).toArray(),
db.nmeaArchives.where({ logbookId }).toArray()
])
if (!logbook) throw new Error('BACKUP_LOGBOOK_NOT_FOUND')
return {
logbookMeta: {
id: logbook.id,
encryptedTitle: logbook.encryptedTitle,
updatedAt: logbook.updatedAt,
isDemo: logbook.isDemo === 1
},
yacht: yacht ? pickEnc(yacht) : null,
deviation: deviation ? pickEnc(deviation) : null,
logbookCrewSelection: logbookCrewSelection ? pickEnc(logbookCrewSelection) : null,
logbookVesselSelection: logbookVesselSelection ? pickEnc(logbookVesselSelection) : null,
crews: crews.map((c) => ({ ...pickEnc(c), payloadId: c.payloadId, updatedAt: c.updatedAt })),
entries: entries.map((e) => ({
...pickEnc(e),
payloadId: e.payloadId,
updatedAt: e.updatedAt
})),
photos: photos.map((p) => ({
...pickEnc(p),
payloadId: p.payloadId,
entryId: p.entryId,
updatedAt: p.updatedAt
})),
voiceMemos: voiceMemos.map((v) => ({
...pickEnc(v),
payloadId: v.payloadId,
entryId: v.entryId,
updatedAt: v.updatedAt
})),
gpsTracks: gpsTracks.map((t) => ({
...pickEnc(t),
entryId: t.entryId,
updatedAt: t.updatedAt
})),
nmeaArchives: nmeaArchives.map((n) => ({
...pickEnc(n),
entryId: n.entryId,
updatedAt: n.updatedAt
}))
}
}
export type BackupProgressPhase = 'collect' | 'pack' | 'done'
export interface BackupExportProgress {
phase: BackupProgressPhase
current: number
total: number
bytesPacked: number
}
export interface BuiltArchive {
zipBytes: Uint8Array
manifest: BackupManifestV2
counts: BackupManifestCounts
totalUncompressedBytes: number
}
function addEncFile(
zipFiles: Record<string, Uint8Array>,
path: string,
fields: DexieEncFields
): number {
const bytes = encBytesFromDexieFields(fields)
zipFiles[path] = bytes
return bytes.byteLength
}
export function buildArchiveFromCollected(
collected: CollectedBackupData,
keyEnc: Uint8Array,
options: {
exportedAt: string
appVersion?: string
onProgress?: (progress: BackupExportProgress) => void
}
): BuiltArchive {
const zipFiles: Record<string, Uint8Array> = {}
let totalUncompressedBytes = 0
const logbookPath = 'logbook.meta.json'
zipFiles[logbookPath] = utf8Bytes(JSON.stringify(collected.logbookMeta))
totalUncompressedBytes += zipFiles[logbookPath].byteLength
zipFiles['key.enc'] = keyEnc
totalUncompressedBytes += keyEnc.byteLength
const files: BackupManifestFiles = {
key: 'key.enc',
logbook: logbookPath,
yacht: null,
deviation: null,
logbookCrewSelection: null,
logbookVesselSelection: null,
crews: [],
entries: [],
photos: [],
voiceMemos: [],
gpsTracks: [],
nmeaArchives: []
}
const packSteps: Array<() => void> = []
if (collected.yacht) {
packSteps.push(() => {
const path = 'payloads/yacht.enc'
const size = addEncFile(zipFiles, path, collected.yacht!)
files.yacht = path
totalUncompressedBytes += size
})
}
if (collected.deviation) {
packSteps.push(() => {
const path = 'payloads/deviation.enc'
const size = addEncFile(zipFiles, path, collected.deviation!)
files.deviation = path
totalUncompressedBytes += size
})
}
if (collected.logbookCrewSelection) {
packSteps.push(() => {
const path = 'payloads/logbook-crew.enc'
const size = addEncFile(zipFiles, path, collected.logbookCrewSelection!)
files.logbookCrewSelection = path
totalUncompressedBytes += size
})
}
if (collected.logbookVesselSelection) {
packSteps.push(() => {
const path = 'payloads/logbook-vessel.enc'
const size = addEncFile(zipFiles, path, collected.logbookVesselSelection!)
files.logbookVesselSelection = path
totalUncompressedBytes += size
})
}
for (const c of collected.crews) {
packSteps.push(() => {
const path = `payloads/crews/${c.payloadId}.enc`
const size = addEncFile(zipFiles, path, c)
const index: BackupIndexedPayloadFile = {
path,
payloadId: c.payloadId,
updatedAt: c.updatedAt,
bytes: size
}
files.crews.push(index)
totalUncompressedBytes += size
})
}
for (const e of collected.entries) {
packSteps.push(() => {
const path = `payloads/entries/${e.payloadId}.enc`
const size = addEncFile(zipFiles, path, e)
const index: BackupIndexedPayloadFile = {
path,
payloadId: e.payloadId,
updatedAt: e.updatedAt,
bytes: size
}
files.entries.push(index)
totalUncompressedBytes += size
})
}
for (const p of collected.photos) {
packSteps.push(() => {
const path = `payloads/photos/${p.payloadId}.enc`
const size = addEncFile(zipFiles, path, p)
const index: BackupIndexedEntryFile = {
path,
payloadId: p.payloadId,
entryId: p.entryId,
updatedAt: p.updatedAt,
bytes: size
}
files.photos.push(index)
totalUncompressedBytes += size
})
}
for (const v of collected.voiceMemos) {
packSteps.push(() => {
const path = `payloads/voice-memos/${v.payloadId}.enc`
const size = addEncFile(zipFiles, path, v)
const index: BackupIndexedEntryFile = {
path,
payloadId: v.payloadId,
entryId: v.entryId,
updatedAt: v.updatedAt,
bytes: size
}
files.voiceMemos.push(index)
totalUncompressedBytes += size
})
}
for (const t of collected.gpsTracks) {
packSteps.push(() => {
const path = `payloads/gps-tracks/${t.entryId}.enc`
const size = addEncFile(zipFiles, path, t)
const index: BackupIndexedTrackFile = {
path,
entryId: t.entryId,
updatedAt: t.updatedAt,
bytes: size
}
files.gpsTracks.push(index)
totalUncompressedBytes += size
})
}
for (const n of collected.nmeaArchives) {
packSteps.push(() => {
const path = `payloads/nmea-archives/${n.entryId}.enc`
const size = addEncFile(zipFiles, path, n)
const index: BackupIndexedTrackFile = {
path,
entryId: n.entryId,
updatedAt: n.updatedAt,
bytes: size
}
files.nmeaArchives.push(index)
totalUncompressedBytes += size
})
}
const total = packSteps.length
packSteps.forEach((step, i) => {
step()
options.onProgress?.({
phase: 'pack',
current: i + 1,
total,
bytesPacked: totalUncompressedBytes
})
})
const counts: BackupManifestCounts = {
entries: collected.entries.length,
photos: collected.photos.length,
voiceMemos: collected.voiceMemos.length,
crews: collected.crews.length,
gpsTracks: collected.gpsTracks.length,
nmeaArchives: collected.nmeaArchives.length,
hasYacht: !!collected.yacht,
hasDeviation: !!collected.deviation,
hasLogbookCrewSelection: !!collected.logbookCrewSelection,
hasLogbookVesselSelection: !!collected.logbookVesselSelection
}
const manifest: BackupManifestV2 = {
format: BACKUP_FORMAT,
version: BACKUP_VERSION,
exportedAt: options.exportedAt,
appVersion: options.appVersion,
compression: 'zip-deflate-6',
logbookId: collected.logbookMeta.id,
counts,
totalUncompressedBytes,
files
}
zipFiles['manifest.json'] = utf8Bytes(JSON.stringify(manifest))
totalUncompressedBytes += zipFiles['manifest.json'].byteLength
const zipBytes = buildZipArchive(zipFiles)
manifest.totalUncompressedBytes = totalUncompressedBytes
options.onProgress?.({
phase: 'done',
current: total,
total,
bytesPacked: totalUncompressedBytes
})
return { zipBytes, manifest, counts, totalUncompressedBytes }
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import {
dexieFieldsFromEncBytes,
encBytesFromDexieFields,
ENC_HEADER_SIZE
} from './encBlob.js'
function toB64(bytes: number[]): string {
return btoa(String.fromCharCode(...bytes))
}
describe('encBlob', () => {
it('round-trips dexie AES-GCM fields', () => {
const fields = {
encryptedData: toB64([9, 8, 7]),
iv: toB64(Array.from({ length: 12 }, (_, i) => i)),
tag: toB64(Array.from({ length: 16 }, (_, i) => i + 20))
}
const enc = encBytesFromDexieFields(fields)
expect(enc.byteLength).toBe(ENC_HEADER_SIZE + 3)
expect(dexieFieldsFromEncBytes(enc)).toEqual(fields)
})
it('rejects invalid magic', () => {
expect(() => dexieFieldsFromEncBytes(new Uint8Array(40))).toThrow('BACKUP_INVALID_ENC')
})
})
@@ -0,0 +1,45 @@
import { base64ToBuffer, bufferToBase64 } from '../crypto.js'
export const ENC_MAGIC = new Uint8Array([0x4b, 0x44, 0x41, 0x42]) // KDAB
export const ENC_FORMAT_VERSION = 1
export const ENC_HEADER_SIZE = 33 // 4 + 1 + 12 + 16
export interface DexieEncFields {
encryptedData: string
iv: string
tag: string
}
export function encBytesFromDexieFields(fields: DexieEncFields): Uint8Array {
const iv = new Uint8Array(base64ToBuffer(fields.iv))
const tag = new Uint8Array(base64ToBuffer(fields.tag))
const ciphertext = new Uint8Array(base64ToBuffer(fields.encryptedData))
if (iv.length !== 12) throw new Error('BACKUP_INVALID_ENC')
if (tag.length !== 16) throw new Error('BACKUP_INVALID_ENC')
const out = new Uint8Array(ENC_HEADER_SIZE + ciphertext.length)
out.set(ENC_MAGIC, 0)
out[4] = ENC_FORMAT_VERSION
out.set(iv, 5)
out.set(tag, 17)
out.set(ciphertext, 33)
return out
}
export function dexieFieldsFromEncBytes(bytes: Uint8Array): DexieEncFields {
if (bytes.length < ENC_HEADER_SIZE) throw new Error('BACKUP_INVALID_ENC')
for (let i = 0; i < 4; i++) {
if (bytes[i] !== ENC_MAGIC[i]) throw new Error('BACKUP_INVALID_ENC')
}
if (bytes[4] !== ENC_FORMAT_VERSION) throw new Error('BACKUP_INVALID_ENC')
const iv = bufferToBase64(bytes.slice(5, 17).buffer)
const tag = bufferToBase64(bytes.slice(17, 33).buffer)
const ciphertext = bufferToBase64(bytes.slice(33).buffer)
return { encryptedData: ciphertext, iv, tag }
}
export function encByteLength(fields: DexieEncFields): number {
const ct = base64ToBuffer(fields.encryptedData).byteLength
return ENC_HEADER_SIZE + ct
}
@@ -0,0 +1,97 @@
export const BACKUP_FORMAT = 'kapteins-daagbok-backup' as const
export const BACKUP_VERSION = 2 as const
export interface BackupIndexedFile {
path: string
updatedAt: string
bytes: number
}
export interface BackupIndexedPayloadFile extends BackupIndexedFile {
payloadId: string
}
export interface BackupIndexedEntryFile extends BackupIndexedPayloadFile {
entryId: string
}
export interface BackupIndexedTrackFile extends BackupIndexedFile {
entryId: string
}
export interface BackupManifestCounts {
entries: number
photos: number
voiceMemos: number
crews: number
gpsTracks: number
nmeaArchives: number
hasYacht: boolean
hasDeviation: boolean
hasLogbookCrewSelection: boolean
hasLogbookVesselSelection: boolean
}
export interface BackupManifestFiles {
key: string
logbook: string
yacht: string | null
deviation: string | null
logbookCrewSelection: string | null
logbookVesselSelection: string | null
crews: BackupIndexedPayloadFile[]
entries: BackupIndexedPayloadFile[]
photos: BackupIndexedEntryFile[]
voiceMemos: BackupIndexedEntryFile[]
gpsTracks: BackupIndexedTrackFile[]
nmeaArchives: BackupIndexedTrackFile[]
}
export interface BackupManifestV2 {
format: typeof BACKUP_FORMAT
version: typeof BACKUP_VERSION
exportedAt: string
appVersion?: string
compression: 'zip-deflate-6'
logbookId: string
counts: BackupManifestCounts
totalUncompressedBytes: number
files: BackupManifestFiles
}
export interface LogbookMetaJson {
id: string
encryptedTitle: string
updatedAt: string
isDemo?: boolean
}
export function parseManifestJson(text: string): BackupManifestV2 {
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch {
throw new Error('BACKUP_INVALID_FORMAT')
}
if (!isBackupManifestV2(parsed)) {
throw new Error('BACKUP_INVALID_FORMAT')
}
return parsed
}
export function isBackupManifestV2(value: unknown): value is BackupManifestV2 {
if (!value || typeof value !== 'object') return false
const obj = value as Partial<BackupManifestV2>
return (
obj.format === BACKUP_FORMAT &&
obj.version === BACKUP_VERSION &&
typeof obj.exportedAt === 'string' &&
typeof obj.logbookId === 'string' &&
!!obj.counts &&
!!obj.files
)
}
export function serializeManifest(manifest: BackupManifestV2): string {
return JSON.stringify(manifest)
}
@@ -0,0 +1,45 @@
import { strToU8, unzipSync, zipSync } from 'fflate'
import { parseManifestJson, type BackupManifestV2 } from './manifest.js'
const ZIP_LEVEL = 6
export function buildZipArchive(files: Record<string, Uint8Array>): Uint8Array {
return zipSync(files, { level: ZIP_LEVEL })
}
export function unzipArchive(data: Uint8Array): Record<string, Uint8Array> {
try {
return unzipSync(data)
} catch {
throw new Error('BACKUP_INVALID_ARCHIVE')
}
}
export function readManifestFromArchive(
files: Record<string, Uint8Array>
): BackupManifestV2 {
const raw = files['manifest.json']
if (!raw) throw new Error('BACKUP_INVALID_FORMAT')
const text = new TextDecoder().decode(raw)
return parseManifestJson(text)
}
export function readTextFile(files: Record<string, Uint8Array>, path: string): string {
const raw = files[path]
if (!raw) throw new Error('BACKUP_MISSING_BLOB')
return new TextDecoder().decode(raw)
}
export function readBinaryFile(files: Record<string, Uint8Array>, path: string): Uint8Array {
const raw = files[path]
if (!raw) throw new Error('BACKUP_MISSING_BLOB')
return raw
}
export function utf8Bytes(text: string): Uint8Array {
return strToU8(text)
}
export function isZipArchive(bytes: Uint8Array): boolean {
return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b
}
+12
View File
@@ -91,6 +91,7 @@ export function clearLogbookKeysCache() {
export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer> { export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer> {
const localLb = await db.logbooks.get(logbookId) const localLb = await db.logbooks.get(logbookId)
const encryptedTitle = localLb ? localLb.encryptedTitle : '' const encryptedTitle = localLb ? localLb.encryptedTitle : ''
const isShared = localLb?.isShared === 1
const masterKey = getActiveMasterKey() const masterKey = getActiveMasterKey()
let key = await getLogbookKey(logbookId) let key = await getLogbookKey(logbookId)
@@ -103,6 +104,11 @@ export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer>
// Key works, return it // Key works, return it
return key return key
} catch (err) { } catch (err) {
if (isShared) {
throw new Error(
'Shared logbook encryption key is missing or invalid. Please go online and refresh your logbooks.'
)
}
console.warn('Stored logbook key failed to decrypt title. Testing if master key works (legacy migration)...') console.warn('Stored logbook key failed to decrypt title. Testing if master key works (legacy migration)...')
try { try {
const parsed = JSON.parse(encryptedTitle) const parsed = JSON.parse(encryptedTitle)
@@ -145,6 +151,12 @@ export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer>
// If no logbook key exists yet // If no logbook key exists yet
if (!key) { if (!key) {
if (isShared) {
throw new Error(
'Shared logbook encryption key not found. Please go online and refresh your logbooks.'
)
}
if (encryptedTitle && masterKey) { if (encryptedTitle && masterKey) {
try { try {
// Check if title is already decryptable using masterKey (meaning it is a legacy logbook) // Check if title is already decryptable using masterKey (meaning it is a legacy logbook)
@@ -1,7 +1,12 @@
import { formatAppDecimal } from '../../utils/numberFormat.js'
import type { NmeaChangeEvent, NmeaDetectionConfig, NmeaTimePoint } from './nmeaTypes.js' import type { NmeaChangeEvent, NmeaDetectionConfig, NmeaTimePoint } from './nmeaTypes.js'
import { DEFAULT_NMEA_DETECTION_CONFIG } from './nmeaTypes.js' import { DEFAULT_NMEA_DETECTION_CONFIG } from './nmeaTypes.js'
import { angularDelta } from './nmeaTimeSeries.js' import { angularDelta } from './nmeaTimeSeries.js'
function formatNmeaDecimal(value: number): string {
return formatAppDecimal(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
}
function pushUnique(events: NmeaChangeEvent[], event: NmeaChangeEvent, minGapMs: number) { function pushUnique(events: NmeaChangeEvent[], event: NmeaChangeEvent, minGapMs: number) {
const last = events[events.length - 1] const last = events[events.length - 1]
if (last && last.type === event.type && event.timestamp - last.timestamp < minGapMs) return if (last && last.type === event.type && event.timestamp - last.timestamp < minGapMs) return
@@ -64,7 +69,7 @@ export function detectNmeaChanges(
timestamp: p.timestamp, timestamp: p.timestamp,
confidence: 'medium', confidence: 'medium',
summaryKey: 'logs.nmea_change_wind_speed', summaryKey: 'logs.nmea_change_wind_speed',
summaryParams: { from: lastWindSpeed.toFixed(1), to: p.windSpeedKnots.toFixed(1) }, summaryParams: { from: formatNmeaDecimal(lastWindSpeed), to: formatNmeaDecimal(p.windSpeedKnots) },
data: p data: p
}, config.dedupeWindowMs) }, config.dedupeWindowMs)
} }
@@ -79,7 +84,7 @@ export function detectNmeaChanges(
timestamp: p.timestamp, timestamp: p.timestamp,
confidence: 'medium', confidence: 'medium',
summaryKey: 'logs.nmea_change_pressure', summaryKey: 'logs.nmea_change_pressure',
summaryParams: { from: lastPressure.toFixed(1), to: p.pressureHpa.toFixed(1) }, summaryParams: { from: formatNmeaDecimal(lastPressure), to: formatNmeaDecimal(p.pressureHpa) },
data: p data: p
}, config.dedupeWindowMs) }, config.dedupeWindowMs)
} }
@@ -95,7 +100,7 @@ export function detectNmeaChanges(
timestamp: p.timestamp, timestamp: p.timestamp,
confidence: 'high', confidence: 'high',
summaryKey: 'logs.nmea_change_depth', summaryKey: 'logs.nmea_change_depth',
summaryParams: { from: lastDepth.toFixed(1), to: p.depthM.toFixed(1) }, summaryParams: { from: formatNmeaDecimal(lastDepth), to: formatNmeaDecimal(p.depthM) },
data: p data: p
}, config.dedupeWindowMs) }, config.dedupeWindowMs)
} }
@@ -156,7 +161,7 @@ export function detectNmeaChanges(
timestamp: p.timestamp, timestamp: p.timestamp,
confidence: 'medium', confidence: 'medium',
summaryKey: 'logs.nmea_change_water_temp', summaryKey: 'logs.nmea_change_water_temp',
summaryParams: { from: lastWaterTemp.toFixed(1), to: p.waterTempC.toFixed(1) }, summaryParams: { from: formatNmeaDecimal(lastWaterTemp), to: formatNmeaDecimal(p.waterTempC) },
data: p data: p
}, config.dedupeWindowMs) }, config.dedupeWindowMs)
} }
@@ -200,7 +205,7 @@ export function detectNmeaChanges(
timestamp: p.timestamp, timestamp: p.timestamp,
confidence: 'low', confidence: 'low',
summaryKey: 'logs.nmea_change_speed', summaryKey: 'logs.nmea_change_speed',
summaryParams: { from: lastSog.toFixed(1), to: sog.toFixed(1) }, summaryParams: { from: formatNmeaDecimal(lastSog), to: formatNmeaDecimal(sog) },
data: p data: p
}, config.dedupeWindowMs) }, config.dedupeWindowMs)
} }
@@ -2,6 +2,7 @@ import type { TFunction } from 'i18next'
import type { LogEventPayload } from '../../utils/logEntryPayload.js' import type { LogEventPayload } from '../../utils/logEntryPayload.js'
import { normalizeLogEvent } from '../../utils/logEntryPayload.js' import { normalizeLogEvent } from '../../utils/logEntryPayload.js'
import { formatCourseAngle } from '../../utils/courseAngle.js' import { formatCourseAngle } from '../../utils/courseAngle.js'
import { formatAppDecimal, formatCanonicalCoordinate } from '../../utils/numberFormat.js'
import { degreesToCardinal } from '../../utils/courseAngle.js' import { degreesToCardinal } from '../../utils/courseAngle.js'
import type { import type {
NmeaChangeEvent, NmeaChangeEvent,
@@ -33,9 +34,12 @@ function pointToLogEvent(
windDirection: windDir, windDirection: windDir,
windStrength: point.windSpeedKnots != null ? String(point.windSpeedKnots) : '', windStrength: point.windSpeedKnots != null ? String(point.windSpeedKnots) : '',
windPressure: point.pressureHpa != null ? String(Math.round(point.pressureHpa)) : '', windPressure: point.pressureHpa != null ? String(Math.round(point.pressureHpa)) : '',
gpsLat: point.lat != null ? point.lat.toFixed(6) : '', gpsLat: point.lat != null ? formatCanonicalCoordinate(point.lat) : '',
gpsLng: point.lng != null ? point.lng.toFixed(6) : '', gpsLng: point.lng != null ? formatCanonicalCoordinate(point.lng) : '',
logReading: point.logDistanceNm != null ? point.logDistanceNm.toFixed(2) : '', logReading:
point.logDistanceNm != null
? formatAppDecimal(point.logDistanceNm, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: '',
sailsOrMotor, sailsOrMotor,
remarks remarks
}) })
@@ -51,7 +55,11 @@ function buildRemarks(change: NmeaChangeEvent, t: TFunction): string {
const parts: string[] = [] const parts: string[] = []
parts.push(t(change.summaryKey, change.summaryParams ?? {})) parts.push(t(change.summaryKey, change.summaryParams ?? {}))
if (change.data?.depthM != null) { if (change.data?.depthM != null) {
parts.push(t('logs.nmea_remark_depth', { depth: change.data.depthM.toFixed(1) })) parts.push(
t('logs.nmea_remark_depth', {
depth: formatAppDecimal(change.data.depthM, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
})
)
} }
if (change.confidence === 'low') { if (change.confidence === 'low') {
parts.push(t('logs.nmea_remark_uncertain')) parts.push(t('logs.nmea_remark_uncertain'))
+66 -12
View File
@@ -13,12 +13,13 @@ function formatPasskeySignDate(signedAt: string): string {
} }
export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> { export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> {
let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = ''; let yachtName = '', owner = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
let entry: any = null; let entry: any = null;
if (preloadedData) { if (preloadedData) {
const yacht = preloadedData.yacht || {}; const yacht = preloadedData.yacht || {};
yachtName = yacht.name || ''; yachtName = yacht.name || '';
owner = yacht.owner || '';
homePort = yacht.port || ''; homePort = yacht.port || '';
registration = yacht.registrationNumber || yacht.registration || ''; registration = yacht.registrationNumber || yacht.registration || '';
callsign = yacht.callSign || ''; callsign = yacht.callSign || '';
@@ -35,6 +36,7 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
const yacht = await resolveVesselForLogbook(logbookId) const yacht = await resolveVesselForLogbook(logbookId)
if (yacht) { if (yacht) {
yachtName = yacht.name || '' yachtName = yacht.name || ''
owner = yacht.owner || ''
homePort = yacht.homePort || '' homePort = yacht.homePort || ''
registration = yacht.registrationNumber || '' registration = yacht.registrationNumber || ''
callsign = yacht.callSign || '' callsign = yacht.callSign || ''
@@ -74,24 +76,56 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
doc.setFontSize(8.5); doc.setFontSize(8.5);
doc.setFont('Helvetica', 'normal'); doc.setFont('Helvetica', 'normal');
doc.text(`Yachtname: ${yachtName || '—'}`, 10, 21); doc.text(`Yachtname: ${yachtName || '—'}`, 10, 21);
doc.text(`Heimathafen: ${homePort || '—'}`, 60, 21); doc.text(`Eigner: ${owner || '—'}`, 55, 21);
doc.text(`Kennzeichen: ${registration || '—'}`, 110, 21); doc.text(`Heimathafen: ${homePort || '—'}`, 100, 21);
doc.text(`Rufzeichen: ${callsign || '—'}`, 160, 21); doc.text(`Kennzeichen: ${registration || '—'}`, 145, 21);
doc.text(`ATIS: ${atis || '—'}`, 210, 21); doc.text(`Rufzeichen: ${callsign || '—'}`, 190, 21);
doc.text(`MMSI: ${mmsi || '—'}`, 250, 21); doc.text(`ATIS: ${atis || '—'}`, 230, 21);
doc.text(`MMSI: ${mmsi || '—'}`, 260, 21);
doc.text(`Datum: ${entry.date || '—'}`, 10, 23); doc.text(`Datum: ${entry.date || '—'}`, 10, 24);
doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 23); doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 24);
doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 23); doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 24);
doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 23); doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 24);
// Format Crew names with initials
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {}
const crewList: string[] = []
if (entry.selectedSkipperId && crewSnapshots[entry.selectedSkipperId]) {
const name = crewSnapshots[entry.selectedSkipperId].name || 'Skipper'
const initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || 'S'
crewList.push(`${name} [${initial}] (Skipper)`)
} else if (crewSnapshots['skipper']) {
const name = crewSnapshots['skipper'].name || 'Skipper'
crewList.push(`${name} [S] (Skipper)`)
}
if (Array.isArray(entry.selectedCrewIds)) {
for (const crewId of entry.selectedCrewIds) {
const snap = crewSnapshots[crewId]
if (snap) {
const name = snap.name || ''
const initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '?'
crewList.push(`${name} [${initial}]`)
}
}
}
const crewText = crewList.length > 0 ? `Besatzung (Crew): ${crewList.join(', ')}` : ''
doc.setFont('Helvetica', 'normal');
if (entry.trackDistanceNm) { if (entry.trackDistanceNm) {
doc.setFont('Helvetica', 'normal');
doc.text( doc.text(
`GPS-Track: ${entry.trackDistanceNm} sm · max. ${entry.trackSpeedMaxKn ?? '—'} kn · Ø ${entry.trackSpeedAvgKn ?? '—'} kn`, `GPS-Track: ${entry.trackDistanceNm} sm · max. ${entry.trackSpeedMaxKn ?? '—'} kn · Ø ${entry.trackSpeedAvgKn ?? '—'} kn`,
10, 10,
27 27
); );
if (crewText) {
doc.text(crewText, 140, 27);
}
} else if (crewText) {
doc.text(crewText, 10, 27);
} }
// Divider line // Divider line
@@ -175,8 +209,28 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
doc.text(gps, writeX + 1, y + 4.2); doc.text(gps, writeX + 1, y + 4.2);
writeX += colWidths[11]; writeX += colWidths[11];
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {};
let initial = '';
if (ev.creatorId) {
const snap = crewSnapshots[ev.creatorId];
let name = '';
if (snap) {
name = snap.name || '';
} else if (ev.creatorId === 'skipper') {
name = 'Skipper';
} else {
name = ev.creatorId;
}
if (name) {
initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '';
}
}
// Clip remarks to fit within the 94mm bounds // Clip remarks to fit within the 94mm bounds
const remarks = ev.remarks || ''; let remarks = ev.remarks || '';
if (initial) {
remarks = `[${initial}] ${remarks}`;
}
const maxChars = 65; const maxChars = 65;
const clippedRemarks = remarks.length > maxChars ? remarks.substring(0, maxChars) + '...' : remarks; const clippedRemarks = remarks.length > maxChars ? remarks.substring(0, maxChars) + '...' : remarks;
doc.text(clippedRemarks, writeX + 1, y + 4.2); doc.text(clippedRemarks, writeX + 1, y + 4.2);
-3
View File
@@ -55,9 +55,6 @@ export async function saveEntryPhoto(options: {
}) })
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: analyticsContext }) trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: analyticsContext })
if (analyticsContext === 'live_log') {
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_PHOTO_UPLOADED)
}
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err)) syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
return photoId return photoId
} }
+132 -20
View File
@@ -27,17 +27,62 @@ export function getNotificationPermission(): NotificationPermission | 'unsupport
return Notification.permission return Notification.permission
} }
let cachedVapidKey: string | null = null
let cachedRegistration: ServiceWorkerRegistration | null = null
async function getRegistrationCompat(timeoutMs = 8000): Promise<ServiceWorkerRegistration> {
if (!('serviceWorker' in navigator)) {
throw new Error('Service Worker is not supported by your browser')
}
try {
const reg = await navigator.serviceWorker.getRegistration()
if (reg) return reg
} catch (e) {
console.warn('Failed to get service worker registration directly:', e)
}
// Fallback to waiting for ready state with a timeout
const readyPromise = navigator.serviceWorker.ready
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout waiting for Service Worker ready state')), timeoutMs)
)
return Promise.race([readyPromise, timeoutPromise])
}
export async function preloadPushService(): Promise<void> {
if (!isPushSupported()) return
try {
if (!cachedVapidKey) {
await fetchVapidPublicKey()
}
if (!cachedRegistration) {
cachedRegistration = await getRegistrationCompat()
}
} catch (err) {
console.warn('Failed to preload push service:', err)
}
}
async function fetchVapidPublicKey(): Promise<string | null> { async function fetchVapidPublicKey(): Promise<string | null> {
if (cachedVapidKey) return cachedVapidKey
const envKey = import.meta.env.VITE_VAPID_PUBLIC_KEY const envKey = import.meta.env.VITE_VAPID_PUBLIC_KEY
if (typeof envKey === 'string' && envKey.trim()) { if (typeof envKey === 'string' && envKey.trim()) {
return envKey.trim() cachedVapidKey = envKey.trim()
return cachedVapidKey
} }
try { try {
const res = await fetch(`${API_BASE}/vapid-public-key`) const res = await fetch(`${API_BASE}/vapid-public-key`)
if (!res.ok) return null if (!res.ok) return null
const data = await res.json() const data = await res.json()
return typeof data.publicKey === 'string' ? data.publicKey : null if (typeof data.publicKey === 'string') {
cachedVapidKey = data.publicKey.trim()
return cachedVapidKey
}
return null
} catch { } catch {
return null return null
} }
@@ -72,11 +117,61 @@ export async function savePushPrefs(collaboratorChangesEnabled: boolean): Promis
}) })
} }
async function requestNotificationPermission(): Promise<NotificationPermission> {
if (typeof Notification === 'undefined') return 'denied'
// Try promise-based signature first
try {
const result = Notification.requestPermission()
if (result !== undefined) {
return await result
}
} catch {
// Ignore and fall back to callback
}
// Callback-based fallback
return new Promise<NotificationPermission>((resolve) => {
Notification.requestPermission((permission) => {
resolve(permission)
})
})
}
async function saveSubscriptionToServer(subscription: PushSubscription): Promise<void> { async function saveSubscriptionToServer(subscription: PushSubscription): Promise<void> {
if (!localStorage.getItem('active_userid')) throw new Error('Not authenticated') if (!localStorage.getItem('active_userid')) throw new Error('Not authenticated')
const endpoint = subscription.endpoint
const json = subscription.toJSON() const json = subscription.toJSON()
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) { let p256dh = json.keys?.p256dh
let auth = json.keys?.auth
// Fallback for browsers (like Safari) that might not serialize keys in toJSON()
if (!p256dh && typeof subscription.getKey === 'function') {
try {
const rawKey = subscription.getKey('p256dh')
if (rawKey) {
p256dh = btoa(String.fromCharCode(...new Uint8Array(rawKey)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
} catch (e) {
console.warn('Failed to extract p256dh key manually:', e)
}
}
if (!auth && typeof subscription.getKey === 'function') {
try {
const rawAuth = subscription.getKey('auth')
if (rawAuth) {
auth = btoa(String.fromCharCode(...new Uint8Array(rawAuth)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
} catch (e) {
console.warn('Failed to extract auth key manually:', e)
}
}
if (!endpoint || !p256dh || !auth) {
throw new Error('Invalid push subscription') throw new Error('Invalid push subscription')
} }
@@ -85,8 +180,8 @@ async function saveSubscriptionToServer(subscription: PushSubscription): Promise
await apiJson(`${API_BASE}/subscription`, { await apiJson(`${API_BASE}/subscription`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
endpoint: json.endpoint, endpoint,
keys: json.keys, keys: { p256dh, auth },
locale, locale,
userAgent: navigator.userAgent userAgent: navigator.userAgent
}) })
@@ -98,35 +193,48 @@ export async function subscribeToPush(): Promise<void> {
throw new Error('Push notifications are not supported on this device') throw new Error('Push notifications are not supported on this device')
} }
const permission = await Notification.requestPermission() // Pre-resolve registration using getRegistrationCompat to prevent ready state hangs
if (permission !== 'granted') { let registration = cachedRegistration
throw new Error('Notification permission denied') if (!registration) {
registration = await getRegistrationCompat()
cachedRegistration = registration
} }
const publicKey = await fetchVapidPublicKey() const publicKey = cachedVapidKey || await fetchVapidPublicKey()
if (!publicKey) { if (!publicKey) {
throw new Error('Push notifications are not configured on this server') throw new Error('Push notifications are not configured on this server')
} }
const registration = await navigator.serviceWorker.ready const permission = await requestNotificationPermission()
let subscription = await registration.pushManager.getSubscription() if (permission !== 'granted') {
throw new Error('Notification permission denied')
if (!subscription) {
const keyBytes = urlBase64ToUint8Array(publicKey)
const applicationServerKey = new Uint8Array(keyBytes)
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey
})
} }
const keyBytes = urlBase64ToUint8Array(publicKey)
const applicationServerKey = new Uint8Array(keyBytes)
// Always call subscribe with timeout to prevent silent hangs on push network errors
const subscribePromise = registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey
})
const subscribeTimeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout establishing subscription with push service (FCM/APNs)')), 12000)
)
const subscription = await Promise.race([subscribePromise, subscribeTimeout])
await saveSubscriptionToServer(subscription) await saveSubscriptionToServer(subscription)
} }
export async function unsubscribeFromPush(): Promise<void> { export async function unsubscribeFromPush(): Promise<void> {
if (!isPushSupported()) return if (!isPushSupported()) return
const registration = await navigator.serviceWorker.ready let registration = cachedRegistration
if (!registration) {
registration = await getRegistrationCompat()
cachedRegistration = registration
}
const subscription = await registration.pushManager.getSubscription() const subscription = await registration.pushManager.getSubscription()
if (!subscription) return if (!subscription) return
@@ -164,3 +272,7 @@ export async function disableCollaboratorChangePush(): Promise<void> {
await savePushPrefs(false) await savePushPrefs(false)
await unsubscribeFromPush() await unsubscribeFromPush()
} }
if (isPushSupported()) {
void preloadPushService()
}
+144 -32
View File
@@ -3,11 +3,13 @@ import { getActiveMasterKey } from './auth.js'
import { ensureLogbookKey, getLogbookKey } from './logbookKeys.js' import { ensureLogbookKey, getLogbookKey } from './logbookKeys.js'
import { decryptJson, encryptJson } from './crypto.js' import { decryptJson, encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js' import { syncLogbook } from './sync.js'
import { putEntryRecord } from '../utils/entryListCache.js'
import { import {
buildLogEntryPayload, buildLogEntryPayload,
normalizeLogEvent, normalizeLogEvent,
sortLogEventsByTime, sortLogEventsByTime,
currentLocalTimeHHMM, currentLocalTimeHHMM,
localDateString,
type LogEventPayload type LogEventPayload
} from '../utils/logEntryPayload.js' } from '../utils/logEntryPayload.js'
import { import {
@@ -95,6 +97,14 @@ function buildEncryptedPayload(
consumption: fuel.consumption ?? 0 consumption: fuel.consumption ?? 0
} }
const entryCrew = data.selectedSkipperId
? {
selectedSkipperId: String(data.selectedSkipperId),
selectedCrewIds: Array.isArray(data.selectedCrewIds) ? data.selectedCrewIds.map(String) : [],
crewSnapshotsById: (data.crewSnapshotsById as Record<string, any>) || {}
}
: undefined
const payload = buildLogEntryPayload({ const payload = buildLogEntryPayload({
date: String(data.date || ''), date: String(data.date || ''),
dayOfTravel: String(data.dayOfTravel || ''), dayOfTravel: String(data.dayOfTravel || ''),
@@ -119,15 +129,27 @@ function buildEncryptedPayload(
motorHoursRaw != null && motorHoursRaw !== '' motorHoursRaw != null && motorHoursRaw !== ''
? parseFloat(String(motorHoursRaw)) ? parseFloat(String(motorHoursRaw))
: undefined, : undefined,
events: options.events events: options.events,
entryCrew
}) })
const clear = options.clearSignatures const clear = options.clearSignatures
return { const entryData: Record<string, unknown> = {
...payload, ...payload,
signSkipper: clear ? '' : (data.signSkipper ?? ''), signSkipper: clear ? '' : (data.signSkipper ?? ''),
signCrew: clear ? '' : (data.signCrew ?? '') signCrew: clear ? '' : (data.signCrew ?? '')
} }
const summary = typeof data.aiSummary === 'string' ? data.aiSummary.trim() : ''
if (summary) {
entryData.aiSummary = summary
entryData.aiSummaryGeneratedAt =
typeof data.aiSummaryGeneratedAt === 'string' && data.aiSummaryGeneratedAt
? data.aiSummaryGeneratedAt
: new Date().toISOString()
}
return entryData
} }
export async function loadEntry(logbookId: string, entryId: string): Promise<LoadedEntry | null> { export async function loadEntry(logbookId: string, entryId: string): Promise<LoadedEntry | null> {
@@ -139,18 +161,86 @@ export async function loadEntry(logbookId: string, entryId: string): Promise<Loa
return { payloadId: record.payloadId, updatedAt: record.updatedAt, data } return { payloadId: record.payloadId, updatedAt: record.updatedAt, data }
} }
function scoreTodayEntry(data: Record<string, unknown>): number {
const events = (data.events as unknown[] | undefined)?.length ?? 0
const signed = (data.signSkipper || data.signCrew) ? 1 : 0
const destination = String(data.destination || '').trim() ? 1 : 0
return events * 10 + signed + destination
}
export async function findTodayEntryId(logbookId: string): Promise<string | null> { export async function findTodayEntryId(logbookId: string): Promise<string | null> {
const todayStr = new Date().toISOString().substring(0, 10) const todayStr = localDateString()
const masterKey = await getMasterKey(logbookId) const masterKey = await getMasterKey(logbookId)
const local = sortEntriesNewestFirst(await db.entries.where({ logbookId }).toArray()) const local = sortEntriesNewestFirst(await db.entries.where({ logbookId }).toArray())
let bestId: string | null = null
let bestScore = -1
let bestUpdatedAt = ''
for (const entry of local) { for (const entry of local) {
const decrypted = await tryDecryptEntryPayload(entry, masterKey) const decrypted = await tryDecryptEntryPayload(entry, masterKey)
if (decrypted && String(decrypted.date) === todayStr) { if (!decrypted || String(decrypted.date) !== todayStr) continue
return entry.payloadId
const score = scoreTodayEntry(decrypted)
if (
score > bestScore
|| (score === bestScore && entry.updatedAt > bestUpdatedAt)
) {
bestId = entry.payloadId
bestScore = score
bestUpdatedAt = entry.updatedAt
} }
} }
return null
return bestId
}
async function entryHasAttachments(logbookId: string, entryId: string): Promise<boolean> {
const [photos, voices, track] = await Promise.all([
db.photos.where({ logbookId, entryId }).count(),
db.voiceMemos.where({ logbookId, entryId }).count(),
db.gpsTracks.get(entryId)
])
return photos > 0 || voices > 0 || track != null
}
async function isEmptyTodayEntry(
logbookId: string,
entryId: string,
data: Record<string, unknown>
): Promise<boolean> {
if (((data.events as unknown[] | undefined)?.length ?? 0) > 0) return false
if (data.signSkipper || data.signCrew) return false
if (String(data.destination || '').trim()) return false
return !(await entryHasAttachments(logbookId, entryId))
}
/** Remove duplicate empty travel days for today (e.g. after parallel Live-log init). */
export async function pruneEmptyTodayDuplicates(
logbookId: string,
keepEntryId: string
): Promise<void> {
const todayStr = localDateString()
const masterKey = await getMasterKey(logbookId)
const local = await db.entries.where({ logbookId }).toArray()
const now = new Date().toISOString()
for (const entry of local) {
if (entry.payloadId === keepEntryId) continue
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
if (!decrypted || String(decrypted.date) !== todayStr) continue
if (!(await isEmptyTodayEntry(logbookId, entry.payloadId, decrypted))) continue
await db.entries.delete(entry.payloadId)
await db.syncQueue.put({
action: 'delete',
type: 'entry',
payloadId: entry.payloadId,
logbookId,
data: '',
updatedAt: now
})
}
} }
export async function createTodayEntry(logbookId: string): Promise<string> { export async function createTodayEntry(logbookId: string): Promise<string> {
@@ -173,7 +263,7 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
const localId = window.crypto.randomUUID() const localId = window.crypto.randomUUID()
const nowStr = new Date().toISOString() const nowStr = new Date().toISOString()
const todayStr = nowStr.substring(0, 10) const todayStr = localDateString()
const initialPayload = { const initialPayload = {
date: todayStr, date: todayStr,
@@ -190,14 +280,17 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
const encrypted = await encryptJson(initialPayload, masterKey) const encrypted = await encryptJson(initialPayload, masterKey)
await db.entries.put({ await putEntryRecord(
payloadId: localId, {
logbookId, payloadId: localId,
encryptedData: encrypted.ciphertext, logbookId,
iv: encrypted.iv, encryptedData: encrypted.ciphertext,
tag: encrypted.tag, iv: encrypted.iv,
updatedAt: nowStr tag: encrypted.tag,
}) updatedAt: nowStr
},
initialPayload
)
await db.syncQueue.put({ await db.syncQueue.put({
action: 'create', action: 'create',
@@ -212,20 +305,36 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
return localId return localId
} }
const findOrCreateTodayEntryInflight = new Map<string, Promise<string>>()
async function findOrCreateTodayEntryOnce(logbookId: string): Promise<string> {
await ensureLogbookKey(logbookId)
let entryId = await findTodayEntryId(logbookId)
if (!entryId) {
entryId = await createTodayEntry(logbookId)
}
await pruneEmptyTodayDuplicates(logbookId, entryId)
return entryId
}
/** One travel day per local calendar date; concurrent callers share one in-flight create. */
export async function findOrCreateTodayEntry(logbookId: string): Promise<string> { export async function findOrCreateTodayEntry(logbookId: string): Promise<string> {
const id = logbookId.trim() const id = logbookId.trim()
if (!id) throw new Error('Logbook id required') if (!id) throw new Error('Logbook id required')
await ensureLogbookKey(id) let inflight = findOrCreateTodayEntryInflight.get(id)
if (!inflight) {
const entryCount = await db.entries.where({ logbookId: id }).count() inflight = findOrCreateTodayEntryOnce(id)
if (entryCount === 0) { findOrCreateTodayEntryInflight.set(id, inflight)
return createTodayEntry(id) void inflight.finally(() => {
if (findOrCreateTodayEntryInflight.get(id) === inflight) {
findOrCreateTodayEntryInflight.delete(id)
}
})
} }
return inflight
const existing = await findTodayEntryId(id)
if (existing) return existing
return createTodayEntry(id)
} }
export interface AppendQuickEventResult { export interface AppendQuickEventResult {
@@ -305,14 +414,17 @@ async function persistEntry(
const encrypted = await encryptJson(entryData, masterKey) const encrypted = await encryptJson(entryData, masterKey)
const now = new Date().toISOString() const now = new Date().toISOString()
await db.entries.put({ await putEntryRecord(
payloadId: entryId, {
logbookId, payloadId: entryId,
encryptedData: encrypted.ciphertext, logbookId,
iv: encrypted.iv, encryptedData: encrypted.ciphertext,
tag: encrypted.tag, iv: encrypted.iv,
updatedAt: now tag: encrypted.tag,
}) updatedAt: now
},
entryData
)
await db.syncQueue.put({ await db.syncQueue.put({
action: 'update', action: 'update',
+1 -11
View File
@@ -258,14 +258,4 @@ export function getTrackColor(index: number): string {
return TRACK_COLORS[index % TRACK_COLORS.length] return TRACK_COLORS[index % TRACK_COLORS.length]
} }
export function formatNm(value: number): string { export { formatHours, formatLiters, formatNm } from '../utils/numberFormat.js'
return value.toFixed(2)
}
export function formatLiters(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1)
}
export function formatHours(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1)
}
+57 -14
View File
@@ -8,6 +8,7 @@ import {
type SyncConflict type SyncConflict
} from './syncConflicts.js' } from './syncConflicts.js'
import { syncPersonPool } from './personPoolSync.js' import { syncPersonPool } from './personPoolSync.js'
import { forEachInBatches, yieldToMain } from '../utils/yieldToMain.js'
const API_BASE = '/api/sync' const API_BASE = '/api/sync'
const syncingLogbooks = new Set<string>() const syncingLogbooks = new Set<string>()
@@ -60,6 +61,8 @@ async function entityExistsLocally(item: SyncQueueItem): Promise<boolean> {
return !!(await db.entries.get(item.payloadId)) return !!(await db.entries.get(item.payloadId))
case 'photo': case 'photo':
return !!(await db.photos.get(item.payloadId)) return !!(await db.photos.get(item.payloadId))
case 'voiceMemo':
return !!(await db.voiceMemos.get(item.payloadId))
case 'gpsTrack': case 'gpsTrack':
return !!(await db.gpsTracks.get(item.payloadId)) return !!(await db.gpsTracks.get(item.payloadId))
case 'logbookCrew': case 'logbookCrew':
@@ -130,12 +133,7 @@ async function coalesceSyncQueue(logbookId: string): Promise<SyncQueueItem[]> {
} }
function scheduleResync(logbookId: string) { function scheduleResync(logbookId: string) {
if (pendingResync.has(logbookId)) return
pendingResync.add(logbookId) pendingResync.add(logbookId)
queueMicrotask(() => {
pendingResync.delete(logbookId)
syncLogbook(logbookId).catch((err) => console.warn('Deferred sync failed:', err))
})
} }
type LogbookPushAccess = 'OWNER' | 'WRITE' | 'READ' | 'UNKNOWN' type LogbookPushAccess = 'OWNER' | 'WRITE' | 'READ' | 'UNKNOWN'
@@ -234,6 +232,7 @@ type PulledServerPayload = {
crews?: Array<{ payloadId: string; updatedAt: string }> crews?: Array<{ payloadId: string; updatedAt: string }>
entries?: Array<{ payloadId: string; updatedAt: string }> entries?: Array<{ payloadId: string; updatedAt: string }>
photos?: Array<{ payloadId: string; updatedAt: string }> photos?: Array<{ payloadId: string; updatedAt: string }>
voiceMemos?: Array<{ payloadId: string; updatedAt: string }>
gpsTracks?: Array<{ entryId: string; updatedAt: string }> gpsTracks?: Array<{ entryId: string; updatedAt: string }>
} }
@@ -257,6 +256,7 @@ async function pruneAcknowledgedQueueItems(
for (const c of server.crews ?? []) serverTimes.set('crew:' + c.payloadId, c.updatedAt) for (const c of server.crews ?? []) serverTimes.set('crew:' + c.payloadId, c.updatedAt)
for (const e of server.entries ?? []) serverTimes.set('entry:' + e.payloadId, e.updatedAt) for (const e of server.entries ?? []) serverTimes.set('entry:' + e.payloadId, e.updatedAt)
for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt) for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt)
for (const v of server.voiceMemos ?? []) serverTimes.set('voiceMemo:' + v.payloadId, v.updatedAt)
for (const gt of server.gpsTracks ?? []) serverTimes.set('gpsTrack:' + gt.entryId, gt.updatedAt) for (const gt of server.gpsTracks ?? []) serverTimes.set('gpsTrack:' + gt.entryId, gt.updatedAt)
const localLogbook = await db.logbooks.get(logbookId) const localLogbook = await db.logbooks.get(logbookId)
@@ -303,8 +303,12 @@ async function pullChanges(logbookId: string): Promise<boolean> {
return false return false
} }
const { yacht, deviation, crews, logbookCrewSelection, logbookVesselSelection, entries, photos, gpsTracks } = const { yacht, deviation, crews, logbookCrewSelection, logbookVesselSelection, entries, photos, voiceMemos, gpsTracks } =
await response.json() await response.json()
// Large pull payloads block on JSON.parse — yield before applying to IndexedDB.
await yieldToMain()
const serverSnapshot: PulledServerPayload = { const serverSnapshot: PulledServerPayload = {
yacht, yacht,
deviation, deviation,
@@ -313,6 +317,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
crews, crews,
entries, entries,
photos, photos,
voiceMemos,
gpsTracks gpsTracks
} }
@@ -375,7 +380,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
// 3. Sync Crew List Payloads (legacy) // 3. Sync Crew List Payloads (legacy)
const serverCrewMap = new Map<string, any>() const serverCrewMap = new Map<string, any>()
if (crews && Array.isArray(crews)) { if (crews && Array.isArray(crews)) {
for (const c of crews) { await forEachInBatches(crews, 20, async (c) => {
serverCrewMap.set(c.payloadId, c) serverCrewMap.set(c.payloadId, c)
const local = await db.crews.get(c.payloadId) const local = await db.crews.get(c.payloadId)
if (!local || isNewer(c.updatedAt, local.updatedAt)) { if (!local || isNewer(c.updatedAt, local.updatedAt)) {
@@ -388,7 +393,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
updatedAt: c.updatedAt updatedAt: c.updatedAt
}) })
} }
} })
} }
// Deletions for Crew: If present locally but not on server, and not pending creation locally // Deletions for Crew: If present locally but not on server, and not pending creation locally
@@ -408,7 +413,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
// 4. Sync Journal Entry Payloads // 4. Sync Journal Entry Payloads
const serverEntryMap = new Map<string, any>() const serverEntryMap = new Map<string, any>()
if (entries && Array.isArray(entries)) { if (entries && Array.isArray(entries)) {
for (const e of entries) { await forEachInBatches(entries, 15, async (e) => {
serverEntryMap.set(e.payloadId, e) serverEntryMap.set(e.payloadId, e)
const local = await db.entries.get(e.payloadId) const local = await db.entries.get(e.payloadId)
if (!local || isNewer(e.updatedAt, local.updatedAt)) { if (!local || isNewer(e.updatedAt, local.updatedAt)) {
@@ -421,7 +426,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
updatedAt: e.updatedAt updatedAt: e.updatedAt
}) })
} }
} })
} }
// Deletions for Entries // Deletions for Entries
@@ -440,7 +445,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
// 5. Sync Photos // 5. Sync Photos
const serverPhotoMap = new Map<string, any>() const serverPhotoMap = new Map<string, any>()
if (photos && Array.isArray(photos)) { if (photos && Array.isArray(photos)) {
for (const p of photos) { await forEachInBatches(photos, 20, async (p) => {
serverPhotoMap.set(p.payloadId, p) serverPhotoMap.set(p.payloadId, p)
const local = await db.photos.get(p.payloadId) const local = await db.photos.get(p.payloadId)
if (!local || isNewer(p.updatedAt, local.updatedAt)) { if (!local || isNewer(p.updatedAt, local.updatedAt)) {
@@ -455,7 +460,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
updatedAt: p.updatedAt updatedAt: p.updatedAt
}) })
} }
} })
} }
// Deletions for Photos // Deletions for Photos
@@ -471,10 +476,42 @@ async function pullChanges(logbookId: string): Promise<boolean> {
} }
} }
// 5b. Sync Voice Memos
const serverVoiceMap = new Map<string, any>()
if (voiceMemos && Array.isArray(voiceMemos)) {
await forEachInBatches(voiceMemos, 20, async (v) => {
serverVoiceMap.set(v.payloadId, v)
const local = await db.voiceMemos.get(v.payloadId)
if (!local || isNewer(v.updatedAt, local.updatedAt)) {
await db.voiceMemos.put({
payloadId: v.payloadId,
entryId: v.entryId,
logbookId,
encryptedData: v.encryptedData,
iv: v.iv,
tag: v.tag,
updatedAt: v.updatedAt
})
}
})
}
const localVoiceMemos = await db.voiceMemos.where({ logbookId }).toArray()
for (const lv of localVoiceMemos) {
if (!serverVoiceMap.has(lv.payloadId)) {
const pendingCreate = await db.syncQueue
.where({ payloadId: lv.payloadId, action: 'create' })
.first()
if (!pendingCreate) {
await db.voiceMemos.delete(lv.payloadId)
}
}
}
// 6. Sync GPS Tracks // 6. Sync GPS Tracks
const serverGpsTrackMap = new Map<string, any>() const serverGpsTrackMap = new Map<string, any>()
if (gpsTracks && Array.isArray(gpsTracks)) { if (gpsTracks && Array.isArray(gpsTracks)) {
for (const gt of gpsTracks) { await forEachInBatches(gpsTracks, 10, async (gt) => {
serverGpsTrackMap.set(gt.entryId, gt) serverGpsTrackMap.set(gt.entryId, gt)
const local = await db.gpsTracks.get(gt.entryId) const local = await db.gpsTracks.get(gt.entryId)
if (!local || isNewer(gt.updatedAt, local.updatedAt)) { if (!local || isNewer(gt.updatedAt, local.updatedAt)) {
@@ -487,7 +524,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
updatedAt: gt.updatedAt updatedAt: gt.updatedAt
}) })
} }
} })
} }
// Deletions for GPS Tracks // Deletions for GPS Tracks
@@ -535,6 +572,12 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
} finally { } finally {
syncingLogbooks.delete(logbookId) syncingLogbooks.delete(logbookId)
recomputeSyncingState() recomputeSyncingState()
if (pendingResync.has(logbookId)) {
pendingResync.delete(logbookId)
setTimeout(() => {
syncLogbook(logbookId).catch((err) => console.warn('Deferred sync failed:', err))
}, 1000)
}
} }
} }
+12 -1
View File
@@ -6,7 +6,9 @@ import {
getThemePreference, getThemePreference,
setColorSchemePreference, setColorSchemePreference,
setOwmApiKey, setOwmApiKey,
setThemePreference setThemePreference,
getAiAuthorized,
setAiAuthorized
} from './userPreferences.js' } from './userPreferences.js'
const USER_ID = 'test-user-123' const USER_ID = 'test-user-123'
@@ -58,4 +60,13 @@ describe('userPreferences', () => {
expect(getThemePreference(USER_ID)).toBe('ocean') expect(getThemePreference(USER_ID)).toBe('ocean')
expect(getColorSchemePreference(USER_ID)).toBe('light') expect(getColorSchemePreference(USER_ID)).toBe('light')
}) })
it('stores AI authorization preference per user', () => {
localStorage.setItem('active_userid', USER_ID)
expect(getAiAuthorized()).toBe(false)
setAiAuthorized(USER_ID, true)
expect(getAiAuthorized()).toBe(true)
expect(getAiAuthorized(USER_ID)).toBe(true)
expect(getAiAuthorized('other-user')).toBe(false)
})
}) })
+17
View File
@@ -89,3 +89,20 @@ export function setOwmApiKey(userId: string, value: string): void {
localStorage.removeItem(owmKey(userId)) localStorage.removeItem(owmKey(userId))
} }
} }
function aiAuthorizedKey(userId: string): string {
return `user_pref_ai_authorized_${userId}`
}
export function getAiAuthorized(userId?: string | null): boolean {
const id = resolveUserId(userId)
if (id) {
return localStorage.getItem(aiAuthorizedKey(id)) === 'true'
}
return false
}
export function setAiAuthorized(userId: string, value: boolean): void {
localStorage.setItem(aiAuthorizedKey(userId), String(value))
}
+155
View File
@@ -0,0 +1,155 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { encryptJson, decryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
async function getEncryptionKey(logbookId: string): Promise<ArrayBuffer> {
const key = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!key) throw new Error('Encryption key not found. Please log in.')
return key
}
export async function saveEntryVoiceMemo(options: {
logbookId: string
entryId: string
audioDataUrl: string
mimeType: string
durationSec: number
caption?: string
transcribed?: boolean
analyticsContext?: string
}): Promise<string> {
const {
logbookId,
entryId,
audioDataUrl,
mimeType,
durationSec,
caption = '',
transcribed = true,
analyticsContext = 'logbook'
} = options
const masterKey = await getEncryptionKey(logbookId)
const voiceId = window.crypto.randomUUID()
const voicePayload = {
audio: audioDataUrl,
mimeType,
durationSec,
caption: caption.trim(),
transcribed: !!transcribed
}
const encrypted = await encryptJson(voicePayload, masterKey)
const now = new Date().toISOString()
await db.voiceMemos.put({
payloadId: voiceId,
entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'create',
type: 'voiceMemo',
payloadId: voiceId,
logbookId,
data: JSON.stringify({
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
entryId
}),
updatedAt: now
})
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_UPLOADED, { context: analyticsContext })
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
return voiceId
}
export async function deleteEntryVoiceMemo(logbookId: string, voiceId: string): Promise<void> {
const now = new Date().toISOString()
await db.voiceMemos.delete(voiceId)
await db.syncQueue.put({
action: 'delete',
type: 'voiceMemo',
payloadId: voiceId,
logbookId,
data: '',
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
/** Deletes the newest voice memo for an entry; returns its id or null. */
export async function removeLastVoiceMemoForEntry(
logbookId: string,
entryId: string
): Promise<string | null> {
const memos = await db.voiceMemos.where({ entryId }).toArray()
if (memos.length === 0) return null
memos.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
)
const lastId = memos[0].payloadId
await deleteEntryVoiceMemo(logbookId, lastId)
return lastId
}
/** Updates an existing voice memo payload with a new transcript and sets transcribed: true. */
export async function updateVoiceMemoTranscript(
logbookId: string,
voiceId: string,
transcript: string
): Promise<void> {
const masterKey = await getEncryptionKey(logbookId)
const record = await db.voiceMemos.get(voiceId)
if (!record) throw new Error('Voice memo not found')
const decrypted = await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)
if (!decrypted) throw new Error('Failed to decrypt voice memo')
const manualCaption = decrypted.caption ? String(decrypted.caption).trim() : ''
const finalCaption = manualCaption
? `${manualCaption}\n(Transkript: ${transcript.trim()})`
: transcript.trim()
const updatedPayload = {
...decrypted,
caption: finalCaption,
transcribed: true
}
const encrypted = await encryptJson(updatedPayload, masterKey)
const now = new Date().toISOString()
await db.voiceMemos.put({
...record,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'update',
type: 'voiceMemo',
payloadId: voiceId,
logbookId,
data: JSON.stringify({
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
entryId: record.entryId
}),
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
+58
View File
@@ -44,6 +44,17 @@ describe('fetchOpenWeatherCurrent', () => {
}) })
}) })
it('throws OFFLINE when navigator.onLine is false', async () => {
vi.stubGlobal('navigator', { ...navigator, onLine: false })
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
expect(err).toBeInstanceOf(WeatherApiError)
expect((err as InstanceType<typeof WeatherApiError>).code).toBe('OFFLINE')
expect(apiFetch).not.toHaveBeenCalled()
})
it('does not track when the API request fails', async () => { it('does not track when the API request fails', async () => {
apiFetch.mockResolvedValue({ apiFetch.mockResolvedValue({
ok: false, ok: false,
@@ -58,4 +69,51 @@ describe('fetchOpenWeatherCurrent', () => {
expect(trackPlausibleEvent).not.toHaveBeenCalled() expect(trackPlausibleEvent).not.toHaveBeenCalled()
}) })
it('throws UNAUTHORIZED when status is 401', async () => {
apiFetch.mockResolvedValue({
ok: false,
status: 401,
json: async () => ({ error: 'Unauthorized' })
})
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
expect(err).toBeInstanceOf(WeatherApiError)
expect((err as any).code).toBe('UNAUTHORIZED')
})
it('throws NOT_FOUND when status is 404', async () => {
apiFetch.mockResolvedValue({
ok: false,
status: 404,
json: async () => ({ error: 'Not Found' })
})
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
expect(err).toBeInstanceOf(WeatherApiError)
expect((err as any).code).toBe('NOT_FOUND')
})
it('throws BAD_REQUEST when status is 400', async () => {
apiFetch.mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ error: 'Bad Request' })
})
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
expect(err).toBeInstanceOf(WeatherApiError)
expect((err as any).code).toBe('BAD_REQUEST')
})
it('throws BAD_REQUEST when coordinates or query are missing', async () => {
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
const err = await fetchOpenWeatherCurrent({}).catch((e) => e)
expect(err).toBeInstanceOf(WeatherApiError)
expect((err as any).code).toBe('BAD_REQUEST')
expect(apiFetch).not.toHaveBeenCalled()
})
}) })
+19 -3
View File
@@ -7,9 +7,12 @@ import {
} from './analytics.js' } from './analytics.js'
export class WeatherApiError extends Error { export class WeatherApiError extends Error {
code: 'NO_KEY' | 'REQUEST_FAILED' code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' | 'UNAUTHORIZED' | 'NOT_FOUND' | 'BAD_REQUEST'
constructor(message: string, code: 'NO_KEY' | 'REQUEST_FAILED' = 'REQUEST_FAILED') { constructor(
message: string,
code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' | 'UNAUTHORIZED' | 'NOT_FOUND' | 'BAD_REQUEST' = 'REQUEST_FAILED'
) {
super(message) super(message)
this.name = 'WeatherApiError' this.name = 'WeatherApiError'
this.code = code this.code = code
@@ -26,6 +29,10 @@ export async function fetchOpenWeatherCurrent(
}, },
options?: { analyticsSource: OwmAnalyticsSource } options?: { analyticsSource: OwmAnalyticsSource }
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
if (!navigator.onLine) {
throw new WeatherApiError('Offline', 'OFFLINE')
}
const searchParams = new URLSearchParams() const searchParams = new URLSearchParams()
if (params.lat && params.lon) { if (params.lat && params.lon) {
@@ -34,7 +41,7 @@ export async function fetchOpenWeatherCurrent(
} else if (params.q?.trim()) { } else if (params.q?.trim()) {
searchParams.set('q', params.q.trim()) searchParams.set('q', params.q.trim())
} else { } else {
throw new WeatherApiError('lat/lon or location query required') throw new WeatherApiError('lat/lon or location query required', 'BAD_REQUEST')
} }
const userKey = getOwmApiKeyForActiveUser().trim() const userKey = getOwmApiKeyForActiveUser().trim()
@@ -61,6 +68,15 @@ export async function fetchOpenWeatherCurrent(
if (res.status === 503) { if (res.status === 503) {
throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY') throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY')
} }
if (res.status === 401) {
throw new WeatherApiError('Invalid OpenWeatherMap API key', 'UNAUTHORIZED')
}
if (res.status === 404) {
throw new WeatherApiError('Location or coordinates not found', 'NOT_FOUND')
}
if (res.status === 400) {
throw new WeatherApiError('Invalid or missing location parameters', 'BAD_REQUEST')
}
const data = await res.json() const data = await res.json()
if (!res.ok) { if (!res.ok) {
+4 -4
View File
@@ -6,6 +6,10 @@ import { NetworkFirst, NetworkOnly } from 'workbox-strategies'
declare let self: ServiceWorkerGlobalScope declare let self: ServiceWorkerGlobalScope
precacheAndRoute(self.__WB_MANIFEST)
cleanupOutdatedCaches()
clientsClaim()
const appShellFallback = createHandlerBoundToURL('/index.html') const appShellFallback = createHandlerBoundToURL('/index.html')
const navigationStrategy = new NetworkFirst({ const navigationStrategy = new NetworkFirst({
cacheName: 'app-shell', cacheName: 'app-shell',
@@ -20,10 +24,6 @@ registerRoute(({ request }) => request.mode === 'navigate', async (context) => {
} }
}) })
precacheAndRoute(self.__WB_MANIFEST)
cleanupOutdatedCaches()
clientsClaim()
// Always fetch the live deploy version, even under an older precache. // Always fetch the live deploy version, even under an older precache.
registerRoute(({ url }) => url.pathname === '/version.json', new NetworkOnly()) registerRoute(({ url }) => url.pathname === '/version.json', new NetworkOnly())
+39
View File
@@ -0,0 +1,39 @@
export const VOICE_MEMO_MAX_DURATION_SEC = 60
export const VOICE_MEMO_MAX_BLOB_BYTES = 800_000
const MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus'
]
export function pickMediaRecorderMimeType(): string | undefined {
if (typeof MediaRecorder === 'undefined') return undefined
for (const mime of MIME_CANDIDATES) {
if (MediaRecorder.isTypeSupported(mime)) return mime
}
return undefined
}
export function blobToAudioDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => reject(new Error('audio_read_failed'))
reader.readAsDataURL(blob)
})
}
export function formatVoiceDuration(seconds: number): string {
const s = Math.max(0, Math.floor(seconds))
const m = Math.floor(s / 60)
const r = s % 60
return `${m}:${String(r).padStart(2, '0')}`
}
export function assertVoiceMemoBlobSize(blob: Blob): void {
if (blob.size > VOICE_MEMO_MAX_BLOB_BYTES) {
throw new Error('VOICE_MEMO_TOO_LARGE')
}
}
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from 'vitest'
import {
cameraErrorKeyFromDomException,
isCameraApiSupported,
probeCameraAvailability
} from './cameraAvailability.js'
describe('cameraAvailability', () => {
it('detects missing camera API', () => {
const nav = { mediaDevices: undefined }
vi.stubGlobal('navigator', nav)
expect(isCameraApiSupported()).toBe(false)
vi.unstubAllGlobals()
})
it('returns none when no videoinput devices', async () => {
vi.stubGlobal('navigator', {
mediaDevices: {
getUserMedia: vi.fn(),
enumerateDevices: vi.fn().mockResolvedValue([
{ kind: 'audioinput', deviceId: 'a1', label: '', groupId: '' }
])
}
})
await expect(probeCameraAvailability()).resolves.toBe('none')
vi.unstubAllGlobals()
})
it('returns available when a videoinput exists', async () => {
vi.stubGlobal('navigator', {
mediaDevices: {
getUserMedia: vi.fn(),
enumerateDevices: vi.fn().mockResolvedValue([
{ kind: 'videoinput', deviceId: 'v1', label: '', groupId: '' }
])
}
})
await expect(probeCameraAvailability()).resolves.toBe('available')
vi.unstubAllGlobals()
})
it('maps NotFoundError to no-camera i18n key', () => {
expect(cameraErrorKeyFromDomException(new DOMException('', 'NotFoundError'))).toBe(
'logs.live_photo_no_camera'
)
})
})
+33
View File
@@ -0,0 +1,33 @@
export type CameraAvailability = 'available' | 'none' | 'unsupported'
/** Whether the browser exposes camera APIs at all. */
export function isCameraApiSupported(): boolean {
return typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia
}
/** Best-effort probe for at least one video input device (no permission prompt). */
export async function probeCameraAvailability(): Promise<CameraAvailability> {
if (!isCameraApiSupported()) return 'unsupported'
if (!navigator.mediaDevices?.enumerateDevices) {
// Cannot list devices; defer to getUserMedia attempt in the capture UI.
return 'available'
}
try {
const devices = await navigator.mediaDevices.enumerateDevices()
if (devices.some((d) => d.kind === 'videoinput')) return 'available'
return 'none'
} catch {
return 'none'
}
}
export function cameraErrorKeyFromDomException(err: unknown): string {
const name = err instanceof DOMException ? err.name : ''
if (name === 'NotFoundError' || name === 'OverconstrainedError') {
return 'logs.live_photo_no_camera'
}
if (name === 'NotAllowedError' || name === 'NotReadableError' || name === 'SecurityError') {
return 'logs.live_photo_camera_denied'
}
return 'logs.live_photo_camera_unavailable'
}
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest'
import { hashEntryForSigning } from './entryCanonicalHash.js'
describe('hashEntryForSigning', () => {
it('excludes aiSummary fields from the signing hash', async () => {
const base = {
date: '2026-06-03',
dayOfTravel: '1',
departure: 'A',
destination: 'B',
freshwater: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
fuel: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
events: []
}
const withoutSummary = await hashEntryForSigning(base)
const withSummary = await hashEntryForSigning({
...base,
aiSummary: 'A calm day at sea.',
aiSummaryGeneratedAt: '2026-06-03T12:00:00.000Z'
})
expect(withSummary).toBe(withoutSummary)
})
})
+2 -1
View File
@@ -1,4 +1,5 @@
const SIGNATURE_KEYS = new Set(['signSkipper', 'signCrew']) const SIGNATURE_KEYS = new Set(['signSkipper', 'signCrew'])
const AI_SUMMARY_KEYS = new Set(['aiSummary', 'aiSummaryGeneratedAt'])
function sortEventsByTime(items: unknown[]): unknown[] { function sortEventsByTime(items: unknown[]): unknown[] {
return [...items] return [...items]
@@ -25,7 +26,7 @@ function sortValue(value: unknown, parentKey?: string): unknown {
const obj = value as Record<string, unknown> const obj = value as Record<string, unknown>
const sorted: Record<string, unknown> = {} const sorted: Record<string, unknown> = {}
for (const key of Object.keys(obj).sort()) { for (const key of Object.keys(obj).sort()) {
if (SIGNATURE_KEYS.has(key)) continue if (SIGNATURE_KEYS.has(key) || AI_SUMMARY_KEYS.has(key)) continue
sorted[key] = sortValue(obj[key], key) sorted[key] = sortValue(obj[key], key)
} }
return sorted return sorted
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { buildEntryListCache, entryListItemFromLocal } from './entryListCache.js'
import type { LocalEntry } from '../services/db.js'
describe('entryListCache', () => {
it('builds cache fields from decrypted entry', async () => {
const cache = await buildEntryListCache({
date: '2026-06-02',
dayOfTravel: '3',
departure: 'Kiel',
destination: 'Laboe',
signSkipper: 'Max'
})
expect(cache).toEqual({
date: '2026-06-02',
dayOfTravel: '3',
departure: 'Kiel',
destination: 'Laboe',
skipperSignStatus: 'valid'
})
})
it('maps cached local entry to list item', () => {
const entry: LocalEntry = {
payloadId: 'e1',
logbookId: 'lb1',
encryptedData: 'x',
iv: 'i',
tag: 't',
updatedAt: '2026-06-02T12:00:00.000Z',
listCache: {
date: '2026-06-02',
dayOfTravel: '1',
departure: 'A',
destination: 'B',
skipperSignStatus: 'none'
}
}
expect(entryListItemFromLocal(entry)).toEqual({
id: 'e1',
date: '2026-06-02',
dayOfTravel: '1',
departure: 'A',
destination: 'B',
updatedAt: '2026-06-02T12:00:00.000Z',
skipperSignStatus: 'none'
})
})
it('returns null when cache is missing', () => {
const entry: LocalEntry = {
payloadId: 'e1',
logbookId: 'lb1',
encryptedData: 'x',
iv: 'i',
tag: 't',
updatedAt: '2026-06-02T12:00:00.000Z'
}
expect(entryListItemFromLocal(entry)).toBeNull()
})
})
+64
View File
@@ -0,0 +1,64 @@
import { db, type EntryListCache, type LocalEntry } from '../services/db.js'
import { getSkipperSignStatus, type SkipperSignStatus } from './signatures.js'
export type { EntryListCache }
export interface EntryListItem {
id: string
date: string
dayOfTravel: string
departure: string
destination: string
updatedAt: string
skipperSignStatus: SkipperSignStatus
}
export async function buildEntryListCache(decrypted: Record<string, unknown>): Promise<EntryListCache> {
return {
date: String(decrypted.date || ''),
dayOfTravel: String(decrypted.dayOfTravel || ''),
departure: String(decrypted.departure || ''),
destination: String(decrypted.destination || ''),
skipperSignStatus: await getSkipperSignStatus(decrypted)
}
}
export function entryListItemFromLocal(entry: LocalEntry): EntryListItem | null {
if (!entry.listCache) return null
return {
id: entry.payloadId,
date: entry.listCache.date,
dayOfTravel: entry.listCache.dayOfTravel,
departure: entry.listCache.departure,
destination: entry.listCache.destination,
updatedAt: entry.updatedAt,
skipperSignStatus: entry.listCache.skipperSignStatus
}
}
export type LocalEntryPut = Omit<LocalEntry, 'listCache'> & { listCache?: EntryListCache }
/** Persist entry ciphertext and optional plaintext list cache for fast journal list loads. */
export async function putEntryRecord(
record: LocalEntryPut,
decryptedForCache?: Record<string, unknown>
): Promise<void> {
const listCache =
record.listCache ??
(decryptedForCache ? await buildEntryListCache(decryptedForCache) : undefined)
await db.entries.put({
...record,
...(listCache ? { listCache } : {})
})
}
/** Backfill list cache after a legacy decrypt — fire-and-forget is fine. */
export function persistEntryListCache(
payloadId: string,
decrypted: Record<string, unknown>
): void {
void buildEntryListCache(decrypted)
.then((listCache) => db.entries.update(payloadId, { listCache }))
.catch((err) => console.warn('Failed to persist entry list cache:', err))
}
+20 -5
View File
@@ -7,6 +7,8 @@ import {
liveSogRemark, liveSogRemark,
parseLiveCommentRemark, parseLiveCommentRemark,
livePhotoRemark, livePhotoRemark,
liveVoiceRemark,
parseLiveVoiceRemark,
parseLiveSailsRemark parseLiveSailsRemark
} from './liveEventCodes.js' } from './liveEventCodes.js'
import { formatEventSummary } from './formatEventSummary.js' import { formatEventSummary } from './formatEventSummary.js'
@@ -19,8 +21,8 @@ const t = (key: string, opts?: Record<string, unknown>) => {
'logs.live_cast_off': 'Cast off', 'logs.live_cast_off': 'Cast off',
'logs.live_moor': 'Moor', 'logs.live_moor': 'Moor',
'logs.live_sails': `Sails: ${opts?.sails ?? ''}`, 'logs.live_sails': `Sails: ${opts?.sails ?? ''}`,
'logs.live_fix': 'Fix', 'logs.live_position': 'Position',
'logs.live_fix_coords': `Fix ${opts?.lat}, ${opts?.lng}`, 'logs.live_position_coords': `Position ${opts?.lat}, ${opts?.lng}`,
'logs.live_event_generic': 'Event', 'logs.live_event_generic': 'Event',
'logs.live_temp_entry': `Temperature ${opts?.temp} °C`, 'logs.live_temp_entry': `Temperature ${opts?.temp} °C`,
'logs.live_pressure_entry': `Pressure ${opts?.value} hPa`, 'logs.live_pressure_entry': `Pressure ${opts?.value} hPa`,
@@ -28,6 +30,7 @@ const t = (key: string, opts?: Record<string, unknown>) => {
'logs.live_wind_entry': `Wind ${opts?.value}`, 'logs.live_wind_entry': `Wind ${opts?.value}`,
'logs.live_photo_entry': `Photo: ${opts?.caption}`, 'logs.live_photo_entry': `Photo: ${opts?.caption}`,
'logs.live_photo_entry_plain': 'Photo captured', 'logs.live_photo_entry_plain': 'Photo captured',
'logs.live_voice_entry_plain': 'Voice memo',
'logs.live_course_entry': `Course ${opts?.course}`, 'logs.live_course_entry': `Course ${opts?.course}`,
'logs.live_sog_entry': `SOG ${opts?.speed} kn`, 'logs.live_sog_entry': `SOG ${opts?.speed} kn`,
'logs.live_stw_entry': `STW ${opts?.speed} kn`, 'logs.live_stw_entry': `STW ${opts?.speed} kn`,
@@ -59,6 +62,12 @@ describe('liveEventCodes', () => {
expect(parseLiveSailsRemark(liveSailsRemark('Main + Genoa'))).toBe('Main + Genoa') expect(parseLiveSailsRemark(liveSailsRemark('Main + Genoa'))).toBe('Main + Genoa')
expect(parseLiveCommentRemark(liveCommentRemark('Wind dreht'))).toBe('Wind dreht') expect(parseLiveCommentRemark(liveCommentRemark('Wind dreht'))).toBe('Wind dreht')
}) })
it('parses voice remark with uuid', () => {
const id = 'a1b2c3d4-e5f6-4789-a012-3456789abcde'
expect(parseLiveVoiceRemark(liveVoiceRemark(id))).toBe(id)
expect(parseLiveVoiceRemark('__live:voice:not-a-uuid')).toBeNull()
})
}) })
describe('formatEventSummary', () => { describe('formatEventSummary', () => {
@@ -76,14 +85,14 @@ describe('formatEventSummary', () => {
expect(formatEventSummary(event, t)).toBe('Sails: Main + Genoa') expect(formatEventSummary(event, t)).toBe('Sails: Main + Genoa')
}) })
it('formats fix with coordinates', () => { it('formats position with coordinates', () => {
const event = normalizeLogEvent({ const event = normalizeLogEvent({
time: '09:00', time: '09:00',
remarks: LIVE_EVENT_CODES.FIX, remarks: LIVE_EVENT_CODES.POSITION,
gpsLat: '54.323000', gpsLat: '54.323000',
gpsLng: '10.145000' gpsLng: '10.145000'
}) })
expect(formatEventSummary(event, t)).toBe('Fix 54.323000, 10.145000') expect(formatEventSummary(event, t)).toBe('Position 54.323000, 10.145000')
}) })
it('formats pressure entry', () => { it('formats pressure entry', () => {
@@ -130,4 +139,10 @@ describe('formatEventSummary', () => {
}) })
expect(formatEventSummary(captioned, t)).toBe('Photo: Mastbruch') expect(formatEventSummary(captioned, t)).toBe('Photo: Mastbruch')
}) })
it('formats voice memo entry', () => {
const id = 'a1b2c3d4-e5f6-4789-a012-3456789abcde'
const event = normalizeLogEvent({ time: '12:00', remarks: liveVoiceRemark(id) })
expect(formatEventSummary(event, t)).toBe('Voice memo')
})
}) })

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