Compare commits

...

55 Commits

Author SHA1 Message Date
elpatron 182ea497d8 chore: release v0.1.0.85 2026-06-01 19:43:24 +02:00
elpatron 837bcfe287 fix(ui): Disclaimer-Modal Scroll und Schließen-Button
Ein Scrollbereich im Hinweis-Dialog, Body-Lock beim Öffnen, X oben rechts an der Karte.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 17:20:39 +02:00
elpatron beee33f842 Update plausible-events.md to specify that recommended goal chains are for business use only. 2026-05-31 16:41:16 +02:00
135 changed files with 13376 additions and 722 deletions
+18 -5
View File
@@ -5,13 +5,26 @@ OpenWeatherMapAPIKey=<owm_api_key>
DeepLAPIKey=
# Passkey configuration (WebAuthn Relying Party ID and Origin)
# For local dev: localhost and http://localhost
# For production: e.g. kapteins-daagbok.eu and https://kapteins-daagbok.eu
# For local dev: use localhost (NOT 127.0.0.1 — browsers reject IP addresses for Passkeys)
# Production (kapteins-daagbok.eu):
# RP_ID=kapteins-daagbok.eu
# ORIGIN=https://kapteins-daagbok.eu
RP_ID=localhost
# Must match the frontend URL (Vite dev: http://localhost:5173; Docker: http://localhost)
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
ORIGIN=http://localhost:5173
# Optional: comma-separated CORS origins (defaults to ORIGIN; dev also allows 127.0.0.1:5173)
# CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# Behind Nginx Proxy Manager — see docs/deployment/npm-security.md
# TRUST_PROXY=172.16.10.10
# TRUST_PROXY=1
# Docker Compose database (required for production deploy)
# Generate: openssl rand -hex 24
# Rotate on running server: ./scripts/rotate-postgres-password.sh (see docs/deployment/postgres-password.md)
# POSTGRES_USER=postgres
# POSTGRES_PASSWORD=
# POSTGRES_DB=daagbox
# Optional: comma-separated CORS origins (defaults to ORIGIN; 127.0.0.1 may be allowed for CORS but not for login)
# CORS_ORIGINS=http://localhost:5173
# API session signing (min. 32 chars; required in production)
# Generate: openssl rand -base64 48
+2
View File
@@ -11,3 +11,5 @@ server/dist/
.env.local
.env.*.local
*.log
userfeedback/
+18 -6
View File
@@ -219,13 +219,19 @@ cd server && npx prisma db push && cd ..
| Health Check | http://localhost:5000/api/health |
| Public Demo | http://localhost:5173/demo |
### 5. Tests (Frontend)
### 5. Qualität & Tests
Vor jedem Deploy auf [kapteins-daagbok.eu](https://kapteins-daagbok.eu/) (kein externes CI):
```bash
cd client && npm test
npm run check
# oder: ./scripts/predeploy-check.sh
```
Vitest-Unit-Tests für Utils, i18n und Services (z. B. Kurswinkel, Benutzereinstellungen).
Einzeln: `npm test` (Client + Server) · `npm run build` · optional `npm run lint` (Client, noch nicht in `check`)
- **Client:** Vitest für Utils, i18n, Services
- **Server:** Smoke-Tests (`/api/health`, Auth-Guards) mit Supertest — siehe `server/src/api.smoke.test.ts`
## Docker (produktionsnah)
@@ -237,11 +243,12 @@ Gesamten Stack lokal bauen und starten:
Frontend: http://localhost · API: http://localhost/api/health · Demo: http://localhost/demo
Umgebungsvariablen in `.env` setzen — mindestens `RP_ID`, `ORIGIN` (z. B. `http://localhost`) und `SESSION_SECRET`. Für Push die VAPID-Variablen an den Backend-Container durchreichen (`docker-compose.yml``backend.environment`). Für Feedback `NTFY_*` setzen.
Umgebungsvariablen in `.env` setzen — mindestens `RP_ID`, `ORIGIN` (z. B. `http://localhost`), `SESSION_SECRET` und für Docker Compose `POSTGRES_PASSWORD`. Für Push die VAPID-Variablen an den Backend-Container durchreichen (`docker-compose.yml``backend.environment`). Für Feedback `NTFY_*` setzen.
## Deployment
Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen):
Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen). Führt vor dem SSH-Deploy automatisch [`predeploy-check.sh`](scripts/predeploy-check.sh) aus (`npm run check`):
```bash
./scripts/update-prod.sh
@@ -249,12 +256,17 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen):
Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
Auf dem Server müssen `server/.env` (oder gleichwertige Umgebung) u. a. `DATABASE_URL`, `RP_ID`, `ORIGIN`, `SESSION_SECRET` (≥ 32 Zeichen) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
Auf dem Server müssen `.env` u. a. `POSTGRES_PASSWORD`, `RP_ID`, `ORIGIN` (`https://kapteins-daagbok.eu`), `SESSION_SECRET` (≥ 32 Zeichen), `TRUST_PROXY` (NPM, z. B. `172.16.10.10` oder `1`) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deployment/npm-security.md).
## Dokumentation
| Dokument | Inhalt |
|----------|--------|
| [docs/deployment/npm-security.md](docs/deployment/npm-security.md) | NPM, TLS, `trust proxy`, Security-Header |
| [docs/deployment/predeploy.md](docs/deployment/predeploy.md) | Pre-Deploy-Checks ohne CI |
| [docs/deployment/postgres-password.md](docs/deployment/postgres-password.md) | PostgreSQL-Passwort rotieren / App-Rolle |
| [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics |
| [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan |
| [docs/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan |
+1 -1
View File
@@ -1 +1 @@
0.1.0.72
0.1.0.86
+19 -2
View File
@@ -3,15 +3,32 @@ server {
server_name localhost;
client_max_body_size 50M;
# Security headers (TLS/HSTS at NPM — see docs/deployment/npm-security.md)
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" 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";
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";
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 / {
+12 -30
View File
@@ -17,6 +17,7 @@
"jspdf": "^4.2.1",
"leaflet": "^1.9.4",
"lucide-react": "^1.16.0",
"qrcode": "^1.5.4",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.8"
@@ -25,6 +26,7 @@
"@eslint/js": "^10.0.1",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.12.3",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0",
@@ -34,7 +36,6 @@
"globals": "^17.6.0",
"happy-dom": "^20.9.0",
"playwright": "^1.51.0",
"qrcode": "^1.5.4",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^6.3.5",
@@ -2970,6 +2971,16 @@
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
@@ -3461,7 +3472,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -3471,7 +3481,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -3777,7 +3786,6 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -3855,7 +3863,6 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
@@ -3867,7 +3874,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -3880,7 +3886,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/commander": {
@@ -4051,7 +4056,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -4140,7 +4144,6 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"dev": true,
"license": "MIT"
},
"node_modules/dompurify": {
@@ -4195,7 +4198,6 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
@@ -4948,7 +4950,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -5498,7 +5499,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -6208,7 +6208,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -6231,7 +6230,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -6376,7 +6374,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.13.0"
@@ -6458,7 +6455,6 @@
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
@@ -6653,7 +6649,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -6673,7 +6668,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"dev": true,
"license": "ISC"
},
"node_modules/resolve": {
@@ -6845,7 +6839,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"dev": true,
"license": "ISC"
},
"node_modules/set-function-length": {
@@ -7113,7 +7106,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -7230,7 +7222,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -8067,7 +8058,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"dev": true,
"license": "ISC"
},
"node_modules/which-typed-array": {
@@ -8343,7 +8333,6 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
@@ -8380,7 +8369,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"dev": true,
"license": "ISC"
},
"node_modules/yallist": {
@@ -8394,7 +8382,6 @@
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
@@ -8417,7 +8404,6 @@
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
@@ -8431,7 +8417,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
@@ -8445,7 +8430,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
@@ -8458,7 +8442,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
@@ -8474,7 +8457,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
+3 -2
View File
@@ -29,12 +29,14 @@
"lucide-react": "^1.16.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.8"
"react-i18next": "^17.0.8",
"qrcode": "^1.5.4"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.12.3",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0",
@@ -44,7 +46,6 @@
"globals": "^17.6.0",
"happy-dom": "^20.9.0",
"playwright": "^1.51.0",
"qrcode": "^1.5.4",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^6.3.5",
+906 -8
View File
File diff suppressed because it is too large Load Diff
+106 -18
View File
@@ -4,7 +4,9 @@ import AuthOnboarding from './components/AuthOnboarding.tsx'
import UserProfilePage from './components/UserProfilePage.tsx'
import LogbookDashboard from './components/LogbookDashboard.tsx'
import VesselForm from './components/VesselForm.tsx'
import CrewForm from './components/CrewForm.tsx'
import LogbookCrewPicker from './components/LogbookCrewPicker.tsx'
import { migrateLegacyCrewToPoolIfNeeded } from './services/crewMigration.js'
import { syncPersonPool } from './services/personPoolSync.js'
// Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert (Komponente bleibt erhalten)
// import DeviationForm from './components/DeviationForm.tsx'
import LogEntriesList from './components/LogEntriesList.tsx'
@@ -44,6 +46,7 @@ import { useLiveQuery } from 'dexie-react-hooks'
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react'
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
import ProfileHeaderButton from './components/ProfileHeaderButton.tsx'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage } from './utils/i18nLanguages.js'
import {
@@ -52,6 +55,8 @@ import {
} from './services/demoLogbook.js'
import { fetchLogbooks, parseCollaborationRole } from './services/logbook.js'
import { ensurePushSubscriptionIfEnabled } from './services/pushNotifications.js'
import SyncConflictBanner from './components/SyncConflictBanner.tsx'
import { requestPersistentStorage } from './utils/storagePersist.js'
const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
@@ -70,6 +75,7 @@ function App() {
const [isSyncing, setIsSyncing] = useState(false)
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
const [showUserProfile, setShowUserProfile] = useState(false)
const [storagePersistHint, setStoragePersistHint] = useState(false)
const tourLogbookRef = useRef<{ id: string; title: string } | null>(null)
const activeLogbookRef = useRef<{ id: string | null; title: string | null }>({
id: activeLogbookId,
@@ -157,6 +163,7 @@ function App() {
const userId = localStorage.getItem('active_userid')
if (!userId) return
void syncAppearancePrefs(userId)
void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool())
}, [isAuthenticated])
useEffect(() => {
@@ -427,10 +434,19 @@ function App() {
return () => navigator.serviceWorker.removeEventListener('message', onSwMessage)
}, [isAuthenticated, openLogbookById])
useEffect(() => {
if (!isAuthenticated) return
if (sessionStorage.getItem('storage_persist_hint_dismissed')) return
void requestPersistentStorage().then(({ persisted, supported }) => {
if (supported && !persisted) setStoragePersistHint(true)
})
}, [isAuthenticated])
const handleAuthenticated = async () => {
setIsAuthenticated(true)
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
void ensurePushSubscriptionIfEnabled()
void requestPersistentStorage()
try {
const demo = await seedDemoLogbookIfNeeded()
@@ -557,22 +573,27 @@ function App() {
const isLogbookOwner =
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1
if (showUserProfile) {
return (
<div style={{ display: 'contents' }}>
{pwaInstallBanner}
<UserProfilePage
onBack={() => setShowUserProfile(false)}
onLogout={handleLogout}
/>
</div>
)
}
if (!activeLogbookId) {
return (
<div style={{ display: 'contents' }}>
{pwaInstallBanner}
{showUserProfile ? (
<UserProfilePage
onBack={() => setShowUserProfile(false)}
onLogout={handleLogout}
/>
) : (
<LogbookDashboard
onSelectLogbook={selectLogbook}
onLogout={handleLogout}
onOpenProfile={() => setShowUserProfile(true)}
/>
)}
<LogbookDashboard
onSelectLogbook={selectLogbook}
onLogout={handleLogout}
onOpenProfile={() => setShowUserProfile(true)}
/>
</div>
)
}
@@ -600,7 +621,7 @@ function App() {
<p className="app-subtitle">
{activeAccessRole && activeAccessRole !== 'OWNER'
? t('dashboard.section_shared_hint')
: `${t('app.name')} / ${activeLogbookId?.substring(0, 8)}...`}
: t('app.tagline')}
</p>
</div>
</div>
@@ -622,6 +643,8 @@ function App() {
<Languages size={18} />
</button>
<ProfileHeaderButton onClick={() => setShowUserProfile(true)} />
<DisclaimerHeaderButton />
<FeedbackHeaderButton
@@ -638,10 +661,28 @@ function App() {
</div>
</header>
<SyncConflictBanner logbookId={activeLogbookId} />
{storagePersistHint && (
<div className="storage-persist-hint glass" role="status">
<p>{t('pwa.storage_persist_hint')}</p>
<button
type="button"
className="btn secondary"
onClick={() => {
sessionStorage.setItem('storage_persist_hint_dismissed', '1')
setStoragePersistHint(false)
}}
>
{t('pwa.later')}
</button>
</div>
)}
{/* Active Workspace */}
<div className="app-body">
{/* Navigation Sidebar */}
<aside className="app-sidebar">
<aside className="app-sidebar" aria-label={t('nav.dashboard')}>
<button
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
onClick={() => void handleTabChange('logs')}
@@ -663,7 +704,7 @@ function App() {
<button
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => void handleTabChange('crew')}
data-tour="nav-crew"
data-tour="nav-logbook-crew"
>
<Users size={18} />
{t('nav.crew')}
@@ -714,10 +755,10 @@ function App() {
)}
{activeTab === 'crew' && (
<CrewForm
<LogbookCrewPicker
logbookId={activeLogbookId}
readOnly={logbookReadOnly}
skipperReadOnly={!isLogbookOwner}
selectionOnly={!isLogbookOwner && activeLogbookRecord?.isShared === 1}
/>
)}
@@ -738,6 +779,53 @@ function App() {
/>
)}
</main>
<nav className="app-bottom-nav" aria-label={t('nav.dashboard')}>
<button
type="button"
className={`bottom-nav-btn ${activeTab === 'logs' ? 'active' : ''}`}
onClick={() => void handleTabChange('logs')}
data-tour="nav-logs"
>
<FileText size={20} />
<span>{t('nav.logs')}</span>
</button>
<button
type="button"
className={`bottom-nav-btn ${activeTab === 'vessel' ? 'active' : ''}`}
onClick={() => void handleTabChange('vessel')}
data-tour="nav-vessel"
>
<Ship size={20} />
<span>{t('nav.vessel')}</span>
</button>
<button
type="button"
className={`bottom-nav-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => void handleTabChange('crew')}
data-tour="nav-logbook-crew"
>
<Users size={20} />
<span>{t('nav.crew')}</span>
</button>
<button
type="button"
className={`bottom-nav-btn ${activeTab === 'stats' ? 'active' : ''}`}
onClick={() => void handleTabChange('stats')}
data-tour="nav-stats"
>
<BarChart2 size={20} />
<span>{t('nav.stats')}</span>
</button>
<button
type="button"
className={`bottom-nav-btn ${activeTab === 'settings' ? 'active' : ''}`}
onClick={() => void handleTabChange('settings')}
>
<Settings size={20} />
<span>{t('nav.settings')}</span>
</button>
</nav>
</div>
</div>
</div>
+76 -18
View File
@@ -1,21 +1,28 @@
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import {
registerUser,
loginUser,
completeLoginWithRecovery,
setLocalPin,
hasLocalPin,
decryptWithLocalPin,
import {
registerUser,
loginUser,
completeLoginWithRecovery,
setLocalPin,
hasLocalPin,
decryptWithLocalPin,
getActiveMasterKey,
getKnownUsernames,
forgetUsername
forgetUsername,
hasUnlockedLocalSession,
logoutUser
} from '../services/auth.js'
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
import DisclaimerModal from './DisclaimerModal.tsx'
import BetaBadge from './BetaBadge.tsx'
import {
isPasskeyCompatibleLocation,
localizeWebAuthnError,
toPasskeyCompatibleUrl
} from '../utils/passkeyHost.ts'
interface AuthOnboardingProps {
onAuthenticated: () => void
@@ -54,6 +61,16 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
const [showDisclaimer, setShowDisclaimer] = useState(false)
const [showHelp, setShowHelp] = useState(false)
const passkeyHostOk = isPasskeyCompatibleLocation()
const passkeyCompatibleUrl = passkeyHostOk ? null : toPasskeyCompatibleUrl(window.location.href)
const formatAuthError = (message: string) =>
localizeWebAuthnError(message, {
invalidHost: t('auth.error_invalid_host'),
cancelled: t('auth.error_passkey_cancelled'),
invalidRpId: t('auth.error_invalid_rp_id')
})
const finishAuth = () => {
if (isNewRegistration) {
setShowDisclaimer(true)
@@ -81,7 +98,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
setRecoveryPhrase(result.recoveryPhrase)
}
} catch (err: any) {
setError(err.message || 'Registration failed')
setError(formatAuthError(err.message || 'Registration failed'))
} finally {
setLoading(false)
}
@@ -121,7 +138,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
}
}
} catch (err: any) {
setError(err.message || 'Login failed')
setError(formatAuthError(err.message || 'Login failed'))
} finally {
setLoading(false)
}
@@ -185,19 +202,33 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
const handlePinLoginSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!pinLoginInput.trim()) return
if (!pinLoginInput.trim() || loading) return
const resolvedUser =
username.trim() ||
encryptedPayloads?.username ||
localStorage.getItem('active_username') ||
''
if (!resolvedUser) {
setError(t('auth.error_session_incomplete'))
return
}
setLoading(true)
setError(null)
try {
const resolvedUser = username.trim() || encryptedPayloads?.username
const key = await decryptWithLocalPin(pinLoginInput.trim(), resolvedUser)
if (key) {
onAuthenticated()
} else {
if (!key) {
setError(t('auth.error_incorrect_pin'))
return
}
} catch (err: any) {
if (!hasUnlockedLocalSession()) {
setError(t('auth.error_session_incomplete'))
return
}
setShowPinLogin(false)
onAuthenticated()
} catch {
setError(t('auth.error_incorrect_pin'))
} finally {
setLoading(false)
@@ -361,6 +392,24 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
>
{t('auth.use_recovery_instead')}
</button>
<button
type="button"
className="btn secondary"
onClick={() => {
void (async () => {
setShowPinLogin(false)
setPinLoginInput('')
setEncryptedPayloads(null)
setError(null)
await logoutUser()
})()
}}
disabled={loading}
style={{ width: '100%' }}
>
{t('auth.back')}
</button>
</div>
</form>
</div>
@@ -445,12 +494,21 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
</div>
<div className="auth-form" style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: '20px' }}>
{!passkeyHostOk && passkeyCompatibleUrl && (
<div className="auth-error" role="alert">
<p style={{ margin: '0 0 8px' }}>{t('auth.error_invalid_host')}</p>
<a href={passkeyCompatibleUrl} className="btn secondary" style={{ display: 'inline-block', textDecoration: 'none' }}>
{t('auth.use_localhost_link')}
</a>
</div>
)}
{/* Prominent Login button */}
<button
type="button"
className="btn primary"
onClick={() => handleLogin()}
disabled={loading}
disabled={loading || !passkeyHostOk}
style={{ width: '100%', padding: '16px' }}
>
{loading
@@ -583,7 +641,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
<button
type="submit"
className="btn secondary"
disabled={loading || !username.trim()}
disabled={loading || !username.trim() || !passkeyHostOk}
style={{ width: '100%' }}
>
{t('auth.register')}
+2 -1
View File
@@ -6,6 +6,7 @@ import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { MAX_POOL_CREW_MEMBERS } from '../types/person.js'
import { useDialog } from './ModalDialog.tsx'
import { Users, User, Plus, Trash2, Edit2, Save, X, Check, Camera } from 'lucide-react'
@@ -603,7 +604,7 @@ export default function CrewForm({
<Users size={24} className="form-icon" />
<h2>{t('crew.crew_section')}</h2>
</div>
{!readOnly && crewList.length < 5 && !showMemberForm && (
{!readOnly && crewList.length < MAX_POOL_CREW_MEMBERS && !showMemberForm && (
<button className="btn primary" onClick={openAddMember} style={{ width: 'auto', padding: '8px 16px' }}>
<Plus size={16} />
{t('crew.add_crew')}
+23 -4
View File
@@ -2,7 +2,9 @@ import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import VesselForm from './VesselForm.tsx'
import CrewForm from './CrewForm.tsx'
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
import type { LogbookCrewSelectionData } from '../types/person.js'
import { personToSnapshot } from '../utils/personSnapshots.js'
import LogEntriesList from './LogEntriesList.tsx'
import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react'
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
@@ -52,7 +54,19 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
cycleAppLanguage(i18n)
}
const { title, yacht, crews, entries, gpsTracks, photos, firstEntryId } = fixture
const { title, yacht, personPool, logbookCrewSelection, entries, gpsTracks, photos, firstEntryId } =
fixture
const demoSelection: LogbookCrewSelectionData = {
activeSkipperId: logbookCrewSelection.activeSkipperId,
activeCrewIds: logbookCrewSelection.activeCrewIds,
snapshotsById: Object.fromEntries(
Object.entries(logbookCrewSelection.snapshotsById).map(([id, snap]) => [
id,
personToSnapshot(id, snap)
])
)
}
return (
<div className="app-layout">
@@ -115,7 +129,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
<button
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
onClick={() => setActiveTab('crew')}
data-tour="nav-crew"
data-tour="nav-logbook-crew"
>
<Users size={18} />
{t('nav.crew')}
@@ -142,7 +156,12 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
)}
{activeTab === 'crew' && (
<CrewForm logbookId="demo" readOnly={true} preloadedData={crews} />
<LogbookCrewPicker
logbookId="demo"
readOnly={true}
preloadedPool={personPool}
preloadedSelection={demoSelection}
/>
)}
</main>
</div>
+6 -1
View File
@@ -13,7 +13,12 @@ export default function DisclaimerModal({ open, onClose }: DisclaimerModalProps)
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
window.removeEventListener('keydown', onKeyDown)
document.body.style.overflow = prevOverflow
}
}, [open, onClose])
if (!open) return null
+168
View File
@@ -0,0 +1,168 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Users } from 'lucide-react'
import type { EntryCrewFields, PersonSnapshot } from '../types/person.js'
import { loadPersonPool } from '../services/personPool.js'
import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js'
import { buildSnapshotsForSelection } from '../utils/personSnapshots.js'
import type { PersonData } from '../types/person.js'
export interface EntryCrewSectionProps {
logbookId: string
readOnly?: boolean
value: EntryCrewFields
onChange: (next: EntryCrewFields) => void
/** Demo: fixed pool */
preloadedPool?: Map<string, PersonData>
}
export default function EntryCrewSection({
logbookId,
readOnly = false,
value,
onChange,
preloadedPool
}: EntryCrewSectionProps) {
const { t } = useTranslation()
const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map())
useEffect(() => {
if (preloadedPool) {
setPool(preloadedPool)
return
}
let cancelled = false
void (async () => {
try {
const people = await loadPersonPool()
if (cancelled) return
setPool(new Map(people.map((p) => [p.payloadId, p.data])))
} catch {
/* use snapshots only */
}
})()
return () => {
cancelled = true
}
}, [preloadedPool])
const displayPool = useMemo(() => {
const merged = new Map(pool)
for (const snap of Object.values(value.crewSnapshotsById)) {
if (!merged.has(snap.id)) {
merged.set(snap.id, {
name: snap.name,
address: snap.address,
birthDate: snap.birthDate,
phone: snap.phone,
nationality: snap.nationality,
passportNumber: snap.passportNumber,
bloodType: snap.bloodType,
allergies: snap.allergies,
diseases: snap.diseases,
role: snap.role,
photo: snap.photo
})
}
}
return merged
}, [pool, value.crewSnapshotsById])
const skippers = [...displayPool.entries()].filter(([, d]) => d.role === 'skipper')
const crewEntries = [...displayPool.entries()].filter(([, d]) => d.role === 'crew')
const applyChange = (skipperId: string | null, crewIds: string[]) => {
const snapshots = buildSnapshotsForSelection(skipperId, crewIds, displayPool)
onChange({
selectedSkipperId: skipperId,
selectedCrewIds: crewIds,
crewSnapshotsById: snapshots
})
}
const toggleCrew = (id: string) => {
if (readOnly) return
const next = value.selectedCrewIds.includes(id)
? value.selectedCrewIds.filter((x) => x !== id)
: [...value.selectedCrewIds, id]
applyChange(value.selectedSkipperId, next)
}
return (
<div className="form-card" data-tour="entry-crew">
<div className="form-header">
<Users size={22} className="form-icon" />
<h3>{t('entry_crew.title')}</h3>
</div>
<p className="help-text mb-3">{t('entry_crew.subtitle')}</p>
<div className="input-group mb-3">
<label>{t('entry_crew.day_skipper')}</label>
{skippers.length === 0 ? (
<p className="help-text">{t('entry_crew.no_skipper')}</p>
) : (
<div className="crew-selection-list">
{skippers.map(([id, data]) => (
<label key={id} className="crew-selection-item">
<input
type="radio"
name={`entry-skipper-${logbookId}`}
checked={value.selectedSkipperId === id}
onChange={() => !readOnly && applyChange(id, value.selectedCrewIds)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)}
</div>
<div className="input-group">
<label>{t('entry_crew.day_crew')}</label>
{crewEntries.length === 0 ? (
<p className="help-text">{t('entry_crew.no_crew')}</p>
) : (
<div className="crew-selection-list">
{crewEntries.map(([id, data]) => (
<label key={id} className="crew-selection-item">
<input
type="checkbox"
checked={value.selectedCrewIds.includes(id)}
onChange={() => toggleCrew(id)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)}
</div>
</div>
)
}
export async function loadDefaultEntryCrewForNewDay(
logbookId: string,
previousEntry: Record<string, unknown> | null
): Promise<EntryCrewFields> {
if (previousEntry) {
const selectedSkipperId =
typeof previousEntry.selectedSkipperId === 'string' ? previousEntry.selectedSkipperId : null
const selectedCrewIds = Array.isArray(previousEntry.selectedCrewIds)
? previousEntry.selectedCrewIds.filter((id): id is string => typeof id === 'string')
: []
const crewSnapshotsById =
previousEntry.crewSnapshotsById && typeof previousEntry.crewSnapshotsById === 'object'
? (previousEntry.crewSnapshotsById as Record<string, PersonSnapshot>)
: {}
return { selectedSkipperId, selectedCrewIds, crewSnapshotsById }
}
const selection = await loadLogbookCrewSelection(logbookId)
return {
selectedSkipperId: selection.activeSkipperId,
selectedCrewIds: [...selection.activeCrewIds],
crewSnapshotsById: { ...selection.snapshotsById }
}
}
+54
View File
@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import QRCode from 'qrcode'
interface LinkQrCodeProps {
value: string
size?: number
}
export default function LinkQrCode({ value, size = 200 }: LinkQrCodeProps) {
const { t } = useTranslation()
const [dataUrl, setDataUrl] = useState<string | null>(null)
useEffect(() => {
if (!value.trim()) {
setDataUrl(null)
return
}
let cancelled = false
void QRCode.toDataURL(value, {
width: size,
margin: 2,
errorCorrectionLevel: 'M',
color: { dark: '#0f172a', light: '#ffffff' }
})
.then((url) => {
if (!cancelled) setDataUrl(url)
})
.catch((err) => {
console.error('QR code generation failed:', err)
if (!cancelled) setDataUrl(null)
})
return () => {
cancelled = true
}
}, [value, size])
if (!value.trim() || !dataUrl) return null
return (
<div className="link-qr-block">
<p className="link-qr-label">{t('settings.link_qr_hint')}</p>
<img
src={dataUrl}
width={size}
height={size}
className="link-qr-image"
alt={t('settings.link_qr_alt')}
/>
</div>
)
}
+323
View File
@@ -0,0 +1,323 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Camera, X } from 'lucide-react'
import {
captureVideoFrame,
preferNativeCameraPicker
} from '../utils/captureVideoFrame.js'
interface LiveCameraCaptureProps {
open: boolean
busy?: boolean
caption?: string
onCaptionChange?: (value: string) => void
onClose: () => void
onCapture: (blob: Blob) => void
}
type Phase = 'live' | 'preview' | 'native'
export default function LiveCameraCapture({
open,
busy = false,
caption = '',
onCaptionChange,
onClose,
onCapture
}: LiveCameraCaptureProps) {
const { t } = useTranslation()
const videoRef = useRef<HTMLVideoElement | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const previewUrlRef = useRef<string | null>(null)
const [cameraError, setCameraError] = useState<string | null>(null)
const [ready, setReady] = useState(false)
const [capturing, setCapturing] = useState(false)
const [phase, setPhase] = useState<Phase>(() => (preferNativeCameraPicker() ? 'native' : 'live'))
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
const [streamGeneration, setStreamGeneration] = useState(0)
const clearPreview = useCallback(() => {
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current)
previewUrlRef.current = null
}
setPreviewUrl(null)
setPreviewBlob(null)
}, [])
const stopStream = useCallback(() => {
for (const track of streamRef.current?.getTracks() ?? []) {
track.stop()
}
streamRef.current = null
if (videoRef.current) {
videoRef.current.srcObject = null
}
setReady(false)
}, [])
const enterPreview = useCallback((blob: Blob) => {
stopStream()
clearPreview()
const url = URL.createObjectURL(blob)
previewUrlRef.current = url
setPreviewBlob(blob)
setPreviewUrl(url)
setPhase('preview')
}, [stopStream, clearPreview])
const resetToLive = useCallback(() => {
clearPreview()
setCameraError(null)
setCapturing(false)
if (preferNativeCameraPicker()) {
setPhase('native')
} else {
setPhase('live')
setStreamGeneration((n) => n + 1)
}
}, [clearPreview])
useEffect(() => {
if (!open) {
stopStream()
clearPreview()
setCameraError(null)
setCapturing(false)
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
return
}
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
clearPreview()
}, [open, stopStream, clearPreview])
useEffect(() => {
if (!open || phase !== 'live') {
stopStream()
return
}
let cancelled = false
const start = async () => {
setCameraError(null)
setReady(false)
if (!navigator.mediaDevices?.getUserMedia) {
setCameraError(t('logs.live_photo_camera_unavailable'))
return
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: { ideal: 'environment' },
width: { ideal: 1280 },
height: { ideal: 720 }
},
audio: false
})
if (cancelled) {
for (const track of stream.getTracks()) track.stop()
return
}
streamRef.current = stream
const video = videoRef.current
if (!video) return
const markReady = () => {
if (cancelled) return
if (video.videoWidth > 0 && video.videoHeight > 0) {
setReady(true)
}
}
video.onloadedmetadata = markReady
video.srcObject = stream
await video.play()
markReady()
} catch (err) {
console.error('Camera access failed:', err)
if (!cancelled) {
setCameraError(t('logs.live_photo_camera_denied'))
}
}
}
void start()
return () => {
cancelled = true
stopStream()
}
}, [open, phase, streamGeneration, stopStream, t])
const handleCapture = async () => {
const video = videoRef.current
if (!video || !ready || busy || capturing) return
setCapturing(true)
setCameraError(null)
try {
const blob = await captureVideoFrame(video)
enterPreview(blob)
} catch (err) {
console.error('Live camera capture failed:', err)
setCameraError(t('logs.live_photo_capture_failed'))
} finally {
setCapturing(false)
}
}
const handleNativeFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ''
if (!file || busy) return
setCameraError(null)
try {
enterPreview(file)
} catch (err) {
console.error('Live camera file pick failed:', err)
setCameraError(t('logs.live_photo_capture_failed'))
}
}
const handleSave = () => {
if (!previewBlob || busy) return
onCapture(previewBlob)
}
const handleRetake = () => {
if (busy) return
resetToLive()
}
const openNativePicker = () => {
if (busy) return
fileInputRef.current?.click()
}
if (!open) return null
const showPreview = phase === 'preview' && previewUrl
return (
<div
className="live-log-modal-backdrop live-camera-backdrop"
onClick={(e) => { if (e.target === e.currentTarget && !busy) onClose() }}
>
<div className="live-log-modal live-camera-modal" onClick={(e) => e.stopPropagation()}>
<div className="live-camera-header">
<h3>{t('logs.live_photo_btn')}</h3>
<button
type="button"
className="btn secondary live-camera-close"
onClick={onClose}
disabled={busy}
aria-label={t('logs.confirm_no')}
>
<X size={18} />
</button>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
capture="environment"
className="live-camera-file-input"
onChange={(e) => void handleNativeFile(e)}
/>
{cameraError && (
<p className="live-log-modal-hint auth-error">{cameraError}</p>
)}
{showPreview ? (
<div className="live-camera-preview-wrap">
<img
src={previewUrl}
alt=""
className="live-camera-preview live-camera-preview-still"
/>
</div>
) : phase === 'native' ? (
<div className="live-camera-native-prompt">
<p className="live-log-modal-hint">{t('logs.live_photo_native_hint')}</p>
<button
type="button"
className="btn primary live-camera-open-native"
onClick={openNativePicker}
disabled={busy}
>
<Camera size={18} />
{t('logs.live_photo_open_camera_btn')}
</button>
</div>
) : cameraError && !ready ? null : (
<div className="live-camera-preview-wrap">
<video
ref={videoRef}
className="live-camera-preview"
playsInline
muted
autoPlay
/>
{!ready && (
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
)}
</div>
)}
{onCaptionChange && (
<div className="input-group live-camera-caption">
<label>{t('logs.photo_caption_label')}</label>
<input
type="text"
className="input-text"
placeholder={t('logs.photo_caption_placeholder')}
value={caption}
onChange={(e) => onCaptionChange(e.target.value)}
disabled={busy}
/>
</div>
)}
<div className="live-log-modal-actions live-camera-actions">
<button type="button" className="btn secondary" onClick={onClose} disabled={busy}>
{t('logs.confirm_no')}
</button>
{showPreview ? (
<>
<button type="button" className="btn secondary" onClick={handleRetake} disabled={busy}>
{t('logs.live_photo_retake_btn')}
</button>
<button
type="button"
className="btn primary live-camera-shutter"
onClick={handleSave}
disabled={busy || !previewBlob}
>
<Camera size={18} />
{busy ? t('logs.photo_processing') : t('logs.live_photo_save_btn')}
</button>
</>
) : phase === 'native' ? null : (
<button
type="button"
className="btn primary live-camera-shutter"
onClick={() => void handleCapture()}
disabled={busy || capturing || !ready || !!cameraError}
>
<Camera size={18} />
{capturing ? t('logs.photo_processing') : t('logs.live_photo_capture_btn')}
</button>
)}
</div>
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
+95 -16
View File
@@ -8,11 +8,14 @@ import { syncLogbook } from '../services/sync.js'
import { downloadCsv, shareCsv } from '../services/csvExport.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js'
import { findTodayEntryId } from '../services/quickEventLog.js'
import LogEntryEditor from './LogEntryEditor.tsx'
import LiveLogView from './LiveLogView.tsx'
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
import { useDialog } from './ModalDialog.tsx'
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react'
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react'
import {
carryOverFromPreviousDay,
compareTravelDaysChronological,
@@ -36,6 +39,8 @@ interface LogEntriesListProps {
highlightEntryId?: string | null
}
type LogsViewMode = 'list' | 'live'
interface DecryptedEntryItem {
id: string
date: string
@@ -75,6 +80,8 @@ export default function LogEntriesList({
const [loading, setLoading] = useState(false)
const [exporting, setExporting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<LogsViewMode>('list')
const [returnToLiveAfterEditor, setReturnToLiveAfterEditor] = useState(false)
const prevSelectedEntryIdRef = useRef<string | null | undefined>(undefined)
const loadEntries = useCallback(async () => {
@@ -137,24 +144,26 @@ export default function LogEntriesList({
setEntries(list)
} catch (err: any) {
console.error('Failed to load log entries:', err)
setError(err.message || 'Decryption failed. Could not load journal list.')
setError(getErrorMessage(err, t('errors.load_failed')))
} finally {
setLoading(false)
}
}, [logbookId, readOnly, preloadedEntries])
useEffect(() => {
if (viewMode === 'live') return
loadEntries()
}, [loadEntries])
}, [loadEntries, viewMode])
useEffect(() => {
if (viewMode === 'live') return
const prevSelectedEntryId = prevSelectedEntryIdRef.current
prevSelectedEntryIdRef.current = selectedEntryId
if (prevSelectedEntryId !== undefined && prevSelectedEntryId !== null && selectedEntryId === null) {
loadEntries()
}
}, [selectedEntryId, loadEntries])
}, [selectedEntryId, loadEntries, viewMode])
const handleDownloadCsv = async () => {
setExporting(true)
@@ -169,7 +178,7 @@ export default function LogEntriesList({
trackPlausibleEvent(PlausibleEvents.CSV_EXPORTED)
} catch (err: any) {
console.error('Failed to download CSV:', err)
setError(err.message || 'Failed to generate CSV export.')
setError(getErrorMessage(err, t('errors.export_failed')))
} finally {
setExporting(false)
}
@@ -197,7 +206,7 @@ export default function LogEntriesList({
setError(t('logs.share_unsupported'))
} else {
console.error('Failed to share CSV:', err)
setError(err.message || 'Failed to share CSV export.')
setError(getErrorMessage(err, t('errors.export_failed')))
}
} finally {
setExporting(false)
@@ -218,7 +227,7 @@ export default function LogEntriesList({
trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
} catch (err: any) {
console.error('Failed to download PDF:', err)
setError(err.message || 'Failed to generate PDF export.')
setError(getErrorMessage(err, t('errors.export_failed')))
} finally {
setExporting(false)
}
@@ -231,6 +240,12 @@ export default function LogEntriesList({
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const existingTodayId = await findTodayEntryId(logbookId)
if (existingTodayId) {
setSelectedEntryId(existingTodayId)
return
}
const localEntries = await db.entries.where({ logbookId }).toArray()
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
@@ -269,6 +284,12 @@ export default function LogEntriesList({
const nowStr = new Date().toISOString()
const todayStr = nowStr.substring(0, 10)
const { loadDefaultEntryCrewForNewDay } = await import('./EntryCrewSection.js')
const entryCrew = await loadDefaultEntryCrewForNewDay(
logbookId,
previousEntry as Record<string, unknown> | null
)
const initialPayload = {
date: todayStr,
dayOfTravel: getNextTravelDayNumber(decryptedEntries),
@@ -277,6 +298,9 @@ export default function LogEntriesList({
freshwater,
fuel,
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
selectedSkipperId: entryCrew.selectedSkipperId,
selectedCrewIds: entryCrew.selectedCrewIds,
crewSnapshotsById: entryCrew.crewSnapshotsById,
signSkipper: '',
signCrew: '',
events: []
@@ -310,7 +334,7 @@ export default function LogEntriesList({
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
console.error('Failed to create entry:', err)
setError(err.message || 'Failed to create new log entry.')
setError(getErrorMessage(err, t('errors.save_failed')))
} finally {
setLoading(false)
}
@@ -340,7 +364,7 @@ export default function LogEntriesList({
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
console.error('Failed to delete log entry:', err)
setError(err.message || 'Failed to delete log entry.')
setError(getErrorMessage(err, t('errors.delete_failed')))
}
}
}
@@ -350,7 +374,13 @@ export default function LogEntriesList({
<LogEntryEditor
entryId={selectedEntryId}
logbookId={logbookId}
onBack={() => setSelectedEntryId(null)}
onBack={() => {
setSelectedEntryId(null)
if (returnToLiveAfterEditor) {
setViewMode('live')
setReturnToLiveAfterEditor(false)
}
}}
readOnly={readOnly}
preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)}
preloadedPhotos={preloadedPhotos}
@@ -359,6 +389,22 @@ export default function LogEntriesList({
)
}
if (viewMode === 'live' && !readOnly) {
return (
<LiveLogView
logbookId={logbookId}
onOpenEditor={(entryId) => {
setReturnToLiveAfterEditor(true)
setSelectedEntryId(entryId)
}}
onSwitchToList={() => {
setViewMode('list')
void loadEntries()
}}
/>
)
}
if (loading) {
return (
<div className="tab-placeholder">
@@ -374,13 +420,36 @@ export default function LogEntriesList({
: entries[0]?.id ?? null
return (
<div className="form-card">
<div className="logs-journal">
<div className="section-title-bar mb-6">
<div className="form-header" style={{ margin: 0 }}>
<Calendar size={24} className="form-icon" />
<h2>{t('logs.title')}</h2>
</div>
<div className="section-toolbar">
{!readOnly && (
<div className="logs-view-toggle" role="group" aria-label={t('logs.view_mode_label')}>
<button
type="button"
className={`btn secondary logs-view-toggle-btn ${viewMode === 'list' ? 'is-active' : ''}`}
onClick={() => setViewMode('list')}
title={t('logs.view_list')}
>
<List size={16} />
<span className="hide-mobile">{t('logs.view_list')}</span>
</button>
<button
type="button"
className={`btn secondary logs-view-toggle-btn ${viewMode === 'live' ? 'is-active' : ''}`}
onClick={() => setViewMode('live')}
title={t('logs.live_mode')}
>
<Radio size={16} />
<span className="hide-mobile">{t('logs.live_mode')}</span>
</button>
</div>
)}
<button className="btn secondary" onClick={handleDownloadCsv} disabled={loading || exporting || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.export_csv')}>
<Download size={16} />
<span className="hide-mobile">{exporting ? t('logs.exporting') : t('logs.export_csv')}</span>
@@ -411,9 +480,19 @@ export default function LogEntriesList({
key={item.id}
className="logbook-card glass"
data-tour={tourFirstEntryId === item.id ? 'entry-first' : undefined}
onClick={() => setSelectedEntryId(item.id)}
>
<div className="card-icon">
<button
type="button"
className="logbook-card-select"
onClick={() => setSelectedEntryId(item.id)}
aria-label={
item.departure && item.destination
? `${item.departure}${item.destination}, ${t('logs.travel_day_number', { number: item.dayOfTravel })}`
: `${t('logs.new_entry')}, ${t('logs.travel_day_number', { number: item.dayOfTravel })}`
}
/>
<div className="card-icon" aria-hidden>
<FileText size={24} />
</div>
@@ -425,7 +504,7 @@ export default function LogEntriesList({
</h3>
<div className="card-meta">
<span className="sync-badge synced">
{t('logs.day_of_travel')} {item.dayOfTravel}
{t('logs.travel_day_number', { number: item.dayOfTravel })}
</span>
<EntrySkipperSignBadge status={item.skipperSignStatus} />
<span className="date-badge">
@@ -434,6 +513,8 @@ export default function LogEntriesList({
</div>
</div>
<ChevronRight size={18} className="logbook-card-chevron" aria-hidden />
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
<Download size={18} />
</button>
@@ -443,8 +524,6 @@ export default function LogEntriesList({
<Trash2 size={18} />
</button>
)}
<ChevronRight size={18} style={{ color: '#475569', marginLeft: 'auto' }} />
</div>
))}
</div>
+134 -41
View File
@@ -5,10 +5,15 @@ import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { saveEntryDraft, clearEntryDraft } from '../services/entryDraft.js'
import { getErrorMessage } from '../utils/errors.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp } from 'lucide-react'
import PhotoCapture from './PhotoCapture.tsx'
import SignatureSection from './SignatureSection.tsx'
import EntryCrewSection from './EntryCrewSection.tsx'
import { emptyEntryCrewFields, type EntryCrewFields } from '../types/person.js'
import { entryCrewFromPreviousEntry } from '../utils/personSnapshots.js'
import TrackMap from './TrackMap.tsx'
import { useDialog } from './ModalDialog.tsx'
import {
@@ -25,7 +30,7 @@ import type { SignatureValue } from '../types/signatures.js'
import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, hasUnsavedEventDraft, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js'
import EventTimeInput24h from './EventTimeInput24h.tsx'
import CourseDialInput from './CourseDialInput.tsx'
import { degreesToCardinal } from '../utils/courseAngle.js'
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
import { signLogEntry } from '../services/entrySigning.js'
import { getLogbookAccess } from '../services/logbookAccess.js'
@@ -37,8 +42,16 @@ import {
deleteTrack,
downloadTrackFile,
parseTrackFile,
type SavedTrack
type SavedTrack,
type TrackWaypoint
} from '../services/trackUpload.js'
import NmeaImportWizard from './NmeaImportWizard.tsx'
import {
deleteNmeaArchive,
downloadNmeaArchive,
getNmeaArchive,
type NmeaArchiveRecord
} from '../services/nmeaArchive.js'
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
@@ -98,7 +111,8 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
motorHoursRaw != null && motorHoursRaw !== ''
? parseFloat(String(motorHoursRaw))
: undefined,
events: (decrypted.events as LogEventPayload[]) || []
events: (decrypted.events as LogEventPayload[]) || [],
entryCrew: entryCrewFromPreviousEntry(decrypted as Record<string, unknown>)
})
return JSON.stringify({
@@ -158,6 +172,8 @@ export default function LogEntryEditor({
const [greywaterLevel, setGreywaterLevel] = useState('0')
const [tankCapacities, setTankCapacities] = useState<VesselTankCapacities>({})
const [entryCrew, setEntryCrew] = useState<EntryCrewFields>(emptyEntryCrewFields())
// Signatures
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
@@ -210,6 +226,8 @@ export default function LogEntryEditor({
const [savedTrack, setSavedTrack] = useState<SavedTrack | null>(null)
const [dragOver, setDragOver] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const [nmeaWizardOpen, setNmeaWizardOpen] = useState(false)
const [nmeaArchive, setNmeaArchive] = useState<NmeaArchiveRecord | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const lockedContentHashRef = useRef<string | null>(null)
const contentReadyRef = useRef(false)
@@ -267,7 +285,8 @@ export default function LogEntryEditor({
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
motorHours: motorHours.trim() ? parseFloat(motorHours) : undefined,
events: eventsOverride ?? events
events: eventsOverride ?? events,
entryCrew
})
}, [
date, dayOfTravel, departure, destination,
@@ -275,9 +294,18 @@ export default function LogEntryEditor({
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
greywaterLevel,
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
events
events,
entryCrew
])
useEffect(() => {
if (readOnly || loading || !date) return
const timer = window.setTimeout(() => {
void saveEntryDraft(logbookId, entryId, buildPayloadForSigning())
}, 4000)
return () => window.clearTimeout(timer)
}, [readOnly, loading, logbookId, entryId, buildPayloadForSigning, date])
const fuelPerMotorHour = useMemo(
() => computeFuelPerMotorHour(parseFloat(fuelConsumption) || 0, parseFloat(motorHours) || 0),
[fuelConsumption, motorHours]
@@ -686,6 +714,7 @@ export default function LogEntryEditor({
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
setEntryCrew(entryCrewFromPreviousEntry(preloadedEntry as Record<string, unknown>))
loadTrackStatsFromEntry(preloadedEntry)
setEvents(sortLogEventsByTime((preloadedEntry.events || []).map(normalizeLogEvent)))
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
@@ -724,6 +753,7 @@ export default function LogEntryEditor({
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
setEntryCrew(entryCrewFromPreviousEntry(decrypted as Record<string, unknown>))
loadTrackStatsFromEntry(decrypted)
setEvents(sortLogEventsByTime((decrypted.events || []).map(normalizeLogEvent)))
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
@@ -762,6 +792,45 @@ export default function LogEntryEditor({
loadTrack()
}, [entryId, preloadedTrack])
const loadNmeaArchive = async () => {
if (readOnly) return
try {
const archive = await getNmeaArchive(entryId)
setNmeaArchive(archive)
} catch {
setNmeaArchive(null)
}
}
useEffect(() => {
loadNmeaArchive()
}, [entryId, readOnly])
const handleNmeaImport = async (importedEvents: LogEventPayload[], waypoints?: TrackWaypoint[]) => {
setEvents((prev) => sortLogEventsByTime([...prev, ...importedEvents]))
if (waypoints && waypoints.length > 0) {
try {
const gpxLike = waypoints
.map((wp) => ` <trkpt lat="${wp.lat}" lon="${wp.lng}"><time>${new Date(wp.timestamp).toISOString()}</time></trkpt>`)
.join('\n')
const content = `<?xml version="1.0"?><gpx><trk><trkseg>\n${gpxLike}\n</trkseg></trk></gpx>`
await saveUploadedTrack(logbookId, entryId, content, waypoints, 'imported-from-nmea.nmea', 'nmea')
applyTrackStats(waypoints)
await loadTrack()
trackPlausibleEvent(PlausibleEvents.GPS_TRACK_UPLOADED)
} catch (err: unknown) {
console.warn('Failed to save NMEA track:', err)
}
}
await loadNmeaArchive()
}
const handleDeleteNmeaArchive = async () => {
if (!window.confirm(t('logs.nmea_archive_delete_confirm'))) return
await deleteNmeaArchive(entryId)
setNmeaArchive(null)
}
useEffect(() => {
if (!savedTrack || savedTrack.waypoints.length < 2) return
if (trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) return
@@ -851,7 +920,10 @@ export default function LogEntryEditor({
}
try {
const data = await fetchOpenWeatherCurrent({ q: locationQuery })
const data = await fetchOpenWeatherCurrent(
{ q: locationQuery },
{ analyticsSource: 'entry_editor_gps_lookup' }
)
const coord = data.coord as { lat?: number; lon?: number } | undefined
if (coord?.lat !== undefined && coord?.lon !== undefined) {
setEvGpsLat(Number(coord.lat).toFixed(6))
@@ -906,7 +978,8 @@ export default function LogEntryEditor({
const data = await fetchOpenWeatherCurrent(
hasGps
? { lat: evGpsLat, lon: evGpsLng }
: { q: fallbackLocation }
: { q: fallbackLocation },
{ analyticsSource: 'entry_editor' }
)
const coord = data.coord as { lat?: number; lon?: number } | undefined
@@ -916,38 +989,11 @@ export default function LogEntryEditor({
setEvGpsLng(Number(coord.lon).toFixed(6))
}
const wind = data.wind as { speed?: number; deg?: number } | undefined
const main = data.main as { pressure?: number } | undefined
// Convert wind speed m/s to Beaufort scale
const mps = wind?.speed || 0
let bft = 0
if (mps < 0.3) bft = 0
else if (mps < 1.6) bft = 1
else if (mps < 3.4) bft = 2
else if (mps < 5.5) bft = 3
else if (mps < 8.0) bft = 4
else if (mps < 10.8) bft = 5
else if (mps < 13.9) bft = 6
else if (mps < 17.2) bft = 7
else if (mps < 20.8) bft = 8
else if (mps < 24.5) bft = 9
else if (mps < 28.5) bft = 10
else if (mps < 32.7) bft = 11
else bft = 12
setEvWindStrength(`${bft} Bft (${mps.toFixed(1)} m/s)`)
setEvWindPressure(String(main?.pressure || ''))
// Calculate wind compass direction sector
if (wind?.deg !== undefined) {
setEvWindDirection(degreesToCardinal(wind.deg))
}
if (data.weather && Array.isArray(data.weather) && data.weather[0]) {
const first = data.weather[0] as { icon?: string }
if (first.icon) setEvWeatherIcon(first.icon)
}
const parsed = parseOwmCurrentWeather(data)
setEvWindStrength(parsed.windStrength)
setEvWindPressure(parsed.windPressure)
if (parsed.windDirection) setEvWindDirection(parsed.windDirection)
if (parsed.weatherIcon) setEvWeatherIcon(parsed.weatherIcon)
showAlert(t('settings.weather_success'))
} catch (err) {
@@ -1182,15 +1228,17 @@ export default function LogEntryEditor({
...signaturesForSave
})
await clearEntryDraft(logbookId, entryId)
setSuccess(true)
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
setTimeout(() => {
setSuccess(false)
onBack()
}, 1500)
} catch (err: any) {
} catch (err: unknown) {
console.error('Failed to save entry details:', err)
setError(err.message || 'Failed to save entry details.')
setError(getErrorMessage(err, t('errors.save_failed')))
} finally {
setSaving(false)
}
@@ -1925,6 +1973,31 @@ export default function LogEntryEditor({
</>
)}
{!readOnly && (
<div className="nmea-import-section" style={{ marginTop: '12px' }}>
<button
type="button"
className="btn secondary"
onClick={() => setNmeaWizardOpen(true)}
style={{ width: 'auto', padding: '8px 14px', display: 'inline-flex', alignItems: 'center', gap: '6px' }}
>
<FileText size={16} />
{t('logs.nmea_import_btn')}
</button>
{nmeaArchive && (
<div className="nmea-archive-info" style={{ marginTop: '8px', display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
<span>{t('logs.nmea_archive_stored', { name: nmeaArchive.filename })}</span>
<button type="button" className="btn secondary" style={{ width: 'auto', padding: '4px 10px', fontSize: '13px' }} onClick={() => downloadNmeaArchive(nmeaArchive)}>
<Download size={14} />
</button>
<button type="button" className="btn secondary" style={{ width: 'auto', padding: '4px 10px', fontSize: '13px' }} onClick={handleDeleteNmeaArchive}>
<Trash2 size={14} />
</button>
</div>
)}
</div>
)}
{(savedTrack || trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) && (
<div className="form-grid track-stats-grid">
<div className="input-group">
@@ -1969,6 +2042,13 @@ export default function LogEntryEditor({
<PhotoCapture entryId={entryId} logbookId={logbookId} readOnly={readOnly} preloadedPhotos={preloadedPhotos} />
<EntryCrewSection
logbookId={logbookId}
readOnly={readOnly}
value={entryCrew}
onChange={setEntryCrew}
/>
<SignatureSection
readOnly={readOnly}
disabled={saving}
@@ -2030,6 +2110,19 @@ export default function LogEntryEditor({
</div>
)}
</form>
<NmeaImportWizard
open={nmeaWizardOpen}
onClose={() => {
setNmeaWizardOpen(false)
void loadNmeaArchive()
}}
logbookId={logbookId}
entryId={entryId}
entryDate={date}
nmeaArchive={nmeaArchive}
onImport={handleNmeaImport}
/>
</div>
)
}
+224
View File
@@ -0,0 +1,224 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Users, User, Save, Check } from 'lucide-react'
import type { LogbookCrewSelectionData, PersonSnapshot } from '../types/person.js'
import type { DecryptedPerson } from '../services/personPool.js'
import { loadPersonPool, filterSkippers, filterCrew } from '../services/personPool.js'
import { loadLogbookCrewSelection, saveLogbookCrewSelectionFromIds } from '../services/logbookCrewSelection.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
export interface LogbookCrewPickerProps {
logbookId: string
readOnly?: boolean
/** Demo / share: in-memory pool */
preloadedPool?: Array<{ payloadId: string; data: DecryptedPerson['data'] }>
preloadedSelection?: LogbookCrewSelectionData
/** Shared logbook: only people from selection snapshots */
selectionOnly?: boolean
}
function snapshotsToPoolList(
selection: LogbookCrewSelectionData
): Array<{ payloadId: string; data: DecryptedPerson['data'] }> {
return Object.values(selection.snapshotsById).map((snap) => ({
payloadId: snap.id,
data: {
name: snap.name,
address: snap.address,
birthDate: snap.birthDate,
phone: snap.phone,
nationality: snap.nationality,
passportNumber: snap.passportNumber,
bloodType: snap.bloodType,
allergies: snap.allergies,
diseases: snap.diseases,
role: snap.role,
photo: snap.photo
}
}))
}
export default function LogbookCrewPicker({
logbookId,
readOnly = false,
preloadedPool,
preloadedSelection,
selectionOnly = false
}: LogbookCrewPickerProps) {
const { t } = useTranslation()
const [loading, setLoading] = useState(!preloadedSelection)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(null)
const [pool, setPool] = useState<DecryptedPerson[]>([])
const [activeSkipperId, setActiveSkipperId] = useState<string | null>(null)
const [activeCrewIds, setActiveCrewIds] = useState<string[]>([])
const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const selection =
preloadedSelection ??
(logbookId === 'demo' ? null : await loadLogbookCrewSelection(logbookId))
if (selection) {
setActiveSkipperId(selection.activeSkipperId)
setActiveCrewIds([...selection.activeCrewIds])
}
if (preloadedPool) {
setPool(
preloadedPool.map((p) => ({
payloadId: p.payloadId,
data: p.data
}))
)
} else if (selectionOnly && selection) {
setPool(snapshotsToPoolList(selection))
} else {
setPool(await loadPersonPool())
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to load crew selection')
} finally {
setLoading(false)
}
}, [logbookId, preloadedPool, preloadedSelection, selectionOnly])
useEffect(() => {
void loadData()
}, [loadData])
const skippers = useMemo(() => filterSkippers(pool), [pool])
const crewMembers = useMemo(() => filterCrew(pool), [pool])
const toggleCrew = (id: string) => {
if (readOnly) return
setActiveCrewIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
)
}
const handleSave = async () => {
if (readOnly || logbookId === 'demo') return
setSaving(true)
setError(null)
setSaved(false)
try {
await saveLogbookCrewSelectionFromIds(logbookId, activeSkipperId, activeCrewIds)
setSaved(true)
trackPlausibleEvent(PlausibleEvents.CREW_SAVED, { context: 'logbook_selection' })
setTimeout(() => setSaved(false), 3000)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to save')
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div className="tab-placeholder">
<Users className="header-logo spin" size={48} />
<p>{t('person_pool.loading')}</p>
</div>
)
}
return (
<div className="crew-dashboard-layout" data-tour="logbook-crew-picker">
<div className="form-card">
<div className="form-header">
<Users size={24} className="form-icon" />
<h2>{t('logbook_crew.title')}</h2>
</div>
<p className="help-text mb-4">{t('logbook_crew.subtitle')}</p>
{selectionOnly && <p className="help-text mb-4">{t('logbook_crew.selection_only_hint')}</p>}
{error && <div className="auth-error mb-4">{error}</div>}
<div className="input-group mb-4">
<label>{t('logbook_crew.active_skipper')}</label>
{skippers.length === 0 ? (
<p className="help-text">{t('logbook_crew.no_skippers_in_pool')}</p>
) : (
<div className="crew-selection-list">
{skippers.map((s) => (
<label key={s.payloadId} className="crew-selection-item">
<input
type="radio"
name={`skipper-${logbookId}`}
checked={activeSkipperId === s.payloadId}
onChange={() => !readOnly && setActiveSkipperId(s.payloadId)}
disabled={readOnly}
/>
<User size={16} aria-hidden="true" />
<span>{s.data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
{!readOnly && (
<label className="crew-selection-item">
<input
type="radio"
name={`skipper-${logbookId}`}
checked={activeSkipperId === null}
onChange={() => setActiveSkipperId(null)}
/>
<span>{t('logbook_crew.no_skipper')}</span>
</label>
)}
</div>
)}
</div>
<div className="input-group mb-4">
<label>{t('logbook_crew.active_crew')}</label>
{crewMembers.length === 0 ? (
<p className="help-text">{t('logbook_crew.no_crew_in_pool')}</p>
) : (
<div className="crew-selection-list">
{crewMembers.map((c) => (
<label key={c.payloadId} className="crew-selection-item">
<input
type="checkbox"
checked={activeCrewIds.includes(c.payloadId)}
onChange={() => toggleCrew(c.payloadId)}
disabled={readOnly}
/>
<span>{c.data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)}
</div>
{!readOnly && logbookId !== 'demo' && (
<div className="form-actions">
{saved && (
<div className="success-toast">
<Check size={16} />
<span>{t('logbook_crew.saved')}</span>
</div>
)}
<button type="button" className="btn primary" onClick={() => void handleSave()} disabled={saving}>
<Save size={18} />
{t('logbook_crew.save')}
</button>
</div>
)}
</div>
</div>
)
}
export function selectionFromSnapshots(
snapshotsById: Record<string, PersonSnapshot>
): LogbookCrewSelectionData {
const snapshots = Object.values(snapshotsById)
const skipper = snapshots.find((s) => s.role === 'skipper')
return {
activeSkipperId: skipper?.id ?? null,
activeCrewIds: snapshots.filter((s) => s.role === 'crew').map((s) => s.id),
snapshotsById
}
}
+21 -24
View File
@@ -6,11 +6,13 @@ import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type D
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
import BetaBadge from './BetaBadge.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js'
import { logoutUser } from '../services/auth.js'
import { useDialog } from './ModalDialog.tsx'
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
interface LogbookDashboardProps {
onSelectLogbook: (id: string, title: string) => void
@@ -74,7 +76,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
const filterInputRef = useRef<HTMLInputElement>(null)
const [online, setOnline] = useState(navigator.onLine)
const [username] = useState(localStorage.getItem('active_username') || 'Skipper')
const { pendingCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator()
@@ -102,8 +103,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
try {
const data = await fetchLogbooks()
setLogbooks(data)
} catch (err: any) {
setError(err.message || 'Failed to load logbooks')
} catch (err: unknown) {
setError(getErrorMessage(err, t('errors.load_failed')))
} finally {
setLoading(false)
setRefreshing(false)
@@ -121,8 +122,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
setLogbooks((prev) => [created, ...prev])
setNewTitle('')
trackPlausibleEvent(PlausibleEvents.LOGBOOK_CREATED)
} catch (err: any) {
setError(err.message || 'Failed to create logbook')
} catch (err: unknown) {
setError(getErrorMessage(err, t('errors.save_failed')))
} finally {
setLoading(false)
}
@@ -138,7 +139,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
await deleteLogbook(id)
setLogbooks((prev) => prev.filter((lb) => lb.id !== id))
} catch (err: any) {
setError(err.message || 'Failed to delete logbook')
setError(getErrorMessage(err, t('errors.delete_failed')))
} finally {
setLoading(false)
}
@@ -182,7 +183,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
)
)
} catch (err: any) {
setError(err.message || 'Failed to update logbook title')
setError(getErrorMessage(err, t('errors.save_failed')))
} finally {
setLoading(false)
}
@@ -225,10 +226,18 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
return (
<div
key={lb.id}
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}`}
onClick={() => onSelectLogbook(lb.id, lb.title)}
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}${isEditingTitle ? ' logbook-card--editing-title' : ''}`}
>
<div className="card-icon">
{!isEditingTitle && (
<button
type="button"
className="logbook-card-select"
onClick={() => onSelectLogbook(lb.id, lb.title)}
aria-label={t('dashboard.open_logbook', { title: lb.title })}
/>
)}
<div className="card-icon" aria-hidden>
<BookOpen size={24} />
</div>
@@ -241,7 +250,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
className="logbook-title-inline-edit input-text"
value={editingTitleDraft}
onChange={(e) => setEditingTitleDraft(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
@@ -370,18 +378,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
)}
</div>
{/* Skipper profile */}
<button
type="button"
className="btn-icon skipper-badge"
onClick={onOpenProfile}
title={t('dashboard.open_profile', { name: username })}
aria-label={t('dashboard.open_profile', { name: username })}
data-tour="nav-profile"
>
<User size={18} aria-hidden="true" />
<span className="skipper-badge__name">{username}</span>
</button>
<ProfileHeaderButton onClick={onOpenProfile} />
{/* Lang toggle */}
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
+80 -21
View File
@@ -1,4 +1,14 @@
import React, { createContext, useContext, useState, useRef, useCallback, useMemo } from 'react'
import React, {
createContext,
useContext,
useState,
useRef,
useCallback,
useMemo,
useEffect,
useId
} from 'react'
import { useTranslation } from 'react-i18next'
interface DialogContextType {
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
@@ -16,6 +26,11 @@ export function useDialog() {
}
export function DialogProvider({ children }: { children: React.ReactNode }) {
const { t } = useTranslation()
const titleId = useId()
const messageId = useId()
const confirmRef = useRef<HTMLButtonElement>(null)
const [isOpen, setIsOpen] = useState(false)
const [title, setTitle] = useState('')
const [message, setMessage] = useState('')
@@ -23,19 +38,20 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
const [confirmLabel, setConfirmLabel] = useState('OK')
const [cancelLabel, setCancelLabel] = useState('Cancel')
const resolveRef = useRef<((val: any) => void) | null>(null)
const alertResolveRef = useRef<(() => void) | null>(null)
const confirmResolveRef = useRef<((val: boolean) => void) | null>(null)
const showAlert = useCallback((msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
setMessage(msg)
setTitle(headerTitle || '')
setType('alert')
setConfirmLabel(btnText || 'OK')
setConfirmLabel(btnText || t('dialog.ok'))
setIsOpen(true)
return new Promise<void>((resolve) => {
resolveRef.current = resolve
alertResolveRef.current = resolve
})
}, [])
}, [t])
const showConfirm = useCallback((
msg: string,
@@ -46,31 +62,47 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
setMessage(msg)
setTitle(headerTitle || '')
setType('confirm')
setConfirmLabel(btnConfirm || 'Yes')
setCancelLabel(btnCancel || 'No')
setConfirmLabel(btnConfirm || t('dialog.yes'))
setCancelLabel(btnCancel || t('dialog.no'))
setIsOpen(true)
return new Promise<boolean>((resolve) => {
resolveRef.current = resolve
confirmResolveRef.current = resolve
})
}, [])
}, [t])
const handleConfirm = useCallback(() => {
setIsOpen(false)
if (resolveRef.current) {
resolveRef.current(type === 'confirm' ? true : undefined)
resolveRef.current = null
if (type === 'confirm' && confirmResolveRef.current) {
confirmResolveRef.current(true)
confirmResolveRef.current = null
} else if (alertResolveRef.current) {
alertResolveRef.current()
alertResolveRef.current = null
}
}, [type])
const handleCancel = useCallback(() => {
setIsOpen(false)
if (resolveRef.current) {
resolveRef.current(false)
resolveRef.current = null
if (confirmResolveRef.current) {
confirmResolveRef.current(false)
confirmResolveRef.current = null
}
}, [])
useEffect(() => {
if (!isOpen) return
confirmRef.current?.focus()
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (type === 'confirm') handleCancel()
else handleConfirm()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [isOpen, type, handleCancel, handleConfirm])
const contextValue = useMemo(
() => ({ showAlert, showConfirm }),
[showAlert, showConfirm]
@@ -80,17 +112,44 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
<DialogContext.Provider value={contextValue}>
{children}
{isOpen && (
<div className="custom-dialog-overlay" onClick={type === 'alert' ? handleConfirm : undefined}>
<div className="custom-dialog-card glass scale-in" onClick={(e) => e.stopPropagation()}>
{title && <h3 className="custom-dialog-title">{title}</h3>}
<p className="custom-dialog-message">{message}</p>
<div
className="custom-dialog-overlay"
onClick={type === 'confirm' ? handleCancel : handleConfirm}
>
<div
className="custom-dialog-card glass scale-in"
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
aria-describedby={messageId}
onClick={(e) => e.stopPropagation()}
>
{title && (
<h3 id={titleId} className="custom-dialog-title">
{title}
</h3>
)}
<p id={messageId} className="custom-dialog-message">
{message}
</p>
<div className="custom-dialog-actions">
{type === 'confirm' && (
<button type="button" className="btn secondary" onClick={handleCancel} style={{ width: 'auto', padding: '8px 20px', margin: 0 }}>
<button
type="button"
className="btn secondary"
onClick={handleCancel}
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
>
{cancelLabel}
</button>
)}
<button type="button" className="btn primary" onClick={handleConfirm} style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}>
<button
ref={confirmRef}
type="button"
className="btn primary"
onClick={handleConfirm}
style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}
>
{confirmLabel}
</button>
</div>
+333
View File
@@ -0,0 +1,333 @@
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { FileText, X } from 'lucide-react'
import type { LogEventPayload } from '../utils/logEntryPayload.js'
import { sortLogEventsByTime } from '../utils/logEntryPayload.js'
import { parseNmeaFile, nmeaPointsToWaypoints } from '../services/nmea/nmeaParse.js'
import { filterPointsForDate } from '../services/nmea/nmeaTimeSeries.js'
import { generateNmeaJournalCandidates } from '../services/nmea/nmeaJournalGenerator.js'
import type { NmeaImportMode, NmeaParseResult } from '../services/nmea/nmeaTypes.js'
import { saveNmeaArchive, recordNmeaFileImport, type NmeaArchiveRecord } from '../services/nmeaArchive.js'
import { nmeaFileCrc32 } from '../utils/crc32.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import type { TrackWaypoint } from '../services/trackUpload.js'
interface NmeaImportWizardProps {
open: boolean
onClose: () => void
logbookId: string
entryId: string
entryDate: string
nmeaArchive: NmeaArchiveRecord | null
onImport: (events: LogEventPayload[], waypoints?: TrackWaypoint[]) => void
}
type WizardStep = 'config' | 'preview' | 'archive'
export default function NmeaImportWizard({
open,
onClose,
logbookId,
entryId,
entryDate,
nmeaArchive,
onImport
}: NmeaImportWizardProps) {
const { t } = useTranslation()
const [step, setStep] = useState<WizardStep>('config')
const [parseResult, setParseResult] = useState<NmeaParseResult | null>(null)
const [mode, setMode] = useState<NmeaImportMode>('both')
const [intervalMinutes, setIntervalMinutes] = useState(60)
const [importTrack, setImportTrack] = useState(true)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [error, setError] = useState<string | null>(null)
const [pendingRaw, setPendingRaw] = useState<{ filename: string; text: string } | null>(null)
const [duplicateFile, setDuplicateFile] = useState(false)
const filteredPoints = useMemo(() => {
if (!parseResult) return []
return filterPointsForDate(parseResult.points, entryDate)
}, [parseResult, entryDate])
const candidates = useMemo(() => {
if (!parseResult || filteredPoints.length === 0) return []
return generateNmeaJournalCandidates({
points: filteredPoints,
mode,
intervalMinutes,
t
}).candidates
}, [parseResult, filteredPoints, mode, intervalMinutes, t])
const reset = () => {
setStep('config')
setParseResult(null)
setMode('both')
setIntervalMinutes(60)
setImportTrack(true)
setSelectedIds(new Set())
setError(null)
setDuplicateFile(false)
setPendingRaw(null)
}
const handleClose = () => {
reset()
onClose()
}
const handleFile = (file: File) => {
setError(null)
setDuplicateFile(false)
const reader = new FileReader()
reader.onload = () => {
try {
const text = String(reader.result ?? '')
const crc32 = nmeaFileCrc32(text)
const alreadyImported = nmeaArchive?.importedFiles.some((item) => item.crc32 === crc32) ?? false
setDuplicateFile(alreadyImported)
const result = parseNmeaFile(text, file.name)
if (result.points.length === 0) {
setError(t('logs.nmea_error_no_samples'))
return
}
setParseResult(result)
setPendingRaw({ filename: file.name, text })
const generated = generateNmeaJournalCandidates({
points: filterPointsForDate(result.points, entryDate),
mode,
intervalMinutes,
t
}).candidates
setSelectedIds(new Set(generated.map((c) => c.id)))
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, {
duplicate: alreadyImported,
lines: result.stats.parsedLines,
candidates: generated.length,
has_position: !result.warnings.includes('no_position')
})
} catch (err) {
setError(err instanceof Error ? err.message : t('logs.nmea_error_parse'))
}
}
reader.onerror = () => setError(t('logs.nmea_error_read'))
reader.readAsText(file)
}
const toggleAll = (checked: boolean) => {
setSelectedIds(checked ? new Set(candidates.map((c) => c.id)) : new Set())
}
const toggleOne = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const goPreview = () => {
if (!parseResult) {
setError(t('logs.nmea_error_no_file'))
return
}
const generated = generateNmeaJournalCandidates({
points: filteredPoints,
mode,
intervalMinutes,
t
}).candidates
setSelectedIds(new Set(generated.map((c) => c.id)))
setStep('preview')
}
const applyImport = async () => {
const picked = candidates.filter((c) => selectedIds.has(c.id)).map((c) => c.event)
if (picked.length === 0) {
setError(t('logs.nmea_error_no_selection'))
return
}
const waypoints = importTrack ? nmeaPointsToWaypoints(filteredPoints) : undefined
onImport(sortLogEventsByTime(picked), waypoints)
if (pendingRaw) {
try {
await recordNmeaFileImport(logbookId, entryId, pendingRaw.filename, pendingRaw.text)
} catch (err) {
console.warn('NMEA import CRC record failed:', err)
}
}
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, {
mode,
events: picked.length,
track: importTrack && (waypoints?.length ?? 0) > 0
})
setStep('archive')
}
const finishArchive = async (archive: boolean) => {
try {
if (archive && pendingRaw) {
await saveNmeaArchive(logbookId, entryId, pendingRaw.filename, pendingRaw.text)
}
} catch (err) {
console.warn('NMEA archive save failed:', err)
}
handleClose()
}
useEffect(() => {
if (!open) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') handleClose()
}
window.addEventListener('keydown', onKeyDown)
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
window.removeEventListener('keydown', onKeyDown)
document.body.style.overflow = prevOverflow
}
}, [open])
if (!open) return null
return createPortal(
<div className="disclaimer-modal-overlay" onClick={handleClose}>
<div className="disclaimer-modal-panel" onClick={(e) => e.stopPropagation()}>
<div className="auth-card glass registration-disclaimer registration-disclaimer--modal feedback-modal">
<button
type="button"
className="registration-disclaimer__close feedback-modal__close"
onClick={handleClose}
aria-label={t('logs.nmea_cancel')}
>
<X size={18} />
</button>
<div className="auth-header">
<FileText className="auth-icon accent" size={40} />
<h2>{t('logs.nmea_import_title')}</h2>
</div>
{error && <div className="track-error-msg">{error}</div>}
{duplicateFile && (
<div className="nmea-import-warning" role="status">
{t('logs.nmea_warn_duplicate_file')}
</div>
)}
{step === 'config' && (
<>
<p className="registration-disclaimer__intro">{t('logs.nmea_import_intro')}</p>
<label className="feedback-form__field">
<span>{t('logs.nmea_file_label')}</span>
<input
type="file"
accept=".nmea,.log,.txt"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFile(file)
}}
/>
</label>
{parseResult && (
<div className="nmea-import-summary">
<p>{t('logs.nmea_stats', {
lines: parseResult.stats.parsedLines,
types: parseResult.stats.sentenceTypes.join(', ')
})}</p>
{parseResult.warnings.includes('no_position') && (
<p>{t('logs.nmea_warn_no_position')}</p>
)}
</div>
)}
<fieldset className="nmea-import-mode">
<legend>{t('logs.nmea_mode_label')}</legend>
<label><input type="radio" name="nmea-mode" checked={mode === 'interval'} onChange={() => setMode('interval')} /> {t('logs.nmea_mode_interval')}</label>
<label><input type="radio" name="nmea-mode" checked={mode === 'change'} onChange={() => setMode('change')} /> {t('logs.nmea_mode_change')}</label>
<label><input type="radio" name="nmea-mode" checked={mode === 'both'} onChange={() => setMode('both')} /> {t('logs.nmea_mode_both')}</label>
</fieldset>
{(mode === 'interval' || mode === 'both') && (
<label className="feedback-form__field">
<span>{t('logs.nmea_interval_label')}</span>
<select value={intervalMinutes} onChange={(e) => setIntervalMinutes(Number(e.target.value))}>
<option value={30}>30 min</option>
<option value={60}>60 min</option>
<option value={90}>90 min</option>
<option value={120}>120 min</option>
</select>
</label>
)}
<label className="nmea-import-checkbox">
<input type="checkbox" checked={importTrack} onChange={(e) => setImportTrack(e.target.checked)} />
{t('logs.nmea_import_track')}
</label>
<div className="auth-actions feedback-form__actions">
<button type="button" className="btn secondary" onClick={handleClose}>{t('logs.nmea_cancel')}</button>
<button type="button" className="btn primary" onClick={goPreview} disabled={!parseResult}>
{t('logs.nmea_preview')}
</button>
</div>
</>
)}
{step === 'preview' && (
<>
<p>{t('logs.nmea_preview_hint', { count: candidates.length })}</p>
<div className="nmea-preview-actions">
<button type="button" className="btn secondary" onClick={() => toggleAll(true)}>{t('logs.nmea_select_all')}</button>
<button type="button" className="btn secondary" onClick={() => toggleAll(false)}>{t('logs.nmea_select_none')}</button>
</div>
<div className="nmea-preview-list">
{candidates.map((c) => (
<label key={c.id} className="nmea-preview-row">
<input
type="checkbox"
className="nmea-preview-row__check"
checked={selectedIds.has(c.id)}
onChange={() => toggleOne(c.id)}
/>
<div className="nmea-preview-row__body">
<div className="nmea-preview-row__meta">
<span className="nmea-preview-time">{c.event.time}</span>
<span className="nmea-preview-source">{t(`logs.nmea_source_${c.source}`)}</span>
</div>
<span className="nmea-preview-remarks">{c.event.remarks || c.event.mgk || '—'}</span>
</div>
</label>
))}
</div>
<div className="auth-actions feedback-form__actions">
<button type="button" className="btn secondary" onClick={() => setStep('config')}>{t('logs.nmea_back')}</button>
<button type="button" className="btn primary" onClick={applyImport}>{t('logs.nmea_apply')}</button>
</div>
</>
)}
{step === 'archive' && (
<>
<p>{t('logs.nmea_archive_question')}</p>
<div className="auth-actions feedback-form__actions">
<button type="button" className="btn secondary" onClick={() => finishArchive(false)}>
{t('logs.nmea_archive_discard')}
</button>
<button type="button" className="btn primary" onClick={() => finishArchive(true)}>
{t('logs.nmea_archive_keep')}
</button>
</div>
</>
)}
</div>
</div>
</div>,
document.body
)
}
+313
View File
@@ -0,0 +1,313 @@
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Users, User, Plus, Trash2, Edit2, X, Camera, Save } from 'lucide-react'
import { useDialog } from './ModalDialog.tsx'
import { resizeImageFile } from '../utils/resizeImageFile.js'
import type { PersonData, PersonRole } from '../types/person.js'
import { MAX_POOL_CREW_MEMBERS } from '../types/person.js'
import {
loadPersonPool,
savePerson,
deletePerson,
filterSkippers,
filterCrew,
type DecryptedPerson
} from '../services/personPool.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
const emptyPerson = (role: PersonRole): PersonData => ({
name: '',
address: '',
birthDate: '',
phone: '',
nationality: '',
passportNumber: '',
bloodType: '',
allergies: '',
diseases: '',
role,
photo: null
})
export default function PersonPoolForm() {
const { t } = useTranslation()
const { showConfirm } = useDialog()
const [people, setPeople] = useState<DecryptedPerson[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [formRole, setFormRole] = useState<PersonRole>('crew')
const [form, setForm] = useState<PersonData>(emptyPerson('crew'))
const [saving, setSaving] = useState(false)
const [photoError, setPhotoError] = useState<string | null>(null)
const fileRef = React.useRef<HTMLInputElement>(null)
const reload = useCallback(async () => {
setLoading(true)
setError(null)
try {
setPeople(await loadPersonPool())
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to load')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void reload()
}, [reload])
const openAdd = (role: PersonRole) => {
setEditingId(null)
setFormRole(role)
setForm(emptyPerson(role))
setPhotoError(null)
setShowForm(true)
}
const openEdit = (person: DecryptedPerson) => {
setEditingId(person.payloadId)
setFormRole(person.data.role)
setForm({ ...person.data })
setPhotoError(null)
setShowForm(true)
}
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.name.trim()) return
setSaving(true)
setError(null)
try {
const id = editingId ?? window.crypto.randomUUID()
await savePerson(id, { ...form, role: formRole }, !editingId)
setShowForm(false)
trackPlausibleEvent(PlausibleEvents.CREW_SAVED, { role: formRole, context: 'person_pool' })
await reload()
} catch (err: unknown) {
if (err instanceof Error && err.message === 'MAX_CREW') {
setError(t('crew.max_crew'))
} else {
setError(err instanceof Error ? err.message : 'Failed to save')
}
} finally {
setSaving(false)
}
}
const handleDelete = async (id: string) => {
if (
!(await showConfirm(
t('person_pool.delete_confirm'),
t('person_pool.title'),
t('logs.confirm_yes'),
t('logs.confirm_no')
))
) {
return
}
try {
await deletePerson(id)
await reload()
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to delete')
}
}
const skippers = filterSkippers(people)
const crewList = filterCrew(people)
if (loading) {
return (
<div className="tab-placeholder">
<Users className="header-logo spin" size={48} />
<p>{t('person_pool.loading')}</p>
</div>
)
}
const renderCard = (person: DecryptedPerson) => (
<div key={person.payloadId} className="crew-member-card glass">
<div className="crew-card-header">
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{person.data.photo ? (
<img src={person.data.photo} alt="" className="crew-card-avatar" />
) : (
<div className="crew-card-avatar-placeholder">
<User size={18} />
</div>
)}
<h4>{person.data.name}</h4>
</div>
<div className="card-actions">
<button type="button" className="btn-icon" onClick={() => openEdit(person)} title="Edit">
<Edit2 size={14} />
</button>
<button
type="button"
className="btn-icon logout"
onClick={() => void handleDelete(person.payloadId)}
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
</div>
{person.data.phone && (
<p className="help-text">
<strong>{t('crew.phone')}:</strong> {person.data.phone}
</p>
)}
</div>
)
return (
<section className="form-card" data-tour="profile-crew-pool">
<div className="form-header">
<Users size={24} className="form-icon" />
<h2>{t('person_pool.title')}</h2>
</div>
<p className="help-text mb-4">{t('person_pool.subtitle')}</p>
{error && <div className="auth-error mb-4">{error}</div>}
<div className="section-title-bar mb-4">
<h3>{t('person_pool.skippers_section')}</h3>
{!showForm && (
<button type="button" className="btn primary" style={{ width: 'auto', padding: '8px 16px' }} onClick={() => openAdd('skipper')}>
<Plus size={16} />
{t('person_pool.add_skipper')}
</button>
)}
</div>
{skippers.length === 0 ? (
<p className="help-text mb-4">{t('person_pool.no_skippers')}</p>
) : (
<div className="crew-grid mb-6">{skippers.map(renderCard)}</div>
)}
<div className="section-title-bar mb-4">
<h3>{t('person_pool.crew_section')}</h3>
{!showForm && crewList.length < MAX_POOL_CREW_MEMBERS && (
<button type="button" className="btn primary" style={{ width: 'auto', padding: '8px 16px' }} onClick={() => openAdd('crew')}>
<Plus size={16} />
{t('person_pool.add_crew')}
</button>
)}
</div>
{crewList.length === 0 ? (
<p className="help-text">{t('person_pool.no_crew')}</p>
) : (
<div className="crew-grid">{crewList.map(renderCard)}</div>
)}
{showForm && (
<form onSubmit={(e) => void handleSave(e)} className="member-editor-card glass mt-6">
<div className="editor-header mb-4">
<h3>
{editingId
? formRole === 'skipper'
? t('person_pool.edit_skipper')
: t('crew.edit_crew')
: formRole === 'skipper'
? t('person_pool.add_skipper')
: t('crew.add_crew')}
</h3>
<button type="button" className="btn-icon" onClick={() => setShowForm(false)}>
<X size={16} />
</button>
</div>
<div className="form-grid">
<div className="vessel-photo-wrapper">
<div className="vessel-photo-preview" onClick={() => fileRef.current?.click()}>
{form.photo ? (
<img src={form.photo} alt="" className="vessel-photo" />
) : (
<div className="vessel-photo-placeholder">
<User size={48} />
</div>
)}
<div className="vessel-photo-overlay">
<Camera size={24} />
</div>
</div>
<input
ref={fileRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0]
if (!file) return
void resizeImageFile(file)
.then((photo) => setForm((f) => ({ ...f, photo })))
.catch((err: unknown) => {
setPhotoError(err instanceof Error ? err.message : 'Image error')
})
}}
/>
{photoError && <div className="auth-error mt-2">{photoError}</div>}
</div>
<div className="input-group">
<label>{t('crew.name')} *</label>
<input
className="input-text"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
required
/>
</div>
<div className="input-group">
<label>{t('crew.address')}</label>
<input
className="input-text"
value={form.address}
onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))}
/>
</div>
<div className="input-group">
<label>{t('crew.birthdate')}</label>
<input
type="date"
className="input-text"
value={form.birthDate}
onChange={(e) => setForm((f) => ({ ...f, birthDate: e.target.value }))}
/>
</div>
<div className="input-group">
<label>{t('crew.phone')}</label>
<input
className="input-text"
value={form.phone}
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
/>
</div>
<div className="input-group">
<label>{t('crew.nationality')}</label>
<input
className="input-text"
value={form.nationality}
onChange={(e) => setForm((f) => ({ ...f, nationality: e.target.value }))}
/>
</div>
<div className="input-group">
<label>{t('crew.passport')}</label>
<input
className="input-text"
value={form.passportNumber}
onChange={(e) => setForm((f) => ({ ...f, passportNumber: e.target.value }))}
/>
</div>
</div>
<div className="editor-actions mt-4">
<button type="submit" className="btn primary" disabled={saving || !form.name.trim()}>
<Save size={18} />
{t('crew.save_member')}
</button>
</div>
</form>
)}
</section>
)
}
+21 -100
View File
@@ -3,9 +3,9 @@ import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { decryptJson } from '../services/crypto.js'
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
import { useLiveQuery } from 'dexie-react-hooks'
import { useDialog } from './ModalDialog.tsx'
import { Camera, Trash2 } from 'lucide-react'
@@ -90,109 +90,30 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
setUploading(true)
setError(null)
const reader = new FileReader()
reader.onload = (event) => {
const img = new Image()
img.onload = async () => {
try {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get canvas context')
let width = img.width
let height = img.height
const MAX_WIDTH = 1280
const MAX_HEIGHT = 720
// Calculate resizing conserving aspect ratio
if (width > MAX_WIDTH || height > MAX_HEIGHT) {
const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height)
width = Math.round(width * ratio)
height = Math.round(height * ratio)
}
canvas.width = width
canvas.height = height
ctx.drawImage(img, 0, 0, width, height)
// Compress to JPEG, 70% quality
const compressedBase64 = canvas.toDataURL('image/jpeg', 0.7)
// Encrypt
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const photoId = window.crypto.randomUUID()
const photoPayload = {
image: compressedBase64,
caption: caption.trim()
}
const encrypted = await encryptJson(photoPayload, masterKey)
const now = new Date().toISOString()
// Store locally
await db.photos.put({
payloadId: photoId,
entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
caption: '', // stored encrypted inside payload
updatedAt: now
})
// Queue for background sync
await db.syncQueue.put({
action: 'create',
type: 'photo',
payloadId: photoId,
logbookId,
data: JSON.stringify({
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
entryId
}),
updatedAt: now
})
setCaption('')
if (fileInputRef.current) fileInputRef.current.value = ''
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'logbook' })
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
console.error('Failed to process image:', err)
setError(err.message || 'Failed to process image')
} finally {
setUploading(false)
}
}
img.src = event.target?.result as string
try {
const compressedBase64 = await fileToCompressedJpegDataUrl(file)
await saveEntryPhoto({
logbookId,
entryId,
imageDataUrl: compressedBase64,
caption: caption.trim(),
analyticsContext: 'logbook'
})
setCaption('')
if (fileInputRef.current) fileInputRef.current.value = ''
} catch (err: unknown) {
console.error('Failed to process image:', err)
setError(err instanceof Error ? err.message : 'Failed to process image')
} finally {
setUploading(false)
}
reader.readAsDataURL(file)
}
const handleDelete = async (photoId: string) => {
if (await showConfirm(t('logs.photo_delete_confirm'), t('logs.photos_title'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
try {
const now = new Date().toISOString()
await db.photos.delete(photoId)
await db.syncQueue.put({
action: 'delete',
type: 'photo',
payloadId: photoId,
logbookId,
data: '',
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
await deleteEntryPhoto(logbookId, photoId)
} catch (err: unknown) {
console.error('Failed to delete photo:', err)
}
}
@@ -0,0 +1,25 @@
import { useTranslation } from 'react-i18next'
import { User } from 'lucide-react'
interface ProfileHeaderButtonProps {
onClick: () => void
}
export default function ProfileHeaderButton({ onClick }: ProfileHeaderButtonProps) {
const { t } = useTranslation()
const username = localStorage.getItem('active_username') || 'Skipper'
return (
<button
type="button"
className="btn-icon skipper-badge"
onClick={onClick}
title={t('dashboard.open_profile', { name: username })}
aria-label={t('dashboard.open_profile', { name: username })}
data-tour="nav-profile"
>
<User size={18} aria-hidden="true" />
<span className="skipper-badge__name">{username}</span>
</button>
)
}
+46 -13
View File
@@ -4,7 +4,11 @@ import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18n
import { decryptJson } from '../services/crypto.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import VesselForm from './VesselForm.tsx'
import CrewForm from './CrewForm.tsx'
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
import type { LogbookCrewSelectionData } from '../types/person.js'
import { emptyLogbookCrewSelection } from '../types/person.js'
import { legacyCrewRecordsToLogbookSelection } from '../utils/personSnapshots.js'
import type { PersonData } from '../types/person.js'
import LogEntriesList from './LogEntriesList.tsx'
import { Ship, Users, FileText, Lock, AlertCircle, Globe } from 'lucide-react'
@@ -31,7 +35,10 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
// Logbook data states
const [logbookTitle, setLogbookTitle] = useState('Logbook')
const [yacht, setYacht] = useState<any>(null)
const [crews, setCrews] = useState<any[]>([])
const [logbookCrewSelection, setLogbookCrewSelection] = useState<LogbookCrewSelectionData>(
emptyLogbookCrewSelection()
)
const [legacyCrews, setLegacyCrews] = useState<any[]>([])
const [entries, setEntries] = useState<any[]>([])
const [photos, setPhotos] = useState<any[]>([])
const [gpsTracks, setGpsTracks] = useState<any[]>([])
@@ -71,18 +78,42 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
}
setYacht(decYacht)
// Decrypt Crews
const decCrews = []
if (data.crews) {
for (const c of data.crews) {
const dec = await decryptJson(c.encryptedData, c.iv, c.tag, keyBuffer)
decCrews.push({
payloadId: c.payloadId,
data: dec
if (data.logbookCrewSelection) {
const decSel = await decryptJson(
data.logbookCrewSelection.encryptedData,
data.logbookCrewSelection.iv,
data.logbookCrewSelection.tag,
keyBuffer
)
if (decSel) {
setLogbookCrewSelection({
activeSkipperId: decSel.activeSkipperId ?? null,
activeCrewIds: Array.isArray(decSel.activeCrewIds) ? decSel.activeCrewIds : [],
snapshotsById:
decSel.snapshotsById && typeof decSel.snapshotsById === 'object'
? decSel.snapshotsById
: {}
})
}
}
setCrews(decCrews)
const decCrews: Array<{ payloadId: string; data: PersonData }> = []
if (data.crews) {
for (const c of data.crews) {
const dec = await decryptJson(c.encryptedData, c.iv, c.tag, keyBuffer)
if (dec) {
decCrews.push({
payloadId: c.payloadId,
data: dec as PersonData
})
}
}
}
setLegacyCrews(decCrews)
if (!data.logbookCrewSelection && decCrews.length > 0) {
setLogbookCrewSelection(legacyCrewRecordsToLogbookSelection(decCrews))
}
// Decrypt Entries
const decEntries = []
@@ -234,10 +265,12 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
)}
{activeTab === 'crew' && (
<CrewForm
<LogbookCrewPicker
logbookId="shared"
readOnly={true}
preloadedData={crews}
selectionOnly={true}
preloadedPool={legacyCrews.length > 0 ? legacyCrews : undefined}
preloadedSelection={logbookCrewSelection}
/>
)}
</main>
@@ -28,19 +28,19 @@ export default function RegistrationDisclaimer({
className={`auth-card glass registration-disclaimer${variant === 'view' ? ' registration-disclaimer--modal' : ''}`}
role="document"
>
{variant === 'view' && (
<button
type="button"
className="registration-disclaimer__close"
onClick={onDismiss}
aria-label={t('disclaimer.close')}
>
<X size={18} />
</button>
)}
<div className="auth-header">
<ScrollText className="auth-icon accent" size={48} />
<h2>{t('disclaimer.title')}</h2>
{variant === 'view' && (
<button
type="button"
className="registration-disclaimer__close"
onClick={onDismiss}
aria-label={t('disclaimer.close')}
>
<X size={18} />
</button>
)}
</div>
<p className="registration-disclaimer__intro">{t('disclaimer.intro')}</p>
+43 -34
View File
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { Settings as SettingsIcon, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
import { ensureLogbookKey } from '../services/logbookKeys.js'
import LogbookBackupPanel from './LogbookBackupPanel.tsx'
import LinkQrCode from './LinkQrCode.tsx'
import { useDialog } from './ModalDialog.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { apiFetch } from '../services/api.js'
@@ -314,23 +315,27 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</div>
{shareEnabled && shareLink && (
<div className="input-group mb-4 copy-link-row">
<input
type="text"
readOnly
value={shareLink}
className="input-text font-mono text-xs"
style={{ flex: 1, padding: '10px' }}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
className="btn secondary"
onClick={handleCopyShareLink}
style={{ width: 'auto', padding: '10px' }}
>
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
<div className="link-with-qr mb-4">
<div className="input-group copy-link-row">
<input
type="text"
readOnly
value={shareLink}
className="input-text font-mono text-xs"
style={{ flex: 1, padding: '10px' }}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
className="btn secondary"
onClick={handleCopyShareLink}
style={{ width: 'auto', padding: '10px' }}
title={t('settings.share_copy_btn')}
>
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
<LinkQrCode value={shareLink} />
</div>
)}
</div>
@@ -367,23 +372,27 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</div>
{inviteLink && (
<div className="input-group mb-6 copy-link-row">
<input
type="text"
readOnly
value={inviteLink}
className="input-text font-mono text-xs"
style={{ flex: 1, padding: '10px' }}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
className="btn secondary"
onClick={handleCopyInvite}
style={{ width: 'auto', padding: '10px' }}
>
{inviteCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
<div className="link-with-qr mb-6">
<div className="input-group copy-link-row">
<input
type="text"
readOnly
value={inviteLink}
className="input-text font-mono text-xs"
style={{ flex: 1, padding: '10px' }}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
className="btn secondary"
onClick={handleCopyInvite}
style={{ width: 'auto', padding: '10px' }}
title={t('settings.share_copy_btn')}
>
{inviteCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
<LinkQrCode value={inviteLink} />
</div>
)}
+69 -4
View File
@@ -14,6 +14,11 @@ import {
} from '../services/statsAggregation.js'
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
import {
loadLogbookEventSeries,
type EventSeriesPoint,
type EventSeriesSummary
} from '../services/eventSeriesAggregation.js'
interface StatsDashboardProps {
logbookId: string
@@ -217,7 +222,62 @@ function PropulsionBreakdown({ totals }: { totals: StatsTotals }) {
)
}
function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
function EventSeriesList({ title, points, emptyLabel }: { title: string; points: EventSeriesPoint[]; emptyLabel: string }) {
if (points.length === 0) {
return (
<div className="stats-event-series-block">
<h4 className="stats-section-subtitle">{title}</h4>
<p className="stats-section-sub">{emptyLabel}</p>
</div>
)
}
return (
<div className="stats-event-series-block">
<h4 className="stats-section-subtitle">{title}</h4>
<ul className="stats-event-series-list">
{points.map((point, idx) => (
<li key={`${point.entryId}-${point.time}-${idx}`} className="stats-event-series-item">
<span className="stats-event-series-when">
{new Date(point.date).toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })}
{' · '}
{point.time}
</span>
<span className="stats-event-series-value">{point.summary}</span>
</li>
))}
</ul>
</div>
)
}
function EventSeriesPanel({ series }: { series: EventSeriesSummary }) {
const { t } = useTranslation()
const motorPoints = series.motor.map((point) => ({
...point,
summary: point.summary === 'start'
? t('logs.live_motor_start')
: t('logs.live_motor_stop')
}))
return (
<div className="member-editor-card glass mt-6">
<h3 className="stats-section-title">{t('stats.event_series_title')}</h3>
<p className="stats-section-sub">{t('stats.event_series_hint')}</p>
<EventSeriesList title={t('stats.event_series_pressure')} points={series.pressure} emptyLabel={t('stats.event_series_empty')} />
<EventSeriesList title={t('stats.event_series_wind')} points={series.wind} emptyLabel={t('stats.event_series_empty')} />
<EventSeriesList title={t('stats.event_series_motor')} points={motorPoints} emptyLabel={t('stats.event_series_empty')} />
</div>
)
}
function LogbookScopeView({
summary,
eventSeries
}: {
summary: LogbookStatsSummary
eventSeries: EventSeriesSummary | null
}) {
const { t } = useTranslation()
const { travelDays, routePorts, trackSegments, totals } = summary
@@ -313,6 +373,8 @@ function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
<h3 className="stats-section-title">{t('stats.propulsion_title')}</h3>
<PropulsionBreakdown totals={totals} />
</div>
{eventSeries && <EventSeriesPanel series={eventSeries} />}
</>
)
}
@@ -323,18 +385,21 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [logbookStats, setLogbookStats] = useState<LogbookStatsSummary | null>(null)
const [eventSeries, setEventSeries] = useState<EventSeriesSummary | null>(null)
const [accountStats, setAccountStats] = useState<Awaited<ReturnType<typeof loadAccountStats>> | null>(null)
const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [lb, acc] = await Promise.all([
const [lb, acc, series] = await Promise.all([
loadLogbookStats(logbookId, logbookTitle, true),
loadAccountStats(false)
loadAccountStats(false),
loadLogbookEventSeries(logbookId)
])
setLogbookStats(lb)
setAccountStats(acc)
setEventSeries(series)
} catch (err: unknown) {
console.error('Failed to load statistics:', err)
setError(err instanceof Error ? err.message : 'Failed to load statistics.')
@@ -397,7 +462,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
<p>{t('stats.loading')}</p>
</div>
) : scope === 'logbook' && logbookStats ? (
<LogbookScopeView summary={logbookStats} />
<LogbookScopeView summary={logbookStats} eventSeries={eventSeries} />
) : scope === 'account' && accountStats ? (
<>
<TotalsGrid totals={accountStats.totals} />
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AlertTriangle } from 'lucide-react'
import {
getSyncConflicts,
subscribeSyncConflicts,
type SyncConflict
} from '../services/syncConflicts.js'
import {
resolveSyncConflictKeepLocal,
resolveSyncConflictUseServer
} from '../services/sync.js'
interface SyncConflictBannerProps {
logbookId: string | null
}
export default function SyncConflictBanner({ logbookId }: SyncConflictBannerProps) {
const { t } = useTranslation()
const [items, setItems] = useState<SyncConflict[]>([])
useEffect(() => {
const refresh = () => {
setItems(logbookId ? getSyncConflicts(logbookId) : getSyncConflicts())
}
refresh()
return subscribeSyncConflicts(refresh)
}, [logbookId])
if (items.length === 0) return null
const first = items[0]
return (
<div className="sync-conflict-banner" role="alert">
<AlertTriangle size={20} aria-hidden />
<div className="sync-conflict-banner__body">
<strong>{t('sync.conflict_title')}</strong>
<p>
{t('sync.conflict_message', {
count: items.length,
id: first.payloadId.slice(0, 8)
})}
</p>
<div className="sync-conflict-banner__actions">
<button
type="button"
className="btn secondary"
onClick={() => void resolveSyncConflictUseServer(first)}
>
{t('sync.conflict_use_server')}
</button>
<button
type="button"
className="btn primary"
onClick={() => void resolveSyncConflictKeepLocal(first)}
>
{t('sync.conflict_keep_local')}
</button>
</div>
</div>
</div>
)
}
@@ -30,6 +30,7 @@ import {
} from 'lucide-react'
import AccountDangerZone from './AccountDangerZone.tsx'
import UserProfilePreferences from './UserProfilePreferences.tsx'
import PersonPoolForm from './PersonPoolForm.tsx'
import BetaBadge from './BetaBadge.tsx'
import { useDialog } from './ModalDialog.tsx'
import {
@@ -487,6 +488,8 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
<UserProfilePreferences userId={profile.userId} />
</div>
<PersonPoolForm />
<section className="member-editor-card glass">
<div className="profile-section-header">
<Shield size={20} />
+3 -1
View File
@@ -17,7 +17,9 @@ describe('AppTourContext step order', () => {
expect(profileIndex).toBeGreaterThan(FULL_STEP_ORDER.indexOf('nav_feedback'))
expect(prefsIndex).toBe(profileIndex + 1)
expect(finishIndex).toBe(prefsIndex + 1)
expect(FULL_STEP_ORDER).toHaveLength(12)
expect(FULL_STEP_ORDER).toContain('profile_crew_pool')
expect(FULL_STEP_ORDER).toContain('nav_logbook_crew')
expect(FULL_STEP_ORDER).toHaveLength(13)
})
it('excludes profile, stats and feedback from demo tour', () => {
+19 -6
View File
@@ -26,7 +26,8 @@ export type TourStepId =
| 'entry_open'
| 'entry_track'
| 'nav_vessel'
| 'nav_crew'
| 'profile_crew_pool'
| 'nav_logbook_crew'
| 'nav_stats'
| 'nav_feedback'
| 'nav_profile'
@@ -71,7 +72,8 @@ export const FULL_STEP_ORDER: TourStepId[] = [
'entry_open',
'entry_track',
'nav_vessel',
'nav_crew',
'profile_crew_pool',
'nav_logbook_crew',
'nav_stats',
'nav_feedback',
'nav_profile',
@@ -81,6 +83,7 @@ export const FULL_STEP_ORDER: TourStepId[] = [
/** Public demo has no stats/feedback/profile UI — skip those steps. */
export const DEMO_EXCLUDED_STEPS: TourStepId[] = [
'profile_crew_pool',
'nav_stats',
'nav_feedback',
'nav_profile',
@@ -97,7 +100,7 @@ const LOGBOOK_TOUR_STEPS = new Set<TourStepId>([
'entry_open',
'entry_track',
'nav_vessel',
'nav_crew',
'nav_logbook_crew',
'nav_stats',
'nav_feedback'
])
@@ -112,7 +115,8 @@ const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
entry_open: '[data-tour="entry-first"]',
entry_track: '[data-tour="entry-track"]',
nav_vessel: '[data-tour="nav-vessel"]',
nav_crew: '[data-tour="nav-crew"]',
profile_crew_pool: '[data-tour="profile-crew-pool"]',
nav_logbook_crew: '[data-tour="nav-logbook-crew"]',
nav_stats: '[data-tour="stats-dashboard"]',
nav_feedback: '[data-tour="feedback-form"]',
nav_profile: '[data-tour="nav-profile"]',
@@ -127,7 +131,9 @@ export function tourStepOpensEntry(stepId: TourStepId): boolean {
export function getTourTargetDelay(stepId: TourStepId): number {
if (stepId === 'entry_track') return 400
if (stepId === 'nav_feedback') return 180
if (stepId === 'nav_profile' || stepId === 'profile_preferences') return 250
if (stepId === 'nav_profile' || stepId === 'profile_preferences' || stepId === 'profile_crew_pool') {
return 250
}
return 0
}
@@ -183,8 +189,15 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
nav.setSelectedEntryId(null)
nav.setActiveTab('vessel')
}
if (stepId === 'nav_crew') {
if (stepId === 'profile_crew_pool') {
nav.setSelectedEntryId(null)
nav.setLogbookActive(false)
nav.setProfileOpen(true)
}
if (stepId === 'nav_logbook_crew') {
nav.setSelectedEntryId(null)
nav.setProfileOpen(false)
nav.setLogbookActive(true)
nav.setActiveTab('crew')
}
if (stepId === 'nav_stats') {
+249 -40
View File
@@ -13,6 +13,17 @@
"sv": "Svenska",
"nb": "Norsk"
},
"dialog": {
"ok": "OK",
"yes": "Ja",
"no": "Nej"
},
"errors": {
"load_failed": "Data kunne ikke indlæses.",
"save_failed": "Ændringer kunne ikke gemmes.",
"delete_failed": "Sletning mislykkedes.",
"export_failed": "Eksport mislykkedes."
},
"common": {
"unsaved_changes_title": "Ikke gemte ændringer",
"unsaved_changes_message": "Du har ændringer, der ikke er gemt. Vil du virkelig forlade siden? Dine ændringer vil gå tabt.",
@@ -22,7 +33,7 @@
"nav": {
"dashboard": "Dashboard",
"vessel": "Skibsdata",
"crew": "Besætningsliste",
"crew": "Crew",
"deviation": "Tabel over distraktioner",
"logs": "Indlæg i logbogen",
"stats": "Statistik",
@@ -68,7 +79,12 @@
"enter_pin_placeholder": "Indtast din pinkode...",
"decrypt_with_pin": "Afkodning",
"use_recovery_instead": "Brug genoprettelsesnøgler i stedet",
"error_incorrect_pin": "Forkert PIN-kode. Dekryptering mislykkedes."
"error_incorrect_pin": "Forkert PIN-kode. Dekryptering mislykkedes.",
"error_invalid_host": "Passkeys virker ikke via 127.0.0.1. Åbn appen via localhost.",
"use_localhost_link": "Skift til localhost",
"error_passkey_cancelled": "Passkey-login blev annulleret eller udløb. Prøv igen.",
"error_invalid_rp_id": "Passkey-domæne matcher ikke (RP ID). Brug http://localhost:5173 med RP_ID=localhost i .env til lokal udvikling.",
"error_session_incomplete": "Login ufuldstændig. Log ind med passkey igen."
},
"pwa": {
"title": "Installer app",
@@ -87,13 +103,18 @@
"update_title": "Opdatering tilgængelig",
"update_desc": "En ny version af Kapteins Daagbok er klar. Opdater venligst for at få de seneste ændringer.",
"update_now": "Opdater nu",
"update_reloading": "Indlæser..."
"update_reloading": "Indlæser...",
"storage_persist_hint": "Browseren kan slette offline-data. Tillad permanent lagring, så din logbog forbliver beskyttet."
},
"sync": {
"status_synced": "Synkroniseret",
"status_syncing": "Synkroniser...",
"status_offline": "Offline-cache",
"status_unsynced": "Usynkroniserede ændringer"
"status_unsynced": "Usynkroniserede ændringer",
"conflict_title": "Synkroniseringskonflikt",
"conflict_message": "{{count}} ændring(er) kunne ikke synkroniseres (post {{id}}…). Vælg hvilken version der skal gælde.",
"conflict_use_server": "Brug serverversion",
"conflict_keep_local": "Behold min version"
},
"vessel": {
"title": "Skibets stamdata",
@@ -145,7 +166,8 @@
"sign_cleared_skipper_re_sign_title": "Skippers underskrift fjernet",
"sign_cleared_skipper_re_sign": "Hændelsesloggen er blevet ændret. Skipperens underskrift er blevet fjernet. Godkend venligst igen.",
"date": "dato",
"day_of_travel": "Rejsedag / rejsedag",
"day_of_travel": "Rejsedag",
"travel_day_number": "Rejsedag {{number}}",
"departure": "Starthavn (rejse fra)",
"destination": "Destinationsport (til)",
"route": "Rejse fra/til",
@@ -181,22 +203,111 @@
"sign_badge_skipper_title_valid": "Skipper har udgivet",
"sign_badge_skipper_title_invalid": "Skippers signatur er ugyldig - indholdet er blevet ændret",
"sign_classic_or_passkey": "Valgfrit: klassisk underskrift eller Passkey-frigivelse ovenfor",
"sign_crew_passkey_hint": "Besætningsmedlemmer med skriveadgang kan frigive via Passkey.",
"sign_crew_passkey_hint": "Crew-medlemmer med skriveadgang kan frigive via Passkey.",
"sign_offline_hint": "Passkey-Godkendelse kræver internet - klassisk underskrift mulig offline",
"sign_lock_notice": "Efter underskrivelsen kan der ikke foretages ændringer i logbogen (undtagen fotos), uden at skipper og besætning skal skrive under igen.",
"sign_lock_active": "Denne post er underskrevet. Ændringer i logbogen (undtagen fotos) fjerner automatisk skipperens og besætningens underskrifter.",
"sign_lock_notice": "Efter underskrivelsen kan der ikke foretages ændringer i logbogen (undtagen fotos), uden at skipper og crew skal skrive under igen.",
"sign_lock_active": "Denne post er underskrevet. Ændringer i logbogen (undtagen fotos) fjerner automatisk skipperens og crews underskrifter.",
"sign_lock_warning_title": "Bekræft underskrift",
"sign_lock_warning": "Efter underskrivelsen er det ikke længere muligt at foretage ændringer i logbogen (undtagen fotos), uden at skipper og besætning skal skrive under igen.\n\nVil du gerne fortsætte?",
"sign_lock_warning": "Efter underskrivelsen er det ikke længere muligt at foretage ændringer i logbogen (undtagen fotos), uden at skipper og crew skal skrive under igen.\n\nVil du gerne fortsætte?",
"sign_proceed": "Tegn",
"sign_cancel": "Annuller",
"sign_cleared_re_sign_title": "Underskrifter fjernet",
"sign_cleared_re_sign": "Logbogsoptegnelsen er blevet ændret. Skipperens og besætningens underskrifter er blevet fjernet. Underskriv venligst igen.",
"sign_cleared_re_sign": "Logbogsoptegnelsen er blevet ændret. Skipperens og crews underskrifter er blevet fjernet. Underskriv venligst igen.",
"no_entries": "Ingen logbogsposter fundet for denne yacht. Opret din første rejsedag!",
"back_to_list": "Tilbage til tidsskriftslisten",
"save": "Gem logbogsside",
"saving": "Vil blive reddet...",
"saved": "Logbogsside gemt med succes!",
"loading": "Dagbogen er ved at blive indlæst.",
"view_mode_label": "Visning",
"view_list": "Liste",
"live_mode": "Live",
"live_title": "Live-journal",
"live_loading": "Live-journal indlæses...",
"live_retry": "Prøv igen",
"live_load_error": "Live-journal kunne ikke indlæses.",
"live_action_error": "Indtastning kunne ikke gemmes.",
"live_open_editor": "Fuld editor",
"live_actions_label": "Hurtighandlinger",
"live_stream_label": "Hændelseslog",
"live_stream_title": "Journal",
"live_no_events": "Ingen indtastninger endnu — tryk på en handling.",
"live_motor_start": "Motor Start",
"live_motor_stop": "Motor Stop",
"live_cast_off": "Afsejling",
"live_moor": "Anløb",
"live_sails_btn": "Sejl",
"live_sails_pick": "Vælg sejl",
"live_sails_pick_hint": "Tryk på flere sejl (tryk igen for at fravælge), og indtast derefter.",
"live_sails_selected": "Valgt: {{sails}}",
"live_sails_confirm": "Indtast",
"live_sails_confirm_count": "Indtast ({{count}})",
"live_sails": "Sejl: {{sails}}",
"live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS ikke tilgængelig. Indtast bredde- og længdegrad manuelt, eller prøv igen med GPS-knappen.",
"live_fix_gps_loading": "Henter GPS-position…",
"live_fix_invalid": "Indtast gyldige koordinater (bredde 90…90, længde 180…180).",
"live_fix_lat_placeholder": "Bredde (Lat)",
"live_fix_lng_placeholder": "Længde (Lng)",
"live_photo_btn": "Foto (kamera)",
"live_photo_capture_btn": "Tag billede",
"live_photo_save_btn": "Gem",
"live_photo_retake_btn": "Tag igen",
"live_photo_capture_failed": "Optagelse mislykkedes. Prøv igen.",
"live_photo_open_camera_btn": "Åbn kamera",
"live_photo_native_hint": "Tag et foto med enhedens kamera og gem det her bagefter.",
"live_photo_camera_starting": "Starter kamera…",
"live_photo_camera_denied": "Kameraadgang nægtet eller utilgængelig.",
"live_photo_camera_unavailable": "Kamera understøttes ikke i denne browser.",
"live_photo_error": "Foto kunne ikke gemmes.",
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto taget",
"live_undo_photo_hint": "Foto gemt",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Indtast tekst…",
"live_comment_confirm": "Indtast",
"live_gps_error": "GPS-position kunne ikke bestemmes.",
"live_gps_start_hint": "Begynd altid dagens rejse med en position.",
"live_event_generic": "Hændelse",
"live_weather_btn": "Vejr",
"live_weather_owm_btn": "Hent OpenWeatherMap-vejr",
"live_weather_owm_loading": "Henter vejr…",
"live_weather_fix_required": "Log først en GPS-fix (Fix-knap) for at hente OpenWeatherMap-vejr. Positionen må højst være 6 timer gammel.",
"live_weather_fix_stale": "Den seneste GPS-fix er ældre end 6 timer. Log en ny fix, før du henter vejr.",
"live_wind_btn": "Vind",
"live_temp_btn": "T °C",
"live_pressure_btn": "Lufttryk",
"live_precip_btn": "Nedbør",
"live_sea_state_btn": "Søgang",
"live_course_btn": "Kurs",
"live_fuel_btn": "Diesel",
"live_water_btn": "Vand",
"live_wind_entry": "Vind {{value}}",
"live_temp_entry": "Temperatur {{temp}} °C",
"live_pressure_entry": "Lufttryk {{value}} hPa",
"live_precip_entry": "Nedbør {{value}}",
"live_sea_state_entry": "Søgang {{value}}",
"live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Vand +{{liters}} L",
"live_auto_position": "Auto-position",
"live_undo_hint": "Indtastning gemt",
"live_undo_btn": "Fortryd",
"live_pressure_placeholder": "f.eks. 1013",
"live_temp_placeholder": "f.eks. 18",
"live_precip_placeholder": "f.eks. let regn",
"live_sea_state_placeholder": "f.eks. 3",
"live_course_placeholder": "f.eks. 245",
"live_fuel_placeholder": "Optankede liter",
"live_water_placeholder": "Optankede liter",
"live_sog_btn": "SOG",
"live_stw_btn": "STW",
"live_sog_entry": "SOG {{speed}} kn",
"live_stw_entry": "STW {{speed}} kn",
"live_sog_placeholder": "f.eks. 5,2",
"live_stw_placeholder": "f.eks. 4,8",
"live_sog_hint": "Fart over grund (kn) — GPS-værdi forudfyldes, hvis tilgængelig.",
"delete_entry": "Slet tag",
"delete_confirm": "Er du sikker på, at du vil slette denne rejsedag permanent?",
"carry_over_tanks_title": "Overføre data fra den foregående dag?",
@@ -276,14 +387,64 @@
"track_map_error": "Kortet kunne ikke indlæses.",
"exporting": "Eksport...",
"share_unsupported": "Deling understøttes ikke på denne enhed. Filen er blevet downloadet i stedet.",
"invite_crew": "Inviter besætningen",
"invite_crew": "Inviter crewen",
"invite_link_copied": "Invitationslink kopieret til udklipsholderen!",
"invite_link_desc": "Del dette link med besætningsmedlemmer for at give dem skriveadgang til denne logbog.",
"collaborators_list": "Medlemmer / besætning",
"invite_link_desc": "Del dette link med Crew-medlemmer for at give dem skriveadgang til denne logbog.",
"collaborators_list": "Medlemmer / crew",
"revoke": "Fjerne",
"revoke_confirm": "Er du sikker på, at du vil tilbagekalde dette besætningsmedlems adgang?",
"revoke_confirm": "Er du sikker på, at du vil tilbagekalde dette Crew-medlems adgang?",
"invite_role": "Rolle",
"invite_expires": "Linket er gyldigt i 48 timer"
"invite_expires": "Linket er gyldigt i 48 timer",
"nmea_import_title": "Import NMEA log",
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
"nmea_import_btn": "Import NMEA",
"nmea_file_label": "NMEA file",
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
"nmea_mode_label": "Generate journal entries",
"nmea_mode_interval": "By time interval",
"nmea_mode_change": "On significant change",
"nmea_mode_both": "Both (merge)",
"nmea_interval_label": "Interval (minutes)",
"nmea_import_track": "Import GPS track from NMEA",
"nmea_preview": "Preview",
"nmea_preview_hint": "{{count}} suggested journal entries",
"nmea_select_all": "Select all",
"nmea_select_none": "Select none",
"nmea_source_interval": "Interval",
"nmea_source_change": "Event",
"nmea_apply": "Apply to journal",
"nmea_back": "Back",
"nmea_cancel": "Cancel",
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
"nmea_archive_keep": "Archive",
"nmea_archive_discard": "Discard",
"nmea_archive_stored": "NMEA archived: {{name}}",
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
"nmea_error_parse": "Could not read NMEA file.",
"nmea_error_read": "Could not read file.",
"nmea_error_no_file": "Please choose an NMEA file first.",
"nmea_error_no_selection": "Please select at least one journal entry.",
"nmea_remark_interval": "NMEA interval",
"nmea_remark_uncertain": "uncertain",
"nmea_remark_depth": "Depth {{depth}} m",
"nmea_change_course": "Course change {{from}}° → {{to}}°",
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
"nmea_change_depth": "Depth {{from}} → {{to}} m",
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
"nmea_change_engine_stop": "Engine off",
"nmea_change_autopilot_on": "Autopilot on",
"nmea_change_autopilot_off": "Autopilot off",
"nmea_change_gps_lost": "GPS fix lost",
"nmea_change_gps_regained": "GPS fix restored",
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop",
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries."
},
"dashboard": {
"title": "Dine logbøger",
@@ -300,14 +461,15 @@
"delete_btn": "Slet logbog",
"section_owned": "Mine logbøger",
"section_shared": "Fælles logbøger",
"section_shared_hint": "Du er blevet inviteret som besætningsmedlem. Skipperprofil og indstillinger tilhører ejeren.",
"section_shared_hint": "Du er blevet inviteret som Crew-medlem. Skipperprofil og indstillinger tilhører ejeren.",
"role_owner": "Egen logbog",
"role_owner_hint": "Du er ejer og skipper af denne logbog",
"role_crew": "Adgang for besætning",
"role_crew_hint": "Inviteret logbog - du kan arbejde som besætning og underskrive den",
"role_crew": "Adgang for crew",
"role_crew_hint": "Inviteret logbog - du kan arbejde som crew og underskrive den",
"role_read": "Læs kun",
"role_read_hint": "Opdelt logbog - kun visning, ingen redigering",
"open_profile": "Åben profil af {{name}}",
"open_logbook": "Åbn logbog „{{title}}“",
"edit_title": "Omdøb logbog",
"edit_placeholder": "Nyt navn på logbogen",
"edit_success": "Logbog omdøbt med succes",
@@ -438,23 +600,58 @@
"tour_desc": "Lad dig guide gennem de vigtigste områder i appen igen.",
"tour_restart": "Start turen igen",
"push_title": "Push-meddelelser",
"push_desc": "Som logbogsejer får du besked, når inviterede besætningsmedlemmer synkroniserer ændringer. Intet indhold overføres i ren tekst.",
"push_enable": "Giv os besked om ændringer i besætningen",
"push_desc": "Som logbogsejer får du besked, når inviterede Crew-medlemmer synkroniserer ændringer. Intet indhold overføres i ren tekst.",
"push_enable": "Giv os besked om ændringer i crewen",
"push_active": "Push-meddelelser er aktive på denne enhed.",
"push_unsupported": "Push-meddelelser understøttes ikke i denne browser.",
"push_denied_hint": "Notifikationer er blokeret. Tillad dem i browserens eller enhedens indstillinger.",
"push_ios_install_hint": "På iPhone/iPad: Føj app til startskærmen (iOS 16.4+) for at bruge push.",
"push_error": "Push-meddelelser kunne ikke aktiveres."
},
"person_pool": {
"title": "Stamm-Crew og skippere",
"subtitle": "Administrer din personpulje her skippere og crew til alle logbøger. Vælg aktiv crew per logbog og rejsedag fra puljen.",
"loading": "Indlæser personpulje…",
"skippers_section": "Skippere",
"crew_section": "Stamm-Crew",
"add_skipper": "Tilføj skipper",
"add_crew": "Tilføj Crew-medlem",
"edit_skipper": "Rediger skipper",
"no_skippers": "Ingen skipper i puljen endnu.",
"no_crew": "Ingen Crew-medlemmer i puljen endnu.",
"delete_confirm": "Fjern denne person fra puljen?"
},
"logbook_crew": {
"title": "Crew for denne logbog",
"subtitle": "Vælg skipper og crew for denne logbog. Nye rejsedage arver valget som standard.",
"loading": "Indlæser crew…",
"active_skipper": "Skipper for denne logbog",
"active_crew": "Crew for denne logbog",
"no_skippers_in_pool": "Ingen skipper i puljen tilføj i brugerprofilen først.",
"no_crew_in_pool": "Ingen crew i puljen tilføj i brugerprofilen først.",
"no_skipper": "Ingen skipper valgt",
"unnamed": "Uden navn",
"save": "Gem crew",
"saved": "Logbog-Crew gemt.",
"selection_only_hint": "Du ser den crew ejeren har valgt (delt logbog)."
},
"entry_crew": {
"title": "Crew på denne rejsedag",
"subtitle": "Kan afvige fra logbogstandard. Følgende dage arver fra foregående dag.",
"day_skipper": "Skipper denne dag",
"day_crew": "Crew denne dag",
"no_skipper": "Ingen skipper valgt",
"no_crew": "Ingen crew valgt"
},
"crew": {
"title": "Skipper- og besætningsprofiler",
"title": "Skipper- og Crew-profiler",
"skipper_section": "Skipper-profil",
"skipper_read_only_hint": "Skipperprofilen kan kun redigeres af logbogens ejer.",
"crew_section": "Besætningsliste",
"add_crew": "Tilføj besætningsmedlem",
"edit_crew": "Rediger besætningsmedlem",
"no_crew": "Ingen besætningsmedlemmer tilføjet endnu.",
"max_crew": "Det maksimale antal på 5 besætningsmedlemmer er nået.",
"crew_section": "Crew-liste",
"add_crew": "Tilføj Crew-medlem",
"edit_crew": "Rediger Crew-medlem",
"no_crew": "Ingen Crew-medlemmer tilføjet endnu.",
"max_crew": "Det maksimale antal på 12 Crew-medlemmer i puljen er nået.",
"name": "Navn",
"address": "adresse",
"birthdate": "Fødselsdag",
@@ -467,8 +664,8 @@
"save": "Gem skipper-data",
"save_member": "Gem medlem",
"saved": "Skipperprofilen er blevet gemt!",
"loading": "Besætningsfilerne er indlæst.",
"delete_confirm": "Er du sikker på, at du vil fjerne dette besætningsmedlem?"
"loading": "Crew-filerne er indlæst.",
"delete_confirm": "Er du sikker på, at du vil fjerne dette Crew-medlem?"
},
"deviation": {
"title": "Tabel over kompasafvigelser",
@@ -490,13 +687,15 @@
"weather_date_mismatch": "Vejrdata kan kun hentes for i dag ({{today}}). Dette logbogsindlæg er dateret {{date}}.",
"gps_error": "Indtast en placering, eller find GPS-koordinaterne.",
"share_title": "Del logbog (skrivebeskyttet)",
"share_desc": "Aktivér denne mulighed for at oprette et offentligt, skrivebeskyttet link. Alle med linket kan se dine rejser, yachtprofiler og besætning. Krypteringsnøglerne overføres aldrig til serveren (de forbliver i hash-delen af URL'en).",
"share_desc": "Aktivér denne mulighed for at oprette et offentligt, skrivebeskyttet link. Alle med linket kan se dine rejser, yachtprofiler og crew. Krypteringsnøglerne overføres aldrig til serveren (de forbliver i hash-delen af URL'en).",
"share_privacy_warning": "Anbefaling: Del kun dette link privat (f.eks. via e-mail eller messenger), ikke på sociale medier.",
"share_enable": "Aktivér offentligt link",
"share_copied": "Link kopieret!",
"share_copy_btn": "Kopier link",
"link_qr_hint": "Scan QR-koden med din telefon",
"link_qr_alt": "QR-kode til linket",
"danger_zone_title": "Farezone",
"danger_zone_desc": "Når du sletter din konto, slettes alle dine Passkey'er, logbøger, skibsdata, besætningsprofiler, rejseindlæg og E2E-nøgler uigenkaldeligt. Denne handling kan ikke fortrydes.",
"danger_zone_desc": "Når du sletter din konto, slettes alle dine Passkey'er, logbøger, skibsdata, Crew-profiler, rejseindlæg og E2E-nøgler uigenkaldeligt. Denne handling kan ikke fortrydes.",
"delete_account_btn": "Slet konto uigenkaldeligt",
"delete_account_confirm_title": "Slette konto?",
"delete_account_confirm_desc": "Er du helt sikker på, at du vil slette din konto uigenkaldeligt og alle tilknyttede logbøger og E2E-krypterede data?",
@@ -506,13 +705,13 @@
"delete_backup_hint": "Tip: Lav sikkerhedskopier af dine logbøger (.daagbok.json) i indstillingerne for hver logbog, før du sletter dem.",
"deleting_account": "Kontoen vil blive slettet...",
"invite_push_prompt_title": "Aktivere push-meddelelser?",
"invite_push_prompt_message": "Så snart inviterede besætningsmedlemmer synkroniserer ændringer, kan du blive informeret via push. Intet logbogsindhold sendes i almindelig tekst.",
"invite_push_prompt_ios_message": "Så snart besætningsmedlemmerne synkroniserer ændringer, kan du blive informeret via push. På iPhone/iPad: Føj appen til startskærmen (iOS 16.4+), og aktiver derefter push i brugerprofilen.",
"invite_push_prompt_message": "Så snart inviterede Crew-medlemmer synkroniserer ændringer, kan du blive informeret via push. Intet logbogsindhold sendes i almindelig tekst.",
"invite_push_prompt_ios_message": "Så snart Crew-medlemmerne synkroniserer ændringer, kan du blive informeret via push. På iPhone/iPad: Føj appen til startskærmen (iOS 16.4+), og aktiver derefter push i brugerprofilen.",
"invite_push_prompt_enable": "Aktiver nu",
"invite_push_prompt_later": "Senere",
"invite_push_prompt_success": "Push-meddelelser er aktive på denne enhed.",
"backup_title": "Sikkerhedskopiering og gendannelse",
"backup_desc": "Komplet krypteret backup af denne logbog (poster, fotos, GPS-spor, besætning, skib). Beskyttet med backup-passphrase - til gendannelse til denne eller en ny konto.",
"backup_desc": "Komplet krypteret backup af denne logbog (poster, fotos, GPS-spor, crew, skib). Beskyttet med backup-passphrase - til gendannelse til denne eller en ny konto.",
"backup_export_title": "Opret backup",
"backup_export_desc": "Downloader alle lokale data som .daagbok.json. Hold filen og adgangssætningen adskilt og sikker.",
"backup_restore_title": "Gendan sikkerhedskopi",
@@ -542,7 +741,7 @@
"backup_new_id_confirm": "Importere backup'en som en ny logbog med et nyt ID?",
"backup_stat_entries": "{{count}} Rejsedage",
"backup_stat_photos": "{{count}} Fotos",
"backup_stat_crew": "{{count}} Besætningens poster",
"backup_stat_crew": "{{count}} Crew-poster",
"backup_stat_tracks": "{{count}} GPS-spor",
"backup_exported_at": "Eksporteret: {{date}}"
},
@@ -663,7 +862,13 @@
"unit_l": "L",
"day_label": "Dag {{day}}",
"account_logbooks": "Et overblik over logbøger",
"col_logbook": "Logbog"
"col_logbook": "Logbog",
"event_series_title": "Hændelsesforløb",
"event_series_hint": "Kronologiske værdier fra hændelsesloggen.",
"event_series_pressure": "Lufttryk",
"event_series_wind": "Vind",
"event_series_motor": "Motor",
"event_series_empty": "Ingen indtastninger endnu."
},
"tour": {
"skip": "Spring turen over",
@@ -678,7 +883,7 @@
},
"welcome_public": {
"title": "Velkommen om bord!",
"body": "Udforsk vores demo-logbog med tre dages rejse i Kielerfjorden - uden en konto. Denne korte tur viser dig skibsdata, besætning og logbogsposter."
"body": "Udforsk vores demo-logbog med tre dages rejse i Kielerfjorden - uden en konto. Denne korte tur viser dig skibsdata, crew og logbogsposter."
},
"nav_logs": {
"title": "Indlæg i logbogen",
@@ -700,9 +905,13 @@
"title": "Skibsdata",
"body": "Indtast navn, dimensioner og tekniske data for din yacht - udfyld én gang, tilgængelig for alle rejsedage."
},
"nav_crew": {
"title": "Besætningsliste",
"body": "Administrer besætningsmedlemmer og tildel dem rejsedage senere."
"profile_crew_pool": {
"title": "Stamm-Crew og skippere",
"body": "I brugerprofilen vedligeholder du en personpulje flere skippere (f.eks. charter) og crew til alle logbøger."
},
"nav_logbook_crew": {
"title": "Crew per logbog",
"body": "Vælg skipper og crew fra puljen til denne logbog. Rejsedage arver valget som standard."
},
"nav_stats": {
"title": "Statistik-dashboard",
@@ -728,7 +937,7 @@
},
"seo": {
"title": "Kapteins Daagbok - Gratis digital yachtlogbog (reklamefri)",
"description": "Gratis, reklamefri digital yachtlogbog med end-to-end-kryptering og Passkey-login. Dokumenter sikkert rejsedage, GPS-spor, besætnings- og skibsdata - også offline som PWA.",
"description": "Gratis, reklamefri digital yachtlogbog med end-to-end-kryptering og Passkey-login. Dokumenter sikkert rejsedage, GPS-spor, Crew- og skibsdata - også offline som PWA.",
"keywords": "Yachtlogbog, skibslogbog, logbog om bord, sejlads, Passkey, E2E-kryptering, GPS-spor, maritim logbog, gratis, reklamefri, gratis, uden reklame",
"ogImageAlt": "Kapteins Daagbok Logo"
}
+220 -11
View File
@@ -13,6 +13,17 @@
"sv": "Svenska",
"nb": "Norsk"
},
"dialog": {
"ok": "OK",
"yes": "Ja",
"no": "Nein"
},
"errors": {
"load_failed": "Daten konnten nicht geladen werden.",
"save_failed": "Änderungen konnten nicht gespeichert werden.",
"delete_failed": "Löschen fehlgeschlagen.",
"export_failed": "Export fehlgeschlagen."
},
"common": {
"unsaved_changes_title": "Ungespeicherte Änderungen",
"unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.",
@@ -22,7 +33,7 @@
"nav": {
"dashboard": "Dashboard",
"vessel": "Schiffsdaten",
"crew": "Crew-Liste",
"crew": "Crew",
"deviation": "Ablenkungstabelle",
"logs": "Logbucheinträge",
"stats": "Statistik",
@@ -68,7 +79,12 @@
"enter_pin_placeholder": "Gib deine PIN ein...",
"decrypt_with_pin": "Entschlüsseln",
"use_recovery_instead": "Stattdessen Wiederherstellungsschlüssel verwenden",
"error_incorrect_pin": "Falsche PIN. Entschlüsselung fehlgeschlagen."
"error_incorrect_pin": "Falsche PIN. Entschlüsselung fehlgeschlagen.",
"error_invalid_host": "Passkeys funktionieren nicht über 127.0.0.1. Bitte die App über localhost öffnen.",
"use_localhost_link": "Zu localhost wechseln",
"error_passkey_cancelled": "Passkey-Anmeldung abgebrochen oder abgelaufen. Bitte erneut versuchen.",
"error_invalid_rp_id": "Passkey-Domain passt nicht (RP ID). Lokal nur http://localhost:5173 mit RP_ID=localhost in .env verwenden.",
"error_session_incomplete": "Anmeldung unvollständig. Bitte erneut mit Passkey anmelden."
},
"pwa": {
"title": "App installieren",
@@ -87,13 +103,18 @@
"update_title": "Update verfügbar",
"update_desc": "Eine neue Version von Kapteins Daagbok ist bereit. Bitte aktualisieren, um die neuesten Änderungen zu erhalten.",
"update_now": "Jetzt aktualisieren",
"update_reloading": "Wird geladen…"
"update_reloading": "Wird geladen…",
"storage_persist_hint": "Der Browser kann Offline-Daten löschen. Erlaube dauerhafte Speicherung, damit dein Logbuch geschützt bleibt (in den Browser-Einstellungen oder beim nächsten Hinweis)."
},
"sync": {
"status_synced": "Synchronisiert",
"status_syncing": "Synchronisiere…",
"status_offline": "Offline-Cache",
"status_unsynced": "Unsynchronisierte Änderungen"
"status_unsynced": "Unsynchronisierte Änderungen",
"conflict_title": "Synchronisationskonflikt",
"conflict_message": "{{count}} Änderung(en) konnten nicht synchronisiert werden (Eintrag {{id}}…). Bitte wähle, welche Version gelten soll.",
"conflict_use_server": "Server-Version übernehmen",
"conflict_keep_local": "Meine Version behalten"
},
"vessel": {
"title": "Schiffs-Stammdaten",
@@ -145,7 +166,8 @@
"sign_cleared_skipper_re_sign_title": "Skipper-Unterschrift entfernt",
"sign_cleared_skipper_re_sign": "Das Ereignisprotokoll wurde geändert. Die Skipper-Unterschrift wurde entfernt. Bitte erneut freigeben.",
"date": "Datum",
"day_of_travel": "Tag der Reise / Reisetag",
"day_of_travel": "Reisetag",
"travel_day_number": "Reisetag {{number}}",
"departure": "Start-Hafen (Reise von)",
"destination": "Ziel-Hafen (nach)",
"route": "Reise von/nach",
@@ -197,6 +219,95 @@
"saving": "Wird gespeichert...",
"saved": "Logbuchseite erfolgreich gespeichert!",
"loading": "Journal wird geladen...",
"view_mode_label": "Ansicht",
"view_list": "Liste",
"live_mode": "Live",
"live_title": "Live-Journal",
"live_loading": "Live-Journal wird geladen...",
"live_retry": "Erneut versuchen",
"live_load_error": "Live-Journal konnte nicht geladen werden.",
"live_action_error": "Eintrag konnte nicht gespeichert werden.",
"live_open_editor": "Vollständiger Editor",
"live_actions_label": "Schnellaktionen",
"live_stream_label": "Ereignisprotokoll",
"live_stream_title": "Journal",
"live_no_events": "Noch keine Einträge — tippe auf eine Aktion.",
"live_motor_start": "Motor Start",
"live_motor_stop": "Motor Stop",
"live_cast_off": "Ablegen",
"live_moor": "Anlegen",
"live_sails_btn": "Segel",
"live_sails_pick": "Segel auswählen",
"live_sails_pick_hint": "Mehrere Segel antippen (erneut antippen zum Abwählen), dann Eintragen.",
"live_sails_selected": "Auswahl: {{sails}}",
"live_sails_confirm": "Eintragen",
"live_sails_confirm_count": "Eintragen ({{count}})",
"live_sails": "Segel: {{sails}}",
"live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}",
"live_fix_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_fix_invalid": "Bitte gültige Koordinaten eingeben (Breite 90…90, Länge 180…180).",
"live_fix_lat_placeholder": "Breite (Lat)",
"live_fix_lng_placeholder": "Länge (Lng)",
"live_photo_btn": "Foto (Kamera)",
"live_photo_capture_btn": "Aufnehmen",
"live_photo_save_btn": "Speichern",
"live_photo_retake_btn": "Neu aufnehmen",
"live_photo_capture_failed": "Aufnahme fehlgeschlagen. Bitte erneut versuchen.",
"live_photo_open_camera_btn": "Kamera öffnen",
"live_photo_native_hint": "Foto mit der Gerätekamera aufnehmen und anschließend hier speichern.",
"live_photo_camera_starting": "Kamera wird gestartet…",
"live_photo_camera_denied": "Kamerazugriff verweigert oder nicht verfügbar.",
"live_photo_camera_unavailable": "Kamera wird von diesem Browser nicht unterstützt.",
"live_photo_error": "Foto konnte nicht gespeichert werden.",
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto aufgenommen",
"live_undo_photo_hint": "Foto gespeichert",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Freitext eingeben…",
"live_comment_confirm": "Eintragen",
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
"live_gps_start_hint": "Beginne deine Tagesreise immer mit einem Standort.",
"live_event_generic": "Ereignis",
"live_weather_btn": "Wetter",
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
"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_fix_stale": "Der letzte GPS-Fix ist älter als 6 Stunden. Bitte erneut einen Fix loggen, bevor du Wetter abrufst.",
"live_wind_btn": "Wind",
"live_temp_btn": "T °C",
"live_pressure_btn": "Luftdruck",
"live_precip_btn": "Niederschlag",
"live_sea_state_btn": "Seegang",
"live_course_btn": "Kurs",
"live_fuel_btn": "Diesel",
"live_water_btn": "Wasser",
"live_wind_entry": "Wind {{value}}",
"live_temp_entry": "Temperatur {{temp}} °C",
"live_pressure_entry": "Luftdruck {{value}} hPa",
"live_precip_entry": "Niederschlag {{value}}",
"live_sea_state_entry": "Seegang {{value}}",
"live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Wasser +{{liters}} L",
"live_auto_position": "Auto-Position",
"live_undo_hint": "Eintrag gespeichert",
"live_undo_btn": "Rückgängig",
"live_pressure_placeholder": "z. B. 1013",
"live_temp_placeholder": "z. B. 18",
"live_precip_placeholder": "z. B. leichter Regen",
"live_sea_state_placeholder": "z. B. 3",
"live_course_placeholder": "z. B. 245",
"live_fuel_placeholder": "Nachgefüllte Liter",
"live_water_placeholder": "Nachgefüllte Liter",
"live_sog_btn": "SOG",
"live_stw_btn": "STW",
"live_sog_entry": "SOG {{speed}} kn",
"live_stw_entry": "STW {{speed}} kn",
"live_sog_placeholder": "z. B. 5,2",
"live_stw_placeholder": "z. B. 4,8",
"live_sog_hint": "Fahrt über Grund (kn) — GPS-Wert wird vorgefüllt, wenn verfügbar.",
"delete_entry": "Tag löschen",
"delete_confirm": "Bist du sicher, dass du diesen Reisetag unwiderruflich löschen möchtest?",
"carry_over_tanks_title": "Daten vom Vortag übernehmen?",
@@ -273,6 +384,56 @@
"track_map_end": "Ziel",
"track_map_speed_slow": "langsam",
"track_map_speed_fast": "schnell",
"nmea_import_title": "NMEA-Protokoll importieren",
"nmea_import_intro": "Lade eine .nmea-Datei vom Bord-Logger. Die App schlägt Journal-Einträge vor — du entscheidest, was übernommen wird.",
"nmea_import_btn": "NMEA importieren",
"nmea_file_label": "NMEA-Datei",
"nmea_stats": "{{lines}} Sätze erkannt · Typen: {{types}}",
"nmea_warn_no_position": "Keine Positions-Sätze gefunden — Track und GPS-Felder können leer bleiben.",
"nmea_warn_duplicate_file": "Diese NMEA-Datei wurde bereits importiert. Ein erneuter Import derselben Datei fügt doppelte Journal-Einträge hinzu.",
"nmea_mode_label": "Journal-Einträge erzeugen",
"nmea_mode_interval": "Nach Zeitintervall",
"nmea_mode_change": "Bei signifikanter Änderung",
"nmea_mode_both": "Beides (zusammenführen)",
"nmea_interval_label": "Intervall (Minuten)",
"nmea_import_track": "GPS-Track aus NMEA übernehmen",
"nmea_preview": "Vorschau",
"nmea_preview_hint": "{{count}} vorgeschlagene Journal-Einträge",
"nmea_select_all": "Alle auswählen",
"nmea_select_none": "Keine auswählen",
"nmea_source_interval": "Intervall",
"nmea_source_change": "Ereignis",
"nmea_apply": "In Journal übernehmen",
"nmea_back": "Zurück",
"nmea_cancel": "Abbrechen",
"nmea_archive_question": "Rohprotokoll lokal archivieren? (Nur auf diesem Gerät, nicht synchronisiert.)",
"nmea_archive_keep": "Archivieren",
"nmea_archive_discard": "Verwerfen",
"nmea_archive_stored": "NMEA archiviert: {{name}}",
"nmea_archive_delete_confirm": "Archiviertes NMEA-Protokoll von diesem Gerät löschen?",
"nmea_error_no_samples": "Keine verwertbaren NMEA-Sätze in der Datei.",
"nmea_error_parse": "NMEA-Datei konnte nicht gelesen werden.",
"nmea_error_read": "Datei konnte nicht gelesen werden.",
"nmea_error_no_file": "Bitte zuerst eine NMEA-Datei wählen.",
"nmea_error_no_selection": "Bitte mindestens einen Journal-Eintrag auswählen.",
"nmea_remark_interval": "NMEA Intervall",
"nmea_remark_uncertain": "unsicher",
"nmea_remark_depth": "Tiefe {{depth}} m",
"nmea_change_course": "Kursänderung {{from}}° → {{to}}°",
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
"nmea_change_pressure": "Luftdruck {{from}} → {{to}} hPa",
"nmea_change_depth": "Tiefe {{from}} → {{to}} m",
"nmea_change_engine_start": "Motor an ({{rpm}} U/min)",
"nmea_change_engine_stop": "Motor aus",
"nmea_change_autopilot_on": "Autopilot ein",
"nmea_change_autopilot_off": "Autopilot aus",
"nmea_change_gps_lost": "GPS-Fix verloren",
"nmea_change_gps_regained": "GPS-Fix wiederhergestellt",
"nmea_change_water_temp": "Wassertemp. {{from}} → {{to}} °C",
"nmea_change_departure": "Abfahrt / Fahrtbeginn",
"nmea_change_anchor": "Ankern / Stop",
"nmea_change_speed": "Geschw. {{from}} → {{to}} kn",
"track_map_error": "Karte konnte nicht geladen werden.",
"exporting": "Exportiere...",
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.",
@@ -308,6 +469,7 @@
"role_read": "Nur Lesen",
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung",
"open_profile": "Profil von {{name}} öffnen",
"open_logbook": "Logbuch „{{title}}“ öffnen",
"edit_title": "Logbuch umbenennen",
"edit_placeholder": "Neuer Name des Logbuchs",
"edit_success": "Logbuch erfolgreich umbenannt",
@@ -446,6 +608,41 @@
"push_ios_install_hint": "Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), um Push zu nutzen.",
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden."
},
"person_pool": {
"title": "Stammcrew & Skipper",
"subtitle": "Lege hier deinen Personen-Pool an Skipper und Crew für alle Logbücher. Aus diesem Pool wählst du pro Logbuch und Reisetag die aktive Crew.",
"loading": "Personen-Pool wird geladen…",
"skippers_section": "Stammskipper",
"crew_section": "Stammcrew",
"add_skipper": "Skipper hinzufügen",
"add_crew": "Crew-Mitglied hinzufügen",
"edit_skipper": "Skipper bearbeiten",
"no_skippers": "Noch kein Skipper im Pool.",
"no_crew": "Noch keine Crew-Mitglieder im Pool.",
"delete_confirm": "Diese Person wirklich aus dem Pool entfernen?"
},
"logbook_crew": {
"title": "Crew für dieses Logbuch",
"subtitle": "Wähle Skipper und Crew für dieses Logbuch. Neue Reisetage übernehmen diese Auswahl standardmäßig.",
"loading": "Crew wird geladen…",
"active_skipper": "Skipper für dieses Logbuch",
"active_crew": "Crew für dieses Logbuch",
"no_skippers_in_pool": "Kein Skipper im Pool zuerst im Benutzerprofil anlegen.",
"no_crew_in_pool": "Keine Crew im Pool zuerst im Benutzerprofil anlegen.",
"no_skipper": "Kein Skipper gewählt",
"unnamed": "Unbenannt",
"save": "Crew speichern",
"saved": "Crew für das Logbuch gespeichert.",
"selection_only_hint": "Du siehst die vom Eigner festgelegte Crew (geteiltes Logbuch)."
},
"entry_crew": {
"title": "Crew an diesem Reisetag",
"subtitle": "Kann vom Logbuch-Standard abweichen. Folge-Reisetage übernehmen den Vortag.",
"day_skipper": "Skipper an diesem Tag",
"day_crew": "Crew an diesem Tag",
"no_skipper": "Kein Skipper gewählt",
"no_crew": "Keine Crew gewählt"
},
"crew": {
"title": "Skipper- & Crew-Profile",
"skipper_section": "Skipper-Profil",
@@ -454,7 +651,7 @@
"add_crew": "Crew-Mitglied hinzufügen",
"edit_crew": "Crew-Mitglied bearbeiten",
"no_crew": "Noch keine Crew-Mitglieder hinzugefügt.",
"max_crew": "Maximale Anzahl von 5 Crew-Mitgliedern erreicht.",
"max_crew": "Maximale Anzahl von 12 Crew-Mitgliedern im Pool erreicht.",
"name": "Name",
"address": "Anschrift",
"birthdate": "Geburtstag",
@@ -495,6 +692,8 @@
"share_enable": "Öffentlichen Link aktivieren",
"share_copied": "Link kopiert!",
"share_copy_btn": "Link kopieren",
"link_qr_hint": "QR-Code zum Scannen mit dem Smartphone",
"link_qr_alt": "QR-Code für den Link",
"danger_zone_title": "Gefahrenzone",
"danger_zone_desc": "Durch das Löschen deines Kontos werden alle deine Passkeys, Logbücher, Schiffsdaten, Crew-Profile, Reiseeinträge und E2E-Schlüssel unwiderruflich gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"delete_account_btn": "Konto unwiderruflich löschen",
@@ -663,7 +862,13 @@
"unit_l": "L",
"day_label": "Tag {{day}}",
"account_logbooks": "Logbücher im Überblick",
"col_logbook": "Logbuch"
"col_logbook": "Logbuch",
"event_series_title": "Ereignis-Verläufe",
"event_series_hint": "Chronologische Werte aus dem Ereignisprotokoll.",
"event_series_pressure": "Luftdruck",
"event_series_wind": "Wind",
"event_series_motor": "Motor",
"event_series_empty": "Keine Einträge vorhanden."
},
"tour": {
"skip": "Tour überspringen",
@@ -678,7 +883,7 @@
},
"welcome_public": {
"title": "Willkommen an Bord!",
"body": "Erkunde unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde ganz ohne Account. Diese kurze Tour zeigt dir Schiffsdaten, Crew und Logbucheinträge."
"body": "Erkunde unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde ganz ohne Account. Diese Tour zeigt dir Schiffsdaten, Crew-Auswahl und Logbucheinträge. Die Stammcrew pflegst du später im Benutzerprofil."
},
"nav_logs": {
"title": "Logbucheinträge",
@@ -700,9 +905,13 @@
"title": "Schiffsdaten",
"body": "Hinterlege Name, Maße und technische Daten deiner Yacht einmal ausfüllen, für alle Reisetage verfügbar."
},
"nav_crew": {
"title": "Crew-Liste",
"body": "Verwalte Besatzungsmitglieder und weise sie später Reisetagen zu."
"profile_crew_pool": {
"title": "Stammcrew & Skipper",
"body": "Im Benutzerprofil pflegst du deinen Personen-Pool mehrere Skipper (z. B. Charter) und Crew-Mitglieder für alle Logbücher."
},
"nav_logbook_crew": {
"title": "Crew pro Logbuch",
"body": "Wähle aus dem Pool, wer auf diesem Logbuch als Skipper und Crew gilt. Reisetage übernehmen diese Auswahl standardmäßig."
},
"nav_stats": {
"title": "Statistik-Dashboard",
+219 -10
View File
@@ -13,6 +13,17 @@
"sv": "Svenska",
"nb": "Norsk"
},
"dialog": {
"ok": "OK",
"yes": "Yes",
"no": "No"
},
"errors": {
"load_failed": "Could not load data.",
"save_failed": "Could not save changes.",
"delete_failed": "Could not delete.",
"export_failed": "Export failed."
},
"common": {
"unsaved_changes_title": "Unsaved changes",
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
@@ -22,7 +33,7 @@
"nav": {
"dashboard": "Dashboard",
"vessel": "Vessel Profile",
"crew": "Crew List",
"crew": "Crew",
"deviation": "Deviation Table",
"logs": "Logbook Entries",
"stats": "Statistics",
@@ -68,7 +79,12 @@
"enter_pin_placeholder": "Enter your PIN...",
"decrypt_with_pin": "Decrypt",
"use_recovery_instead": "Use recovery phrase instead",
"error_incorrect_pin": "Incorrect PIN. Decryption failed."
"error_incorrect_pin": "Incorrect PIN. Decryption failed.",
"error_invalid_host": "Passkeys do not work on 127.0.0.1. Please open the app via localhost.",
"use_localhost_link": "Switch to localhost",
"error_passkey_cancelled": "Passkey sign-in was cancelled or timed out. Please try again.",
"error_invalid_rp_id": "Passkey domain mismatch (RP ID). For local dev use http://localhost:5173 with RP_ID=localhost in .env.",
"error_session_incomplete": "Sign-in incomplete. Please sign in with your passkey again."
},
"pwa": {
"title": "Install app",
@@ -87,13 +103,18 @@
"update_title": "Update available",
"update_desc": "A new version of Kapteins Daagbok is ready. Reload to get the latest changes.",
"update_now": "Reload now",
"update_reloading": "Reloading…"
"update_reloading": "Reloading…",
"storage_persist_hint": "Your browser may delete offline data. Allow persistent storage to keep your logbook safe (browser settings or when prompted)."
},
"sync": {
"status_synced": "Synced",
"status_syncing": "Syncing…",
"status_offline": "Offline Cache",
"status_unsynced": "Unsynced changes"
"status_unsynced": "Unsynced changes",
"conflict_title": "Sync conflict",
"conflict_message": "{{count}} change(s) could not be synced (entry {{id}}…). Choose which version to keep.",
"conflict_use_server": "Use server version",
"conflict_keep_local": "Keep my version"
},
"vessel": {
"title": "Vessel Master Data",
@@ -145,7 +166,8 @@
"sign_cleared_skipper_re_sign_title": "Skipper signature removed",
"sign_cleared_skipper_re_sign": "The event log was changed. The skipper signature was removed. Please sign again.",
"date": "Date",
"day_of_travel": "Day of Travel",
"day_of_travel": "Travel day",
"travel_day_number": "Travel day {{number}}",
"departure": "Departure Port (von)",
"destination": "Destination Port (nach)",
"route": "Route / Journey",
@@ -197,6 +219,95 @@
"saving": "Saving...",
"saved": "Logbook page saved successfully!",
"loading": "Loading journal...",
"view_mode_label": "View",
"view_list": "List",
"live_mode": "Live",
"live_title": "Live Journal",
"live_loading": "Loading live journal...",
"live_retry": "Try again",
"live_load_error": "Could not load live journal.",
"live_action_error": "Could not save entry.",
"live_open_editor": "Full editor",
"live_actions_label": "Quick actions",
"live_stream_label": "Event log",
"live_stream_title": "Journal",
"live_no_events": "No entries yet — tap an action.",
"live_motor_start": "Engine Start",
"live_motor_stop": "Engine Stop",
"live_cast_off": "Cast off",
"live_moor": "Moor",
"live_sails_btn": "Sails",
"live_sails_pick": "Select sails",
"live_sails_pick_hint": "Tap multiple sails (tap again to deselect), then log.",
"live_sails_selected": "Selected: {{sails}}",
"live_sails_confirm": "Log entry",
"live_sails_confirm_count": "Log entry ({{count}})",
"live_sails": "Sails: {{sails}}",
"live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS unavailable. Enter latitude and longitude manually, or try again with the GPS button.",
"live_fix_gps_loading": "Getting GPS position…",
"live_fix_invalid": "Please enter valid coordinates (latitude 90…90, longitude 180…180).",
"live_fix_lat_placeholder": "Latitude (Lat)",
"live_fix_lng_placeholder": "Longitude (Lng)",
"live_photo_btn": "Photo (camera)",
"live_photo_capture_btn": "Capture",
"live_photo_save_btn": "Save",
"live_photo_retake_btn": "Retake",
"live_photo_capture_failed": "Capture failed. Please try again.",
"live_photo_open_camera_btn": "Open camera",
"live_photo_native_hint": "Take a photo with your device camera, then save it here.",
"live_photo_camera_starting": "Starting camera…",
"live_photo_camera_denied": "Camera access denied or unavailable.",
"live_photo_camera_unavailable": "Camera is not supported in this browser.",
"live_photo_error": "Could not save photo.",
"live_photo_entry": "Photo: {{caption}}",
"live_photo_entry_plain": "Photo captured",
"live_undo_photo_hint": "Photo saved",
"live_comment_btn": "Comment",
"live_comment_placeholder": "Enter text…",
"live_comment_confirm": "Log entry",
"live_gps_error": "Could not determine GPS position.",
"live_gps_start_hint": "Always start your day's voyage with a position fix.",
"live_event_generic": "Event",
"live_weather_btn": "Weather",
"live_weather_owm_btn": "Fetch OpenWeatherMap 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_fix_stale": "The last GPS fix is older than 6 hours. Log a new fix before fetching weather.",
"live_wind_btn": "Wind",
"live_temp_btn": "Temp °C",
"live_pressure_btn": "Pressure",
"live_precip_btn": "Precipitation",
"live_sea_state_btn": "Sea state",
"live_course_btn": "Course",
"live_fuel_btn": "Fuel",
"live_water_btn": "Water",
"live_wind_entry": "Wind {{value}}",
"live_temp_entry": "Temperature {{temp}} °C",
"live_pressure_entry": "Pressure {{value}} hPa",
"live_precip_entry": "Precipitation {{value}}",
"live_sea_state_entry": "Sea state {{value}}",
"live_course_entry": "Course {{course}}",
"live_fuel_entry": "Fuel +{{liters}} L",
"live_water_entry": "Water +{{liters}} L",
"live_auto_position": "Auto position",
"live_undo_hint": "Entry saved",
"live_undo_btn": "Undo",
"live_pressure_placeholder": "e.g. 1013",
"live_temp_placeholder": "e.g. 18",
"live_precip_placeholder": "e.g. light rain",
"live_sea_state_placeholder": "e.g. 3",
"live_course_placeholder": "e.g. 245",
"live_fuel_placeholder": "Liters refilled",
"live_water_placeholder": "Liters refilled",
"live_sog_btn": "SOG",
"live_stw_btn": "STW",
"live_sog_entry": "SOG {{speed}} kn",
"live_stw_entry": "STW {{speed}} kn",
"live_sog_placeholder": "e.g. 5.2",
"live_stw_placeholder": "e.g. 4.8",
"live_sog_hint": "Speed over ground (kn) — prefilled from GPS when available.",
"delete_entry": "Delete Day",
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
"carry_over_tanks_title": "Carry over from previous day?",
@@ -273,6 +384,56 @@
"track_map_end": "End",
"track_map_speed_slow": "slow",
"track_map_speed_fast": "fast",
"nmea_import_title": "Import NMEA log",
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
"nmea_import_btn": "Import NMEA",
"nmea_file_label": "NMEA file",
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries.",
"nmea_mode_label": "Generate journal entries",
"nmea_mode_interval": "By time interval",
"nmea_mode_change": "On significant change",
"nmea_mode_both": "Both (merge)",
"nmea_interval_label": "Interval (minutes)",
"nmea_import_track": "Import GPS track from NMEA",
"nmea_preview": "Preview",
"nmea_preview_hint": "{{count}} suggested journal entries",
"nmea_select_all": "Select all",
"nmea_select_none": "Select none",
"nmea_source_interval": "Interval",
"nmea_source_change": "Event",
"nmea_apply": "Apply to journal",
"nmea_back": "Back",
"nmea_cancel": "Cancel",
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
"nmea_archive_keep": "Archive",
"nmea_archive_discard": "Discard",
"nmea_archive_stored": "NMEA archived: {{name}}",
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
"nmea_error_parse": "Could not read NMEA file.",
"nmea_error_read": "Could not read file.",
"nmea_error_no_file": "Please choose an NMEA file first.",
"nmea_error_no_selection": "Please select at least one journal entry.",
"nmea_remark_interval": "NMEA interval",
"nmea_remark_uncertain": "uncertain",
"nmea_remark_depth": "Depth {{depth}} m",
"nmea_change_course": "Course change {{from}}° → {{to}}°",
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
"nmea_change_depth": "Depth {{from}} → {{to}} m",
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
"nmea_change_engine_stop": "Engine off",
"nmea_change_autopilot_on": "Autopilot on",
"nmea_change_autopilot_off": "Autopilot off",
"nmea_change_gps_lost": "GPS fix lost",
"nmea_change_gps_regained": "GPS fix restored",
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop",
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
"track_map_error": "Could not load map.",
"exporting": "Exporting...",
"share_unsupported": "Web sharing is not supported on this device. File downloaded instead.",
@@ -308,6 +469,7 @@
"role_read": "Read only",
"role_read_hint": "Shared logbook — view only, no editing",
"open_profile": "Open profile for {{name}}",
"open_logbook": "Open logbook “{{title}}”",
"edit_title": "Rename Logbook",
"edit_placeholder": "New name of the logbook",
"edit_success": "Logbook renamed successfully",
@@ -446,6 +608,41 @@
"push_ios_install_hint": "On iPhone/iPad: add the app to your Home Screen (iOS 16.4+) to use push notifications.",
"push_error": "Could not enable push notifications."
},
"person_pool": {
"title": "Core Crew & skippers",
"subtitle": "Maintain your person pool here — skippers and crew for all logbooks. Select active crew per logbook and travel day from this pool.",
"loading": "Loading person pool…",
"skippers_section": "Skippers",
"crew_section": "Core Crew",
"add_skipper": "Add skipper",
"add_crew": "Add crew member",
"edit_skipper": "Edit skipper",
"no_skippers": "No skippers in the pool yet.",
"no_crew": "No crew members in the pool yet.",
"delete_confirm": "Remove this person from the pool?"
},
"logbook_crew": {
"title": "Crew for this logbook",
"subtitle": "Choose skipper and crew for this logbook. New travel days inherit this selection by default.",
"loading": "Loading crew…",
"active_skipper": "Skipper for this logbook",
"active_crew": "Crew for this logbook",
"no_skippers_in_pool": "No skipper in the pool — add one in your user profile first.",
"no_crew_in_pool": "No crew in the pool — add members in your user profile first.",
"no_skipper": "No skipper selected",
"unnamed": "Unnamed",
"save": "Save crew",
"saved": "Logbook crew saved.",
"selection_only_hint": "You see the crew set by the owner (shared logbook)."
},
"entry_crew": {
"title": "Crew on this travel day",
"subtitle": "May differ from the logbook default. Following days inherit from the previous day.",
"day_skipper": "Skipper on this day",
"day_crew": "Crew on this day",
"no_skipper": "No skipper selected",
"no_crew": "No crew selected"
},
"crew": {
"title": "Skipper & Crew Profiles",
"skipper_section": "Skipper Profile",
@@ -454,7 +651,7 @@
"add_crew": "Add Crew Member",
"edit_crew": "Edit Crew Member",
"no_crew": "No crew members added yet.",
"max_crew": "Maximum of 5 crew members reached.",
"max_crew": "Maximum of 12 crew members in the pool reached.",
"name": "Full Name",
"address": "Address",
"birthdate": "Date of Birth",
@@ -495,6 +692,8 @@
"share_enable": "Enable Public Link",
"share_copied": "Link copied!",
"share_copy_btn": "Copy Link",
"link_qr_hint": "Scan this QR code with your phone",
"link_qr_alt": "QR code for the link",
"danger_zone_title": "Danger Zone",
"danger_zone_desc": "Deleting your account will permanently delete all your passkeys, logbooks, vessel data, crew profiles, travel logs, and E2E keys. This action cannot be undone.",
"delete_account_btn": "Permanently Delete Account",
@@ -663,7 +862,13 @@
"unit_l": "L",
"day_label": "Day {{day}}",
"account_logbooks": "Logbooks overview",
"col_logbook": "Logbook"
"col_logbook": "Logbook",
"event_series_title": "Event series",
"event_series_hint": "Chronological values from the event log.",
"event_series_pressure": "Barometric pressure",
"event_series_wind": "Wind",
"event_series_motor": "Engine",
"event_series_empty": "No entries yet."
},
"tour": {
"skip": "Skip tour",
@@ -700,9 +905,13 @@
"title": "Vessel data",
"body": "Enter your yacht's name, dimensions, and technical details fill once, use on every travel day."
},
"nav_crew": {
"title": "Crew list",
"body": "Manage crew members and assign them to travel days later."
"profile_crew_pool": {
"title": "Core Crew & skippers",
"body": "In your user profile you maintain a person pool — multiple skippers (e.g. charter) and crew for all logbooks."
},
"nav_logbook_crew": {
"title": "Crew per logbook",
"body": "Pick skipper and crew from the pool for this logbook. Travel days inherit this selection by default."
},
"nav_stats": {
"title": "Statistics dashboard",
+250 -41
View File
@@ -13,6 +13,17 @@
"sv": "Svenska",
"nb": "Norsk"
},
"dialog": {
"ok": "OK",
"yes": "Ja",
"no": "Nei"
},
"errors": {
"load_failed": "Data kunne ikke lastes.",
"save_failed": "Endringer kunne ikke lagres.",
"delete_failed": "Sletting mislyktes.",
"export_failed": "Eksport mislyktes."
},
"common": {
"unsaved_changes_title": "Ikke-lagrede endringer",
"unsaved_changes_message": "Du har endringer som ikke er lagret. Vil du virkelig forlate siden? Endringene dine vil gå tapt.",
@@ -22,7 +33,7 @@
"nav": {
"dashboard": "Dashbord",
"vessel": "Skipsdata",
"crew": "Mannskapsliste",
"crew": "Crew",
"deviation": "Tabell over distraksjoner",
"logs": "Loggbokoppføringer",
"stats": "Statistikk",
@@ -68,7 +79,12 @@
"enter_pin_placeholder": "Tast inn PIN-koden din...",
"decrypt_with_pin": "Dekryptere",
"use_recovery_instead": "Bruk gjenopprettingsnøkler i stedet",
"error_incorrect_pin": "Feil PIN-kode. Dekryptering mislyktes."
"error_incorrect_pin": "Feil PIN-kode. Dekryptering mislyktes.",
"error_invalid_host": "Passkeys fungerer ikke via 127.0.0.1. Åpne appen via localhost.",
"use_localhost_link": "Bytt til localhost",
"error_passkey_cancelled": "Passkey-innlogging ble avbrutt eller utløp. Prøv igjen.",
"error_invalid_rp_id": "Passkey-domene stemmer ikke (RP ID). Bruk http://localhost:5173 med RP_ID=localhost i .env for lokal utvikling.",
"error_session_incomplete": "Innlogging ufullstendig. Logg inn med passkey igjen."
},
"pwa": {
"title": "Installer app",
@@ -87,13 +103,18 @@
"update_title": "Oppdatering tilgjengelig",
"update_desc": "En ny versjon av Kapteins Daagbok er klar. Oppdater for å få med de siste endringene.",
"update_now": "Oppdater nå",
"update_reloading": "Laster..."
"update_reloading": "Laster...",
"storage_persist_hint": "Nettleseren kan slette offlinedata. Tillat permanent lagring slik at loggboken din forblir beskyttet."
},
"sync": {
"status_synced": "Synkronisert",
"status_syncing": "Synkroniser...",
"status_offline": "Frakoblet hurtigbuffer",
"status_unsynced": "Usynkroniserte endringer"
"status_unsynced": "Usynkroniserte endringer",
"conflict_title": "Synkroniseringskonflikt",
"conflict_message": "{{count}} endring(er) kunne ikke synkroniseres (post {{id}}…). Velg hvilken versjon som skal gjelde.",
"conflict_use_server": "Bruk serverversjon",
"conflict_keep_local": "Behold min versjon"
},
"vessel": {
"title": "Stamdata for skip",
@@ -145,7 +166,8 @@
"sign_cleared_skipper_re_sign_title": "Skippers signatur fjernet",
"sign_cleared_skipper_re_sign": "Hendelsesloggen har blitt endret. Skipperens signatur er fjernet. Vennligst godkjenn på nytt.",
"date": "dato",
"day_of_travel": "Reisens dag / reisedag",
"day_of_travel": "Reisedag",
"travel_day_number": "Reisedag {{number}}",
"departure": "Starthavn (reise fra)",
"destination": "Destinasjonsport (til)",
"route": "Reise fra/til",
@@ -161,7 +183,7 @@
"consumption": "Daglig forbruk",
"signatures": "Underskrifter / frigivelse",
"sign_skipper": "Skippers signatur",
"sign_crew": "Mannskapets signatur",
"sign_crew": "Crews signatur",
"sign_hint": "Signer med finger, penn eller mus",
"sign_clear": "Slett",
"sign_export_image": "[Signatur]",
@@ -181,22 +203,111 @@
"sign_badge_skipper_title_valid": "Skipper har gitt ut",
"sign_badge_skipper_title_invalid": "Skippersignaturen er ugyldig - innholdet har blitt endret",
"sign_classic_or_passkey": "Valgfritt: klassisk signatur eller Passkey utgivelse ovenfor",
"sign_crew_passkey_hint": "Besetningsmedlemmer med skrivetilgang kan frigjøre via Passkey.",
"sign_crew_passkey_hint": "Crew-medlemmer med skrivetilgang kan frigjøre via Passkey.",
"sign_offline_hint": "Passkey-Godkjenning krever Internett - klassisk signatur mulig offline",
"sign_lock_notice": "Etter signering er det ikke mulig å gjøre endringer i loggbokoppføringen (unntatt bilder) uten at skipper og mannskap må signere på nytt.",
"sign_lock_active": "Denne oppføringen er signert. Endringer i loggboken (unntatt bilder) fjerner automatisk skipperens og mannskapets signaturer.",
"sign_lock_notice": "Etter signering er det ikke mulig å gjøre endringer i loggbokoppføringen (unntatt bilder) uten at skipper og crew må signere på nytt.",
"sign_lock_active": "Denne oppføringen er signert. Endringer i loggboken (unntatt bilder) fjerner automatisk skipperens og crews signaturer.",
"sign_lock_warning_title": "Bekreft signatur",
"sign_lock_warning": "Etter signering er det ikke lenger mulig å gjøre endringer i loggbokoppføringen (unntatt bilder) uten at skipper og mannskap må signere på nytt.\n\nØnsker du å fortsette?",
"sign_lock_warning": "Etter signering er det ikke lenger mulig å gjøre endringer i loggbokoppføringen (unntatt bilder) uten at skipper og crew må signere på nytt.\n\nØnsker du å fortsette?",
"sign_proceed": "Skilt",
"sign_cancel": "Avbryt",
"sign_cleared_re_sign_title": "Signaturer fjernet",
"sign_cleared_re_sign": "Loggbokoppføringen har blitt endret. Skipperens og mannskapets signaturer er fjernet. Vennligst signer på nytt.",
"sign_cleared_re_sign": "Loggbokoppføringen har blitt endret. Skipperens og crews signaturer er fjernet. Vennligst signer på nytt.",
"no_entries": "Ingen loggbokoppføringer funnet for denne båten. Lag din første seilasdag!",
"back_to_list": "Tilbake til tidsskriftlisten",
"save": "Lagre loggbokside",
"saving": "...vil bli reddet...",
"saved": "Loggboksiden er vellykket lagret!",
"loading": "Tidsskriftet lastes inn...",
"view_mode_label": "Visning",
"view_list": "Liste",
"live_mode": "Live",
"live_title": "Live-journal",
"live_loading": "Live-journal lastes inn...",
"live_retry": "Prøv igjen",
"live_load_error": "Live-journal kunne ikke lastes inn.",
"live_action_error": "Oppføringen kunne ikke lagres.",
"live_open_editor": "Full editor",
"live_actions_label": "Hurtighandlinger",
"live_stream_label": "Hendelseslogg",
"live_stream_title": "Journal",
"live_no_events": "Ingen oppføringer ennå — trykk på en handling.",
"live_motor_start": "Motor Start",
"live_motor_stop": "Motor Stopp",
"live_cast_off": "Avreise",
"live_moor": "Anløp",
"live_sails_btn": "Seil",
"live_sails_pick": "Velg seil",
"live_sails_pick_hint": "Trykk flere seil (trykk igjen for å fjerne), deretter loggfør.",
"live_sails_selected": "Valgt: {{sails}}",
"live_sails_confirm": "Loggfør",
"live_sails_confirm_count": "Loggfør ({{count}})",
"live_sails": "Seil: {{sails}}",
"live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS ikke tilgjengelig. Skriv inn bredde- og lengdegrad manuelt, eller prøv igjen med GPS-knappen.",
"live_fix_gps_loading": "Henter GPS-posisjon…",
"live_fix_invalid": "Skriv inn gyldige koordinater (bredde 90…90, lengde 180…180).",
"live_fix_lat_placeholder": "Bredde (Lat)",
"live_fix_lng_placeholder": "Lengde (Lng)",
"live_photo_btn": "Foto (kamera)",
"live_photo_capture_btn": "Ta bilde",
"live_photo_save_btn": "Lagre",
"live_photo_retake_btn": "Ta på nytt",
"live_photo_capture_failed": "Opptak mislyktes. Prøv igjen.",
"live_photo_open_camera_btn": "Åpne kamera",
"live_photo_native_hint": "Ta et bilde med enhetskameraet og lagre det her etterpå.",
"live_photo_camera_starting": "Starter kamera…",
"live_photo_camera_denied": "Kameratilgang nektet eller utilgjengelig.",
"live_photo_camera_unavailable": "Kamera støttes ikke i denne nettleseren.",
"live_photo_error": "Kunne ikke lagre foto.",
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto tatt",
"live_undo_photo_hint": "Foto lagret",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Skriv inn tekst…",
"live_comment_confirm": "Loggfør",
"live_gps_error": "GPS-posisjon kunne ikke bestemmes.",
"live_gps_start_hint": "Start alltid dagsreisen med en posisjon.",
"live_event_generic": "Hendelse",
"live_weather_btn": "Vær",
"live_weather_owm_btn": "Hent OpenWeatherMap-vær",
"live_weather_owm_loading": "Henter vær…",
"live_weather_fix_required": "Logg først en GPS-fix (Fix-knapp) for å hente OpenWeatherMap-vær. Posisjonen må være maks 6 timer gammel.",
"live_weather_fix_stale": "Siste GPS-fix er eldre enn 6 timer. Logg en ny fix før du henter vær.",
"live_wind_btn": "Vind",
"live_temp_btn": "T °C",
"live_pressure_btn": "Lufttrykk",
"live_precip_btn": "Nedbør",
"live_sea_state_btn": "Sjøgang",
"live_course_btn": "Kurs",
"live_fuel_btn": "Diesel",
"live_water_btn": "Vann",
"live_wind_entry": "Vind {{value}}",
"live_temp_entry": "Temperatur {{temp}} °C",
"live_pressure_entry": "Lufttrykk {{value}} hPa",
"live_precip_entry": "Nedbør {{value}}",
"live_sea_state_entry": "Sjøgang {{value}}",
"live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Vann +{{liters}} L",
"live_auto_position": "Auto-posisjon",
"live_undo_hint": "Oppføring lagret",
"live_undo_btn": "Angre",
"live_pressure_placeholder": "f.eks. 1013",
"live_temp_placeholder": "f.eks. 18",
"live_precip_placeholder": "f.eks. lett regn",
"live_sea_state_placeholder": "f.eks. 3",
"live_course_placeholder": "f.eks. 245",
"live_fuel_placeholder": "Påfylte liter",
"live_water_placeholder": "Påfylte liter",
"live_sog_btn": "SOG",
"live_stw_btn": "STW",
"live_sog_entry": "SOG {{speed}} kn",
"live_stw_entry": "STW {{speed}} kn",
"live_sog_placeholder": "f.eks. 5,2",
"live_stw_placeholder": "f.eks. 4,8",
"live_sog_hint": "Fart over grunn (kn) — GPS-verdi fylles inn hvis tilgjengelig.",
"delete_entry": "Slett tagg",
"delete_confirm": "Er du sikker på at du vil slette denne reisedagen permanent?",
"carry_over_tanks_title": "Overføre data fra dagen før?",
@@ -276,14 +387,64 @@
"track_map_error": "Kartet kunne ikke lastes inn.",
"exporting": "Eksport...",
"share_unsupported": "Deling støttes ikke på denne enheten. Filen har blitt lastet ned i stedet.",
"invite_crew": "Inviter mannskapet",
"invite_crew": "Inviter crewet",
"invite_link_copied": "Invitasjonslenke kopiert til utklippstavlen!",
"invite_link_desc": "Del denne lenken med besetningsmedlemmene for å gi dem skrivetilgang til loggboken.",
"collaborators_list": "Medlemmer / Besetning",
"invite_link_desc": "Del denne lenken med Crew-medlemmene for å gi dem skrivetilgang til loggboken.",
"collaborators_list": "Medlemmer / Crew",
"revoke": "Fjern",
"revoke_confirm": "Er du sikker på at du vil oppheve dette besetningsmedlemmets tilgang?",
"revoke_confirm": "Er du sikker på at du vil oppheve dette Crew-medlemmets tilgang?",
"invite_role": "Rolle",
"invite_expires": "Lenken er gyldig i 48 timer"
"invite_expires": "Lenken er gyldig i 48 timer",
"nmea_import_title": "Import NMEA log",
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
"nmea_import_btn": "Import NMEA",
"nmea_file_label": "NMEA file",
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
"nmea_mode_label": "Generate journal entries",
"nmea_mode_interval": "By time interval",
"nmea_mode_change": "On significant change",
"nmea_mode_both": "Both (merge)",
"nmea_interval_label": "Interval (minutes)",
"nmea_import_track": "Import GPS track from NMEA",
"nmea_preview": "Preview",
"nmea_preview_hint": "{{count}} suggested journal entries",
"nmea_select_all": "Select all",
"nmea_select_none": "Select none",
"nmea_source_interval": "Interval",
"nmea_source_change": "Event",
"nmea_apply": "Apply to journal",
"nmea_back": "Back",
"nmea_cancel": "Cancel",
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
"nmea_archive_keep": "Archive",
"nmea_archive_discard": "Discard",
"nmea_archive_stored": "NMEA archived: {{name}}",
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
"nmea_error_parse": "Could not read NMEA file.",
"nmea_error_read": "Could not read file.",
"nmea_error_no_file": "Please choose an NMEA file first.",
"nmea_error_no_selection": "Please select at least one journal entry.",
"nmea_remark_interval": "NMEA interval",
"nmea_remark_uncertain": "uncertain",
"nmea_remark_depth": "Depth {{depth}} m",
"nmea_change_course": "Course change {{from}}° → {{to}}°",
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
"nmea_change_depth": "Depth {{from}} → {{to}} m",
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
"nmea_change_engine_stop": "Engine off",
"nmea_change_autopilot_on": "Autopilot on",
"nmea_change_autopilot_off": "Autopilot off",
"nmea_change_gps_lost": "GPS fix lost",
"nmea_change_gps_regained": "GPS fix restored",
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop",
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries."
},
"dashboard": {
"title": "Loggbøkene dine",
@@ -300,14 +461,15 @@
"delete_btn": "Slett loggbok",
"section_owned": "Loggbøkene mine",
"section_shared": "Felles loggbøker",
"section_shared_hint": "Du er invitert som besetningsmedlem. Skipperprofil og innstillinger tilhører eieren.",
"section_shared_hint": "Du er invitert som Crew-medlem. Skipperprofil og innstillinger tilhører eieren.",
"role_owner": "Egen loggbok",
"role_owner_hint": "Du er eier og skipper av denne loggboken",
"role_crew": "Tilgang for mannskapet",
"role_crew_hint": "Loggbok med invitasjon - du kan jobbe som mannskap og signere den",
"role_crew": "Tilgang for crewet",
"role_crew_hint": "Loggbok med invitasjon - du kan jobbe som crew og signere den",
"role_read": "Bare les",
"role_read_hint": "Delt loggbok - kun visning, ingen redigering",
"open_profile": "Åpne profilen til {{name}}",
"open_logbook": "Åpne loggbok «{{title}}»",
"edit_title": "Endre navn på loggbok",
"edit_placeholder": "Nytt navn på loggboken",
"edit_success": "Loggboken har fått nytt navn",
@@ -438,23 +600,58 @@
"tour_desc": "La deg veilede gjennom de viktigste områdene i appen på nytt.",
"tour_restart": "Start turen på nytt",
"push_title": "Push-varsler",
"push_desc": "Som loggbokseier vil du bli varslet når inviterte besetningsmedlemmer synkroniserer endringer. Ingen innhold overføres i ren tekst.",
"push_enable": "Gi oss beskjed om endringer i mannskapet",
"push_desc": "Som loggbokseier vil du bli varslet når inviterte Crew-medlemmer synkroniserer endringer. Ingen innhold overføres i ren tekst.",
"push_enable": "Gi oss beskjed om endringer i crewet",
"push_active": "Push-varsler er aktive på denne enheten.",
"push_unsupported": "Push-varsler støttes ikke i denne nettleseren.",
"push_denied_hint": "Varsler er blokkert. Tillat dem i innstillingene i nettleseren eller på enheten.",
"push_ios_install_hint": "På iPhone/iPad: Legg til app på startskjermen (iOS 16.4+) for å bruke push.",
"push_error": "Push-varsler kunne ikke aktiveres."
},
"person_pool": {
"title": "Stamm-Crew og skippere",
"subtitle": "Hold personpoolen din her skippere og crew for alle loggbøker. Velg aktivt crew per loggbok og reisedag fra poolen.",
"loading": "Laster personpool…",
"skippers_section": "Skippere",
"crew_section": "Stamm-Crew",
"add_skipper": "Legg til skipper",
"add_crew": "Legg til Crew-medlem",
"edit_skipper": "Rediger skipper",
"no_skippers": "Ingen skipper i poolen ennå.",
"no_crew": "Ingen Crew-medlemmer i poolen ennå.",
"delete_confirm": "Fjerne denne personen fra poolen?"
},
"logbook_crew": {
"title": "Crew for denne loggboken",
"subtitle": "Velg skipper og crew for denne loggboken. Nye reisedager arver valget som standard.",
"loading": "Laster crew…",
"active_skipper": "Skipper for denne loggboken",
"active_crew": "Crew for denne loggboken",
"no_skippers_in_pool": "Ingen skipper i poolen legg til i brukerprofilen først.",
"no_crew_in_pool": "Ingen crew i poolen legg til i brukerprofilen først.",
"no_skipper": "Ingen skipper valgt",
"unnamed": "Uten navn",
"save": "Lagre crew",
"saved": "Loggbok-Crew lagret.",
"selection_only_hint": "Du ser crewet eieren har valgt (delt loggbok)."
},
"entry_crew": {
"title": "Crew på denne reisedagen",
"subtitle": "Kan avvike fra loggbokstandard. Følgende dager arver fra forrige dag.",
"day_skipper": "Skipper denne dagen",
"day_crew": "Crew denne dagen",
"no_skipper": "Ingen skipper valgt",
"no_crew": "Ingen crew valgt"
},
"crew": {
"title": "Skipper- og mannskapsprofiler",
"title": "Skipper- og Crew-profiler",
"skipper_section": "Skipperprofil",
"skipper_read_only_hint": "Skipperprofilen kan bare redigeres av eieren av loggboken.",
"crew_section": "Mannskapsliste",
"add_crew": "Legg til besetningsmedlem",
"edit_crew": "Rediger besetningsmedlem",
"no_crew": "Ingen besetningsmedlemmer er lagt til ennå.",
"max_crew": "Maksimalt antall på 5 besetningsmedlemmer er nådd.",
"crew_section": "Crew-liste",
"add_crew": "Legg til Crew-medlem",
"edit_crew": "Rediger Crew-medlem",
"no_crew": "Ingen Crew-medlemmer er lagt til ennå.",
"max_crew": "Maksimalt antall på 12 Crew-medlemmer i poolen er nådd.",
"name": "Navn",
"address": "adresse",
"birthdate": "Bursdag",
@@ -467,8 +664,8 @@
"save": "Lagre skipperdata",
"save_member": "Lagre medlem",
"saved": "Skipperprofilen er vellykket lagret!",
"loading": "Mannskapsfilene er lastet inn...",
"delete_confirm": "Er du sikker på at du vil fjerne dette besetningsmedlemmet?"
"loading": "Crew-filene er lastet inn...",
"delete_confirm": "Er du sikker på at du vil fjerne dette Crew-medlemmet?"
},
"deviation": {
"title": "Tabell over kompassavvik",
@@ -490,13 +687,15 @@
"weather_date_mismatch": "Værdata kan bare hentes ut for i dag ({{today}}). Denne loggbokoppføringen er datert {{date}}.",
"gps_error": "Vennligst skriv inn en posisjon eller finn GPS-koordinatene.",
"share_title": "Del loggbok (skrivebeskyttet)",
"share_desc": "Aktiver dette alternativet for å opprette en offentlig, skrivebeskyttet lenke. Alle som har denne lenken, kan se seilasene, båtprofilene og mannskapet ditt. Krypteringsnøklene overføres aldri til serveren (de forblir i hash-delen av URL-en).",
"share_desc": "Aktiver dette alternativet for å opprette en offentlig, skrivebeskyttet lenke. Alle som har denne lenken, kan se seilasene, båtprofilene og crewet ditt. Krypteringsnøklene overføres aldri til serveren (de forblir i hash-delen av URL-en).",
"share_privacy_warning": "Anbefaling: Del denne lenken kun privat (f.eks. via e-post eller messenger), ikke på sosiale medier.",
"share_enable": "Aktiver offentlig lenke",
"share_copied": "Linken er kopiert!",
"share_copy_btn": "Kopier lenke",
"link_qr_hint": "Skann QR-koden med telefonen",
"link_qr_alt": "QR-kode for lenken",
"danger_zone_title": "Faresone",
"danger_zone_desc": "Hvis du sletter kontoen din, slettes alle dine Passkeys, loggbøker, skipsdata, mannskapsprofiler, reiseoppføringer og E2E-nøkler ugjenkallelig. Denne handlingen kan ikke angres.",
"danger_zone_desc": "Hvis du sletter kontoen din, slettes alle dine Passkeys, loggbøker, skipsdata, Crew-profiler, reiseoppføringer og E2E-nøkler ugjenkallelig. Denne handlingen kan ikke angres.",
"delete_account_btn": "Slett konto ugjenkallelig",
"delete_account_confirm_title": "Slett konto?",
"delete_account_confirm_desc": "Er du helt sikker på at du vil slette kontoen din og alle tilknyttede loggbøker og E2E-krypterte data ugjenkallelig?",
@@ -506,13 +705,13 @@
"delete_backup_hint": "Tips: Lag sikkerhetskopier av loggbøkene dine (.daagbok.json) i innstillingene for hver loggbok før du sletter dem.",
"deleting_account": "Kontoen vil bli slettet...",
"invite_push_prompt_title": "Aktivere push-varsler?",
"invite_push_prompt_message": "Så snart inviterte besetningsmedlemmer synkroniserer endringer, kan du bli informert via push. Ingen loggbokinnhold sendes i ren tekst.",
"invite_push_prompt_ios_message": "Så snart besetningsmedlemmene synkroniserer endringer, kan du bli informert via push. På iPhone/iPad: Legg til appen på startskjermen (iOS 16.4+), og aktiver deretter push i brukerprofilen.",
"invite_push_prompt_message": "Så snart inviterte Crew-medlemmer synkroniserer endringer, kan du bli informert via push. Ingen loggbokinnhold sendes i ren tekst.",
"invite_push_prompt_ios_message": "Så snart Crew-medlemmene synkroniserer endringer, kan du bli informert via push. På iPhone/iPad: Legg til appen på startskjermen (iOS 16.4+), og aktiver deretter push i brukerprofilen.",
"invite_push_prompt_enable": "Aktiver nå",
"invite_push_prompt_later": "Senere",
"invite_push_prompt_success": "Push-varsler er aktive på denne enheten.",
"backup_title": "Sikkerhetskopiering og gjenoppretting",
"backup_desc": "Fullstendig kryptert sikkerhetskopi av denne loggboken (oppføringer, bilder, GPS-spor, mannskap, skip). Beskyttet med sikkerhetskopieringspassord - for gjenoppretting til denne eller en ny konto.",
"backup_desc": "Fullstendig kryptert sikkerhetskopi av denne loggboken (oppføringer, bilder, GPS-spor, crew, skip). Beskyttet med sikkerhetskopieringspassord - for gjenoppretting til denne eller en ny konto.",
"backup_export_title": "Opprett sikkerhetskopi",
"backup_export_desc": "Laster ned alle lokale data som .daagbok.json. Hold filen og passordfrasen adskilt og sikker.",
"backup_restore_title": "Gjenopprett sikkerhetskopi",
@@ -542,7 +741,7 @@
"backup_new_id_confirm": "Importere sikkerhetskopien som en ny loggbok med ny ID?",
"backup_stat_entries": "{{count}} Reisedager",
"backup_stat_photos": "{{count}} Bilder",
"backup_stat_crew": "{{count}} Mannskapsposter",
"backup_stat_crew": "{{count}} Crew-poster",
"backup_stat_tracks": "{{count}} GPS-spor",
"backup_exported_at": "Eksportert: {{date}}"
},
@@ -663,7 +862,13 @@
"unit_l": "L",
"day_label": "Dag {{day}}",
"account_logbooks": "Oversikt over loggbøker",
"col_logbook": "Loggbok"
"col_logbook": "Loggbok",
"event_series_title": "Hendelsesforløp",
"event_series_hint": "Kronologiske verdier fra hendelsesloggen.",
"event_series_pressure": "Lufttrykk",
"event_series_wind": "Vind",
"event_series_motor": "Motor",
"event_series_empty": "Ingen oppføringer ennå."
},
"tour": {
"skip": "Hopp over turen",
@@ -678,7 +883,7 @@
},
"welcome_public": {
"title": "Velkommen om bord!",
"body": "Utforsk vår demologgbok med tre dagers reise i Kielfjorden - uten konto. Denne korte omvisningen viser deg skipsdata, mannskap og loggbokoppføringer."
"body": "Utforsk vår demologgbok med tre dagers reise i Kielfjorden - uten konto. Denne korte omvisningen viser deg skipsdata, crew og loggbokoppføringer."
},
"nav_logs": {
"title": "Loggbokoppføringer",
@@ -700,9 +905,13 @@
"title": "Skipsdata",
"body": "Skriv inn navn, dimensjoner og tekniske data for båten din - fyll inn én gang, tilgjengelig for alle reisedager."
},
"nav_crew": {
"title": "Mannskapsliste",
"body": "Administrer mannskapet og tilordne dem til reisedager senere."
"profile_crew_pool": {
"title": "Stamm-Crew og skippere",
"body": "I brukerprofilen vedlikeholder du en personpool flere skippere (f.eks. charter) og crew for alle loggbøker."
},
"nav_logbook_crew": {
"title": "Crew per loggbok",
"body": "Velg skipper og crew fra poolen for denne loggboken. Reisedager arver valget som standard."
},
"nav_stats": {
"title": "Dashbord for statistikk",
@@ -728,7 +937,7 @@
},
"seo": {
"title": "Kapteins Daagbok - Gratis digital loggbok for fritidsbåter (uten reklame)",
"description": "Gratis, annonsefri digital loggbok med ende-til-ende-kryptering og Passkey-pålogging. Dokumenter seilingsdager, GPS-spor, mannskaps- og skipsdata på en sikker måte - også offline som PWA.",
"description": "Gratis, annonsefri digital loggbok med ende-til-ende-kryptering og Passkey-pålogging. Dokumenter seilingsdager, GPS-spor, Crew- og skipsdata på en sikker måte - også offline som PWA.",
"keywords": "Yachtloggbok, skipsloggbok, loggbok om bord, seiling, Passkey, E2E-kryptering, GPS-sporing, maritim loggbok, gratis, reklamefri, gratis, uten reklame",
"ogImageAlt": "Kapteins Daagbok Logo"
}
+251 -42
View File
@@ -13,6 +13,17 @@
"sv": "Svenska",
"nb": "Norsk"
},
"dialog": {
"ok": "OK",
"yes": "Ja",
"no": "Nej"
},
"errors": {
"load_failed": "Data kunde inte laddas.",
"save_failed": "Ändringar kunde inte sparas.",
"delete_failed": "Radering misslyckades.",
"export_failed": "Export misslyckades."
},
"common": {
"unsaved_changes_title": "Osparade ändringar",
"unsaved_changes_message": "Du har ändringar som inte sparats. Vill du verkligen lämna sidan? Dina ändringar kommer att gå förlorade.",
@@ -22,7 +33,7 @@
"nav": {
"dashboard": "Instrumentpanel",
"vessel": "Fartygsdata",
"crew": "Besättningslista",
"crew": "Crew",
"deviation": "Distraktionsbord",
"logs": "Loggboksanteckningar",
"stats": "Statistik",
@@ -68,7 +79,12 @@
"enter_pin_placeholder": "Ange din PIN-kod...",
"decrypt_with_pin": "Dekryptera",
"use_recovery_instead": "Använd återställningsnycklar istället",
"error_incorrect_pin": "Felaktig PIN-kod. Dekryptering misslyckades."
"error_incorrect_pin": "Felaktig PIN-kod. Dekryptering misslyckades.",
"error_invalid_host": "Passkeys fungerar inte via 127.0.0.1. Öppna appen via localhost.",
"use_localhost_link": "Byt till localhost",
"error_passkey_cancelled": "Passkey-inloggning avbröts eller gick ut. Försök igen.",
"error_invalid_rp_id": "Passkey-domänen matchar inte (RP ID). Använd http://localhost:5173 med RP_ID=localhost i .env för lokal utveckling.",
"error_session_incomplete": "Inloggning ofullständig. Logga in med passkey igen."
},
"pwa": {
"title": "Installera app",
@@ -87,13 +103,18 @@
"update_title": "Uppdatering tillgänglig",
"update_desc": "En ny version av Kapteins Daagbok är klar. Uppdatera för att få de senaste ändringarna.",
"update_now": "Uppdatering nu",
"update_reloading": "Laddar..."
"update_reloading": "Laddar...",
"storage_persist_hint": "Webbläsaren kan radera offlinedata. Tillåt permanent lagring så att din loggbok förblir skyddad."
},
"sync": {
"status_synced": "Synkroniserad",
"status_syncing": "Synkronisera...",
"status_offline": "Offline-cache",
"status_unsynced": "Osynkroniserade förändringar"
"status_unsynced": "Osynkroniserade förändringar",
"conflict_title": "Synkroniseringskonflikt",
"conflict_message": "{{count}} ändring(ar) kunde inte synkas (post {{id}}…). Välj vilken version som ska gälla.",
"conflict_use_server": "Använd serverversion",
"conflict_keep_local": "Behåll min version"
},
"vessel": {
"title": "Masterdata för fartyg",
@@ -145,7 +166,8 @@
"sign_cleared_skipper_re_sign_title": "Skippers signatur borttagen",
"sign_cleared_skipper_re_sign": "Händelseloggen har ändrats. Skepparens signatur har tagits bort. Vänligen godkänn igen.",
"date": "datum",
"day_of_travel": "Resedag / resedag",
"day_of_travel": "Resedag",
"travel_day_number": "Resedag {{number}}",
"departure": "Starthamn (resa från)",
"destination": "Destinationsport (till)",
"route": "Resa från/till",
@@ -161,7 +183,7 @@
"consumption": "Daglig konsumtion",
"signatures": "Underskrifter / frisläppande",
"sign_skipper": "Skepparens signatur",
"sign_crew": "Besättningens signatur",
"sign_crew": "Crews signatur",
"sign_hint": "Signera med finger, penna eller mus",
"sign_clear": "Radera",
"sign_export_image": "[Signatur]",
@@ -181,22 +203,111 @@
"sign_badge_skipper_title_valid": "Skepparen har släppt",
"sign_badge_skipper_title_invalid": "Skippers signatur ogiltig - innehållet har ändrats",
"sign_classic_or_passkey": "Valfritt: klassisk signatur eller Passkey release ovan",
"sign_crew_passkey_hint": "Besättningsmedlemmar med skrivbehörighet kan frigöra via Passkey.",
"sign_crew_passkey_hint": "Crew-medlemmar med skrivbehörighet kan frigöra via Passkey.",
"sign_offline_hint": "Passkey-Godkännande kräver Internet - klassisk signatur möjlig offline",
"sign_lock_notice": "Efter undertecknandet är det inte möjligt att göra ändringar i loggboksanteckningen (utom foton) utan att skepparen och besättningen måste underteckna på nytt.",
"sign_lock_active": "Denna post är signerad. Ändringar i loggboken (utom foton) tar automatiskt bort skepparens och besättningens signaturer.",
"sign_lock_notice": "Efter undertecknandet är det inte möjligt att göra ändringar i loggboksanteckningen (utom foton) utan att skepparen och crewen måste underteckna på nytt.",
"sign_lock_active": "Denna post är signerad. Ändringar i loggboken (utom foton) tar automatiskt bort skepparens och crews signaturer.",
"sign_lock_warning_title": "Bekräfta underskrift",
"sign_lock_warning": "Efter undertecknandet är det inte längre möjligt att göra ändringar i loggboksanteckningen (utom foton) utan att skepparen och besättningen måste underteckna på nytt.\n\nVill du fortsätta?",
"sign_lock_warning": "Efter undertecknandet är det inte längre möjligt att göra ändringar i loggboksanteckningen (utom foton) utan att skepparen och crewen måste underteckna på nytt.\n\nVill du fortsätta?",
"sign_proceed": "Teckna",
"sign_cancel": "Avbryt",
"sign_cleared_re_sign_title": "Underskrifter borttagna",
"sign_cleared_re_sign": "Loggboksanteckningen har ändrats. Skepparens och besättningens namnteckningar har tagits bort. Vänligen underteckna igen.",
"sign_cleared_re_sign": "Loggboksanteckningen har ändrats. Skepparens och crews namnteckningar har tagits bort. Vänligen underteckna igen.",
"no_entries": "Inga loggboksposter hittade för denna yacht. Skapa din första resedag!",
"back_to_list": "Tillbaka till tidskriftslistan",
"save": "Spara loggbokssida",
"saving": "Kommer att sparas...",
"saved": "Loggbokssidan har sparats framgångsrikt!",
"loading": "Journalen laddas...",
"view_mode_label": "Vy",
"view_list": "Lista",
"live_mode": "Live",
"live_title": "Live-journal",
"live_loading": "Live-journal laddas...",
"live_retry": "Försök igen",
"live_load_error": "Live-journal kunde inte laddas.",
"live_action_error": "Posten kunde inte sparas.",
"live_open_editor": "Fullständig editor",
"live_actions_label": "Snabbåtgärder",
"live_stream_label": "Händelselogg",
"live_stream_title": "Journal",
"live_no_events": "Inga poster ännu — tryck på en åtgärd.",
"live_motor_start": "Motor Start",
"live_motor_stop": "Motor Stopp",
"live_cast_off": "Avgång",
"live_moor": "Anlöp",
"live_sails_btn": "Segel",
"live_sails_pick": "Välj segel",
"live_sails_pick_hint": "Tryck på flera segel (tryck igen för att avmarkera), logga sedan.",
"live_sails_selected": "Valt: {{sails}}",
"live_sails_confirm": "Logga",
"live_sails_confirm_count": "Logga ({{count}})",
"live_sails": "Segel: {{sails}}",
"live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS ej tillgänglig. Ange latitud och longitud manuellt, eller försök igen med GPS-knappen.",
"live_fix_gps_loading": "Hämtar GPS-position…",
"live_fix_invalid": "Ange giltiga koordinater (latitud 90…90, longitud 180…180).",
"live_fix_lat_placeholder": "Latitud (Lat)",
"live_fix_lng_placeholder": "Longitud (Lng)",
"live_photo_btn": "Foto (kamera)",
"live_photo_capture_btn": "Ta foto",
"live_photo_save_btn": "Spara",
"live_photo_retake_btn": "Ta om",
"live_photo_capture_failed": "Bildtagning misslyckades. Försök igen.",
"live_photo_open_camera_btn": "Öppna kamera",
"live_photo_native_hint": "Ta ett foto med enhetens kamera och spara det här efteråt.",
"live_photo_camera_starting": "Startar kamera…",
"live_photo_camera_denied": "Kameraåtkomst nekad eller ej tillgänglig.",
"live_photo_camera_unavailable": "Kameran stöds inte i den här webbläsaren.",
"live_photo_error": "Foto kunde inte sparas.",
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto taget",
"live_undo_photo_hint": "Foto sparat",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Ange text…",
"live_comment_confirm": "Logga",
"live_gps_error": "GPS-position kunde inte bestämmas.",
"live_gps_start_hint": "Börja alltid dagsresan med en position.",
"live_event_generic": "Händelse",
"live_weather_btn": "Väder",
"live_weather_owm_btn": "Hämta OpenWeatherMap-väder",
"live_weather_owm_loading": "Hämtar väder…",
"live_weather_fix_required": "Logga först en GPS-fix (Fix-knappen) för att hämta OpenWeatherMap-väder. Positionen får högst vara 6 timmar gammal.",
"live_weather_fix_stale": "Senaste GPS-fixen är äldre än 6 timmar. Logga en ny fix innan du hämtar väder.",
"live_wind_btn": "Vind",
"live_temp_btn": "T °C",
"live_pressure_btn": "Lufttryck",
"live_precip_btn": "Nederbörd",
"live_sea_state_btn": "Sjögang",
"live_course_btn": "Kurs",
"live_fuel_btn": "Diesel",
"live_water_btn": "Vatten",
"live_wind_entry": "Vind {{value}}",
"live_temp_entry": "Temperatur {{temp}} °C",
"live_pressure_entry": "Lufttryck {{value}} hPa",
"live_precip_entry": "Nederbörd {{value}}",
"live_sea_state_entry": "Sjögang {{value}}",
"live_course_entry": "Kurs {{course}}",
"live_fuel_entry": "Diesel +{{liters}} L",
"live_water_entry": "Vatten +{{liters}} L",
"live_auto_position": "Auto-position",
"live_undo_hint": "Post sparad",
"live_undo_btn": "Ångra",
"live_pressure_placeholder": "t.ex. 1013",
"live_temp_placeholder": "t.ex. 18",
"live_precip_placeholder": "t.ex. lätt regn",
"live_sea_state_placeholder": "t.ex. 3",
"live_course_placeholder": "t.ex. 245",
"live_fuel_placeholder": "Påfyllda liter",
"live_water_placeholder": "Påfyllda liter",
"live_sog_btn": "SOG",
"live_stw_btn": "STW",
"live_sog_entry": "SOG {{speed}} kn",
"live_stw_entry": "STW {{speed}} kn",
"live_sog_placeholder": "t.ex. 5,2",
"live_stw_placeholder": "t.ex. 4,8",
"live_sog_hint": "Fart över grund (kn) — GPS-värde fylls i om tillgängligt.",
"delete_entry": "Ta bort tagg",
"delete_confirm": "Är du säker på att du vill radera den här resedagen permanent?",
"carry_over_tanks_title": "Överföra data från föregående dag?",
@@ -276,14 +387,64 @@
"track_map_error": "Kartan kunde inte läsas in.",
"exporting": "Export...",
"share_unsupported": "Delning stöds inte på den här enheten. Filen har laddats ner istället.",
"invite_crew": "Bjud in besättningen",
"invite_crew": "Bjud in crewen",
"invite_link_copied": "Länk till inbjudan kopierad till urklipp!",
"invite_link_desc": "Dela den här länken med besättningsmedlemmar för att ge dem skrivrättigheter till loggboken.",
"collaborators_list": "Medlemmar / Besättning",
"invite_link_desc": "Dela den här länken med Crew-medlemmar för att ge dem skrivrättigheter till loggboken.",
"collaborators_list": "Medlemmar / Crew",
"revoke": "Ta bort",
"revoke_confirm": "Är du säker på att du vill återkalla den här besättningsmedlemmens åtkomst?",
"revoke_confirm": "Är du säker på att du vill återkalla den här Crew-medlemmens åtkomst?",
"invite_role": "Roll",
"invite_expires": "Länken är giltig i 48 timmar"
"invite_expires": "Länken är giltig i 48 timmar",
"nmea_import_title": "Import NMEA log",
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
"nmea_import_btn": "Import NMEA",
"nmea_file_label": "NMEA file",
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
"nmea_mode_label": "Generate journal entries",
"nmea_mode_interval": "By time interval",
"nmea_mode_change": "On significant change",
"nmea_mode_both": "Both (merge)",
"nmea_interval_label": "Interval (minutes)",
"nmea_import_track": "Import GPS track from NMEA",
"nmea_preview": "Preview",
"nmea_preview_hint": "{{count}} suggested journal entries",
"nmea_select_all": "Select all",
"nmea_select_none": "Select none",
"nmea_source_interval": "Interval",
"nmea_source_change": "Event",
"nmea_apply": "Apply to journal",
"nmea_back": "Back",
"nmea_cancel": "Cancel",
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
"nmea_archive_keep": "Archive",
"nmea_archive_discard": "Discard",
"nmea_archive_stored": "NMEA archived: {{name}}",
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
"nmea_error_parse": "Could not read NMEA file.",
"nmea_error_read": "Could not read file.",
"nmea_error_no_file": "Please choose an NMEA file first.",
"nmea_error_no_selection": "Please select at least one journal entry.",
"nmea_remark_interval": "NMEA interval",
"nmea_remark_uncertain": "uncertain",
"nmea_remark_depth": "Depth {{depth}} m",
"nmea_change_course": "Course change {{from}}° → {{to}}°",
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
"nmea_change_depth": "Depth {{from}} → {{to}} m",
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
"nmea_change_engine_stop": "Engine off",
"nmea_change_autopilot_on": "Autopilot on",
"nmea_change_autopilot_off": "Autopilot off",
"nmea_change_gps_lost": "GPS fix lost",
"nmea_change_gps_regained": "GPS fix restored",
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop",
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries."
},
"dashboard": {
"title": "Dina loggböcker",
@@ -300,14 +461,15 @@
"delete_btn": "Radera loggbok",
"section_owned": "Mina loggböcker",
"section_shared": "Delade loggböcker",
"section_shared_hint": "Du har blivit inbjuden som besättningsmedlem. Skepparens profil och inställningar tillhör ägaren.",
"section_shared_hint": "Du har blivit inbjuden som Crew-medlem. Skepparens profil och inställningar tillhör ägaren.",
"role_owner": "Egen loggbok",
"role_owner_hint": "Du är ägare och skeppare till denna loggbok",
"role_crew": "Tillträde för besättningen",
"role_crew_hint": "Inbjuden loggbok - du kan arbeta som besättning och underteckna den",
"role_crew": "Tillträde för crewen",
"role_crew_hint": "Inbjuden loggbok - du kan arbeta som crew och underteckna den",
"role_read": "Endast läsning",
"role_read_hint": "Delad loggbok - endast visning, ingen redigering",
"open_profile": "Öppna profil för {{name}}",
"open_logbook": "Öppna loggbok ”{{title}}”",
"edit_title": "Byt namn på loggbok",
"edit_placeholder": "Nytt namn på loggboken",
"edit_success": "Loggboken har framgångsrikt bytt namn",
@@ -438,23 +600,58 @@
"tour_desc": "Låt dig vägledas genom de viktigaste områdena i appen igen.",
"tour_restart": "Starta resan igen",
"push_title": "Push-meddelanden",
"push_desc": "Som loggboksägare får du ett meddelande när inbjudna besättningsmedlemmar synkroniserar ändringar. Inget innehåll överförs i klartext.",
"push_enable": "Meddela oss om förändringar i besättningen",
"push_desc": "Som loggboksägare får du ett meddelande när inbjudna Crew-medlemmar synkroniserar ändringar. Inget innehåll överförs i klartext.",
"push_enable": "Meddela oss om förändringar i crewen",
"push_active": "Push-meddelanden är aktiva på den här enheten.",
"push_unsupported": "Push-meddelanden stöds inte i den här webbläsaren.",
"push_denied_hint": "Meddelanden är blockerade. Tillåt dem i webbläsarens eller enhetens inställningar.",
"push_ios_install_hint": "På iPhone/iPad: Lägg till app på startskärmen (iOS 16.4+) för att använda push.",
"push_error": "Push-meddelanden kunde inte aktiveras."
},
"person_pool": {
"title": "Stamm-Crew och skeppare",
"subtitle": "Underhåll din personpool här skeppare och crew för alla loggböcker. Välj aktiv crew per loggbok och resdag från poolen.",
"loading": "Laddar personpool…",
"skippers_section": "Skeppare",
"crew_section": "Stamm-Crew",
"add_skipper": "Lägg till skeppare",
"add_crew": "Lägg till Crew-medlem",
"edit_skipper": "Redigera skeppare",
"no_skippers": "Ingen skeppare i poolen ännu.",
"no_crew": "Inga Crew-medlemmar i poolen ännu.",
"delete_confirm": "Ta bort denna person från poolen?"
},
"logbook_crew": {
"title": "Crew för denna loggbok",
"subtitle": "Välj skeppare och crew för denna loggbok. Nya resdagar ärver valet som standard.",
"loading": "Laddar crew…",
"active_skipper": "Skeppare för denna loggbok",
"active_crew": "Crew för denna loggbok",
"no_skippers_in_pool": "Ingen skeppare i poolen lägg till i användarprofilen först.",
"no_crew_in_pool": "Ingen crew i poolen lägg till i användarprofilen först.",
"no_skipper": "Ingen skeppare vald",
"unnamed": "Namnlös",
"save": "Spara crew",
"saved": "Loggbok-Crew sparad.",
"selection_only_hint": "Du ser den crew ägaren valt (delad loggbok)."
},
"entry_crew": {
"title": "Crew denna resdag",
"subtitle": "Kan skilja sig från loggboksstandard. Följande dagar ärver från föregående dag.",
"day_skipper": "Skeppare denna dag",
"day_crew": "Crew denna dag",
"no_skipper": "Ingen skeppare vald",
"no_crew": "Ingen crew vald"
},
"crew": {
"title": "Profiler för skeppare och besättning",
"title": "Profiler för skeppare och crew",
"skipper_section": "Skepparens profil",
"skipper_read_only_hint": "Skepparens profil kan endast redigeras av loggbokens ägare.",
"crew_section": "Besättningslista",
"add_crew": "Lägg till besättningsmedlem",
"edit_crew": "Redigera besättningsmedlem",
"no_crew": "Inga besättningsmedlemmar har lagts till ännu.",
"max_crew": "Maximalt antal på 5 besättningsmedlemmar uppnås.",
"crew_section": "Crew-lista",
"add_crew": "Lägg till Crew-medlem",
"edit_crew": "Redigera Crew-medlem",
"no_crew": "Inga Crew-medlemmar har lagts till ännu.",
"max_crew": "Maximalt antal på 12 Crew-medlemmar i poolen uppnått.",
"name": "Namn",
"address": "adress",
"birthdate": "Födelsedag",
@@ -467,8 +664,8 @@
"save": "Spara skeppardata",
"save_member": "Spara medlem",
"saved": "Skepparens profil har sparats!",
"loading": "Besättningsfilerna är laddade...",
"delete_confirm": "Är du säker på att du vill ta bort den här besättningsmedlemmen?"
"loading": "Crew-filerna är laddade...",
"delete_confirm": "Är du säker på att du vill ta bort den här Crew-medlemmen?"
},
"deviation": {
"title": "Tabell för kompassavvikelse",
@@ -490,13 +687,15 @@
"weather_date_mismatch": "Väderdata kan endast hämtas för idag ({{today}}). Denna loggbokspost är daterad {{date}}.",
"gps_error": "Ange en plats eller bestäm GPS-koordinaterna.",
"share_title": "Aktieloggbok (skrivskyddad)",
"share_desc": "Aktivera det här alternativet för att skapa en publik, skrivskyddad länk. Alla som har länken kan se dina resor, båtprofiler och besättning. Krypteringsnycklarna överförs aldrig till servern (de finns kvar i hashdelen av URL:en).",
"share_desc": "Aktivera det här alternativet för att skapa en publik, skrivskyddad länk. Alla som har länken kan se dina resor, båtprofiler och crew. Krypteringsnycklarna överförs aldrig till servern (de finns kvar i hashdelen av URL:en).",
"share_privacy_warning": "Rekommendation: Dela endast den här länken privat (t.ex. via e-post eller messenger), inte på sociala medier.",
"share_enable": "Aktivera offentlig länk",
"share_copied": "Länk kopierad!",
"share_copy_btn": "Kopiera länk",
"link_qr_hint": "Skanna QR-koden med mobilen",
"link_qr_alt": "QR-kod för länken",
"danger_zone_title": "Farlig zon",
"danger_zone_desc": "Om du raderar ditt konto raderas oåterkalleligen alla dina Passkey, loggböcker, fartygsdata, besättningsprofiler, reseanteckningar och E2E-nycklar. Denna åtgärd kan inte ångras.",
"danger_zone_desc": "Om du raderar ditt konto raderas oåterkalleligen alla dina Passkey, loggböcker, fartygsdata, Crew-profiler, reseanteckningar och E2E-nycklar. Denna åtgärd kan inte ångras.",
"delete_account_btn": "Ta bort konto oåterkalleligt",
"delete_account_confirm_title": "Radera konto?",
"delete_account_confirm_desc": "Är du helt säker på att du oåterkalleligen vill radera ditt konto och alla tillhörande loggböcker och E2E-krypterade data?",
@@ -506,13 +705,13 @@
"delete_backup_hint": "Tips: Skapa säkerhetskopior av dina loggböcker (.daagbok.json) i inställningarna för varje loggbok innan du raderar dem.",
"deleting_account": "Kontot kommer att raderas...",
"invite_push_prompt_title": "Aktivera push-meddelanden?",
"invite_push_prompt_message": "Så snart inbjudna besättningsmedlemmar synkroniserar ändringar kan du bli informerad via push. Inget loggboksinnehåll skickas i klartext.",
"invite_push_prompt_ios_message": "Så snart besättningsmedlemmar synkroniserar ändringar kan du bli informerad via push. På iPhone/iPad: Lägg till appen på startskärmen (iOS 16.4+) och aktivera sedan push i användarprofilen.",
"invite_push_prompt_message": "Så snart inbjudna Crew-medlemmar synkroniserar ändringar kan du bli informerad via push. Inget loggboksinnehåll skickas i klartext.",
"invite_push_prompt_ios_message": "Så snart Crew-medlemmar synkroniserar ändringar kan du bli informerad via push. På iPhone/iPad: Lägg till appen på startskärmen (iOS 16.4+) och aktivera sedan push i användarprofilen.",
"invite_push_prompt_enable": "Aktivera nu",
"invite_push_prompt_later": "Senare",
"invite_push_prompt_success": "Push-meddelanden är aktiva på den här enheten.",
"backup_title": "Säkerhetskopiering och återställning",
"backup_desc": "Komplett krypterad säkerhetskopia av denna loggbok (poster, foton, GPS-spår, besättning, fartyg). Skyddad med lösenfras för säkerhetskopian - för återställning till detta eller ett nytt konto.",
"backup_desc": "Komplett krypterad säkerhetskopia av denna loggbok (poster, foton, GPS-spår, crew, fartyg). Skyddad med lösenfras för säkerhetskopian - för återställning till detta eller ett nytt konto.",
"backup_export_title": "Skapa säkerhetskopia",
"backup_export_desc": "Laddar ner alla lokala data som .daagbok.json. Förvara filen och lösenfrasen separat och säkert.",
"backup_restore_title": "Återställ säkerhetskopian",
@@ -542,7 +741,7 @@
"backup_new_id_confirm": "Importera säkerhetskopian som en ny loggbok med ett nytt ID?",
"backup_stat_entries": "{{count}} Resdagar",
"backup_stat_photos": "{{count}} Foton",
"backup_stat_crew": "{{count}} Besättningens uppgifter",
"backup_stat_crew": "{{count}} Crew-poster",
"backup_stat_tracks": "{{count}} GPS-spår",
"backup_exported_at": "Exporterad: {{date}}"
},
@@ -620,7 +819,7 @@
"join_again": "Gå med igen",
"login_or_register_hint": "Logga in eller registrera ett konto för att gå med i loggboken.",
"or_sign_up": "ELLER REGISTRERA DIG IGEN",
"register_crew_account": "Skapa ett nytt konto för besättningen",
"register_crew_account": "Skapa ett nytt konto för crewen",
"username_label": "Användarens namn",
"create_passkey": "Skapa Passkey.",
"switch_language_en": "Engelska",
@@ -663,7 +862,13 @@
"unit_l": "L",
"day_label": "Dag {{day}}__.",
"account_logbooks": "Loggböcker i en överblick",
"col_logbook": "Loggbok"
"col_logbook": "Loggbok",
"event_series_title": "Händelseförlopp",
"event_series_hint": "Kronologiska värden från händelseloggen.",
"event_series_pressure": "Lufttryck",
"event_series_wind": "Vind",
"event_series_motor": "Motor",
"event_series_empty": "Inga poster ännu."
},
"tour": {
"skip": "Hoppa över turen",
@@ -678,7 +883,7 @@
},
"welcome_public": {
"title": "Välkommen ombord!",
"body": "Utforska vår demologgbok med tre dagars resor i Kielfjorden - utan konto. Den här korta rundturen visar dig fartygsdata, besättning och loggboksanteckningar."
"body": "Utforska vår demologgbok med tre dagars resor i Kielfjorden - utan konto. Den här korta rundturen visar dig fartygsdata, crew och loggboksanteckningar."
},
"nav_logs": {
"title": "Loggboksanteckningar",
@@ -700,9 +905,13 @@
"title": "Fartygsdata",
"body": "Ange namn, dimensioner och tekniska data för din yacht - fyll i en gång, tillgänglig för alla resdagar."
},
"nav_crew": {
"title": "Besättningslista",
"body": "Hantera besättningsmedlemmar och tilldela dem resdagar senare."
"profile_crew_pool": {
"title": "Stamm-Crew och skeppare",
"body": "I användarprofilen underhåller du en personpool flera skeppare (t.ex. charter) och crew för alla loggböcker."
},
"nav_logbook_crew": {
"title": "Crew per loggbok",
"body": "Välj skeppare och crew från poolen för denna loggbok. Resdagar ärver valet som standard."
},
"nav_stats": {
"title": "Kontrollpanel för statistik",
@@ -728,7 +937,7 @@
},
"seo": {
"title": "Kapteins Daagbok - Gratis digital loggbok för båtar (reklamfri)",
"description": "Gratis, annonsfri digital loggbok för båtar med kryptering från början till slut och Passkey-inloggning. Dokumentera resdagar, GPS-spår, besättnings- och fartygsdata på ett säkert sätt - även offline som PWA.",
"description": "Gratis, annonsfri digital loggbok för båtar med kryptering från början till slut och Passkey-inloggning. Dokumentera resdagar, GPS-spår, Crew- och fartygsdata på ett säkert sätt - även offline som PWA.",
"keywords": "Yachtloggbok, skeppsdagbok, ombordloggbok, segling, Passkey, E2E kryptering, GPS-spår, sjöfartsloggbok, gratis, reklamfri, gratis, utan reklam",
"ogImageAlt": "Kapteins Daagbok Logotyp"
}
+5
View File
@@ -12,6 +12,7 @@ import {
markReloadAttempt,
reconcileVersionOnStartup
} from './services/pwaStartup.ts'
import { redirectToPasskeyCompatibleHostIfNeeded } from './utils/passkeyHost.ts'
/** Stale PWA precache on localhost can shadow Vite dev modules. */
async function clearDevServiceWorkerCaches(): Promise<void> {
@@ -40,6 +41,10 @@ function renderBootstrapError(message: string): void {
}
async function bootstrap(): Promise<void> {
if (redirectToPasskeyCompatibleHostIfNeeded()) {
return
}
applyAppearanceToDocument()
installStaleAssetRecovery()
await clearDevServiceWorkerCaches()
+10 -1
View File
@@ -35,9 +35,18 @@ export const PlausibleEvents = {
LOCAL_PIN_REMOVED: 'Local PIN Removed',
DEVICE_FORGOTTEN: 'Device Forgotten',
RECOVERY_ROTATED: 'Recovery Rotated',
LANGUAGE_CHANGED: 'Language Changed'
LANGUAGE_CHANGED: 'Language Changed',
NMEA_IMPORTED: 'NMEA Imported',
NMEA_UPLOADED: 'NMEA Uploaded',
LIVE_LOG_OPENED: 'Live Log Opened',
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
LIVE_LOG_PHOTO_UPLOADED: 'Live Log Photo Uploaded',
OWM_WEATHER_FETCHED: 'OWM Weather Fetched'
} as const
/** Where a successful OpenWeatherMap API call originated (no coordinates or place names). */
export type OwmAnalyticsSource = 'live_log' | 'entry_editor' | 'entry_editor_gps_lookup'
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
export type PlausibleEventProps = Record<string, string | number | boolean>
+6 -1
View File
@@ -12,6 +12,7 @@ import { clearLogbookKeysCache } from './logbookKeys.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
import { db } from './db.js'
import { apiFetch, apiJson } from './api.js'
import { isWebAuthnUserAbortError } from '../utils/passkeyHost.js'
const API_BASE = '/api/auth'
@@ -361,7 +362,11 @@ export async function loginUser(username?: string): Promise<LoginResult> {
const prfRequested = !!options.extensions?.prf
try {
credentialResponse = await startAuthentication({ optionsJSON: options })
} catch (err: any) {
} catch (err: unknown) {
// User cancelled or timed out — never open a second platform prompt.
if (isWebAuthnUserAbortError(err)) {
throw err
}
if (prfRequested) {
console.warn('Passkey authentication with PRF extension failed, retrying without PRF:', err)
if (options.extensions) {
+125
View File
@@ -0,0 +1,125 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { decryptJson, encryptJson } from './crypto.js'
import { getLogbookKey } from './logbookKeys.js'
import type { PersonData } from '../types/person.js'
import { buildLogbookCrewSelection, pickActiveSkipperId } from '../utils/personSnapshots.js'
import { entryCrewFromLogbookSelection } from '../utils/personSnapshots.js'
import { saveLogbookCrewSelection } from './logbookCrewSelection.js'
const MIGRATION_FLAG = 'crew_pool_migration_v1_done'
export async function migrateLegacyCrewToPoolIfNeeded(): Promise<void> {
const userId = localStorage.getItem('active_userid')
if (!userId || localStorage.getItem(MIGRATION_FLAG) === userId) return
const masterKey = getActiveMasterKey()
if (!masterKey) return
try {
const ownedLogbooks = await db.logbooks.filter((lb) => lb.isShared !== 1).toArray()
const poolByLegacyKey = new Map<string, string>()
const poolData = new Map<string, PersonData>()
for (const logbook of ownedLogbooks) {
const logbookKey = (await getLogbookKey(logbook.id)) || masterKey
const legacyCrews = await db.crews.where({ logbookId: logbook.id }).toArray()
const legacyIds: { skipperIds: string[]; crewIds: string[] } = {
skipperIds: [],
crewIds: []
}
for (const record of legacyCrews) {
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, logbookKey)) as
| PersonData
| null
if (!data) continue
const role = record.payloadId === 'skipper' ? 'skipper' : 'crew'
const personData: PersonData = { ...data, role }
const dedupeKey = `${role}:${personData.name}:${personData.passportNumber}`
let poolId = poolByLegacyKey.get(dedupeKey)
if (!poolId) {
poolId = record.payloadId === 'skipper' ? 'skipper' : record.payloadId
const existing = await db.personPool.get(poolId)
if (!existing) {
const encrypted = await encryptJson(personData, masterKey)
const now = new Date().toISOString()
await db.personPool.put({
payloadId: poolId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.userSyncQueue.put({
action: 'create',
type: 'person',
payloadId: poolId,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
poolByLegacyKey.set(dedupeKey, poolId)
poolData.set(poolId, personData)
}
if (role === 'skipper') {
if (!legacyIds.skipperIds.includes(poolId)) legacyIds.skipperIds.push(poolId)
} else {
legacyIds.crewIds.push(poolId)
}
}
const activeSkipperId = pickActiveSkipperId(legacyIds.skipperIds)
const existingSelection = await db.logbookCrewSelections.get(logbook.id)
if (!existingSelection && (activeSkipperId || legacyIds.crewIds.length > 0)) {
const selection = buildLogbookCrewSelection(
activeSkipperId,
legacyIds.crewIds,
poolData
)
await saveLogbookCrewSelection(logbook.id, selection)
const entryCrew = entryCrewFromLogbookSelection(selection)
const entries = await db.entries.where({ logbookId: logbook.id }).toArray()
for (const entry of entries) {
const dec = (await decryptJson(entry.encryptedData, entry.iv, entry.tag, logbookKey)) as Record<
string,
unknown
> | null
if (!dec) continue
if (dec.selectedSkipperId != null || (Array.isArray(dec.selectedCrewIds) && dec.selectedCrewIds.length > 0)) {
continue
}
const updated = {
...dec,
...entryCrew
}
const encrypted = await encryptJson(updated, logbookKey)
const now = new Date().toISOString()
await db.entries.put({
...entry,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'update',
type: 'entry',
payloadId: entry.payloadId,
logbookId: logbook.id,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
}
}
localStorage.setItem(MIGRATION_FLAG, userId)
} catch (err) {
console.warn('Crew pool migration failed:', err)
}
}
+90 -1
View File
@@ -64,6 +64,15 @@ export interface LocalGpsTrack {
updatedAt: string
}
export interface LocalNmeaArchive {
entryId: string
logbookId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface LocalLogbookKey {
logbookId: string
encryptedKey: string
@@ -71,16 +80,50 @@ export interface LocalLogbookKey {
tag: string
}
export interface LocalPerson {
payloadId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface LocalLogbookCrewSelection {
logbookId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface SyncQueueItem {
id?: number
action: 'create' | 'update' | 'delete'
type: 'yacht' | 'crew' | 'deviation' | 'entry' | 'logbook' | 'photo' | 'gpsTrack'
type: 'yacht' | 'crew' | 'deviation' | 'entry' | 'logbook' | 'photo' | 'gpsTrack' | 'logbookCrew'
payloadId: string // payloadId or logbookId depending on the type
logbookId: string
data: string // JSON representation of the local record
updatedAt: string
}
export interface UserSyncQueueItem {
id?: number
action: 'create' | 'update' | 'delete'
type: 'person'
payloadId: string
data: string
updatedAt: string
}
export interface EntryDraftRecord {
logbookId: string
entryId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
class DaagboxDatabase extends Dexie {
logbooks!: Table<LocalLogbook>
yachts!: Table<LocalYacht>
@@ -89,8 +132,13 @@ class DaagboxDatabase extends Dexie {
entries!: Table<LocalEntry>
photos!: Table<LocalPhoto>
gpsTracks!: Table<LocalGpsTrack>
nmeaArchives!: Table<LocalNmeaArchive>
logbookKeys!: Table<LocalLogbookKey>
personPool!: Table<LocalPerson>
logbookCrewSelections!: Table<LocalLogbookCrewSelection>
syncQueue!: Table<SyncQueueItem>
userSyncQueue!: Table<UserSyncQueueItem>
entryDrafts!: Table<EntryDraftRecord, [string, string]>
constructor() {
super('DaagboxDatabase')
@@ -145,6 +193,47 @@ class DaagboxDatabase extends Dexie {
gpsTracks: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId'
})
this.version(6).stores({
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
yachts: 'logbookId, updatedAt',
crews: 'payloadId, logbookId, updatedAt',
deviations: 'logbookId, updatedAt',
entries: 'payloadId, logbookId, updatedAt',
syncQueue: '++id, action, type, payloadId, logbookId',
photos: 'payloadId, entryId, logbookId, updatedAt',
gpsTracks: 'entryId, logbookId, updatedAt',
nmeaArchives: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId'
})
this.version(7).stores({
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
yachts: 'logbookId, updatedAt',
crews: 'payloadId, logbookId, updatedAt',
deviations: 'logbookId, updatedAt',
entries: 'payloadId, logbookId, updatedAt',
syncQueue: '++id, action, type, payloadId, logbookId',
photos: 'payloadId, entryId, logbookId, updatedAt',
gpsTracks: 'entryId, logbookId, updatedAt',
nmeaArchives: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId',
entryDrafts: '[logbookId+entryId], updatedAt'
})
this.version(8).stores({
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
yachts: 'logbookId, updatedAt',
crews: 'payloadId, logbookId, updatedAt',
deviations: 'logbookId, updatedAt',
entries: 'payloadId, logbookId, updatedAt',
syncQueue: '++id, action, type, payloadId, logbookId',
photos: 'payloadId, entryId, logbookId, updatedAt',
gpsTracks: 'entryId, logbookId, updatedAt',
nmeaArchives: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId',
personPool: 'payloadId, updatedAt',
logbookCrewSelections: 'logbookId, updatedAt',
userSyncQueue: '++id, action, type, payloadId',
entryDrafts: '[logbookId+entryId], updatedAt'
})
}
}
+47 -16
View File
@@ -4,9 +4,12 @@ import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import { syncPersonPool } from './personPoolSync.js'
import i18n from '../i18n/index.js'
import type { PersonData } from '../types/person.js'
import { buildLogbookCrewSelection } from '../utils/personSnapshots.js'
import {
buildDemoCrewRecords,
buildDemoPersonPool,
buildDemoEntryPayloads,
buildDemoYachtData
} from './demoLogbookData.js'
@@ -24,7 +27,7 @@ export function getDemoFirstEntryStorageKey(userId: string): string {
async function putEncryptedRecord(
logbookId: string,
key: ArrayBuffer,
type: 'entry' | 'crew' | 'yacht' | 'gpsTrack',
type: 'entry' | 'yacht' | 'gpsTrack' | 'logbookCrew',
payloadId: string,
data: unknown,
now: string
@@ -40,15 +43,6 @@ async function putEncryptedRecord(
tag: encrypted.tag,
updatedAt: now
})
} else if (type === 'crew') {
await db.crews.put({
payloadId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
} else if (type === 'yacht') {
await db.yachts.put({
logbookId,
@@ -66,25 +60,62 @@ async function putEncryptedRecord(
tag: encrypted.tag,
updatedAt: now
})
} else if (type === 'logbookCrew') {
await db.logbookCrewSelections.put({
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
}
await db.syncQueue.put({
action: type === 'yacht' ? 'update' : 'create',
action: type === 'yacht' || type === 'logbookCrew' ? 'update' : 'create',
type,
payloadId: type === 'yacht' ? logbookId : payloadId,
payloadId: type === 'yacht' || type === 'logbookCrew' ? logbookId : payloadId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
async function seedPersonPool(masterKey: ArrayBuffer, now: string): Promise<Map<string, PersonData>> {
const poolMap = new Map<string, PersonData>()
for (const person of buildDemoPersonPool()) {
poolMap.set(person.payloadId, person.data)
const encrypted = await encryptJson(person.data, masterKey)
await db.personPool.put({
payloadId: person.payloadId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.userSyncQueue.put({
action: 'create',
type: 'person',
payloadId: person.payloadId,
data: JSON.stringify(encrypted),
updatedAt: now
})
}
syncPersonPool().catch((err) => console.warn('Demo person pool sync failed:', err))
return poolMap
}
async function seedYachtAndCrew(logbookId: string, key: ArrayBuffer, now: string): Promise<void> {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not available')
const yachtData = buildDemoYachtData()
await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now)
for (const crew of buildDemoCrewRecords()) {
await putEncryptedRecord(logbookId, key, 'crew', crew.payloadId, crew.data, now)
}
const poolMap = await seedPersonPool(masterKey, now)
const skipperId = [...poolMap.entries()].find(([, d]) => d.role === 'skipper')?.[0] ?? null
const crewIds = [...poolMap.entries()].filter(([, d]) => d.role === 'crew').map(([id]) => id)
const selection = buildLogbookCrewSelection(skipperId, crewIds, poolMap)
await putEncryptedRecord(logbookId, key, 'logbookCrew', logbookId, selection, now)
}
export interface DemoSeedResult {
+39 -2
View File
@@ -16,6 +16,7 @@ const PUBLIC_DEMO_ENTRY_IDS = [
'a0000001-0000-4000-8000-000000000003'
] as const
export const PUBLIC_DEMO_SKIPPER_ID = 'skipper'
const PUBLIC_DEMO_CREW_MEMBER_ID = 'a0000001-0000-4000-8000-000000000010'
export interface DemoDaySpec {
@@ -52,7 +53,14 @@ export interface DemoCrewRecord {
export interface PublicDemoFixture {
title: string
yacht: Record<string, unknown>
/** @deprecated legacy share payload */
crews: DemoCrewRecord[]
personPool: DemoCrewRecord[]
logbookCrewSelection: {
activeSkipperId: string
activeCrewIds: string[]
snapshotsById: Record<string, DemoCrewRecord['data'] & { id: string }>
}
entries: Array<Record<string, unknown> & { payloadId: string }>
gpsTracks: Array<{ entryId: string; waypoints: unknown[]; filename: string; gpxContent?: string; fileType: string }>
photos: never[]
@@ -188,11 +196,15 @@ export function buildDemoYachtData(): Record<string, unknown> {
}
}
export function buildDemoPersonPool(): DemoCrewRecord[] {
return buildDemoCrewRecords()
}
export function buildDemoCrewRecords(): DemoCrewRecord[] {
const isDe = isGermanLocale(i18n.language)
return [
{
payloadId: 'skipper',
payloadId: PUBLIC_DEMO_SKIPPER_ID,
data: {
name: 'Demo Skipper',
address: isDe ? 'Am Hafen 12, 24103 Kiel' : 'Harbour Quay 12, 24103 Kiel',
@@ -226,10 +238,26 @@ export function buildDemoCrewRecords(): DemoCrewRecord[] {
]
}
function buildDemoLogbookCrewSelection(pool: DemoCrewRecord[]) {
const skipper = pool.find((p) => p.data.role === 'skipper')
const crew = pool.filter((p) => p.data.role === 'crew')
const snapshotsById: Record<string, DemoCrewRecord['data'] & { id: string }> = {}
for (const p of pool) {
snapshotsById[p.payloadId] = { id: p.payloadId, ...p.data }
}
return {
activeSkipperId: skipper?.payloadId ?? PUBLIC_DEMO_SKIPPER_ID,
activeCrewIds: crew.map((c) => c.payloadId),
snapshotsById
}
}
export function buildPublicDemoFixture(): PublicDemoFixture {
const title = i18n.t('demo.logbook_title')
const yacht = buildDemoYachtData()
const crews = buildDemoCrewRecords()
const personPool = buildDemoPersonPool()
const crews = personPool
const logbookCrewSelection = buildDemoLogbookCrewSelection(personPool)
const days = buildDemoDays()
const entries: PublicDemoFixture['entries'] = []
const gpsTracks: PublicDemoFixture['gpsTracks'] = []
@@ -247,6 +275,9 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
destination: day.destination,
freshwater: { ...day.freshwater },
fuel: { ...day.fuel },
selectedSkipperId: logbookCrewSelection.activeSkipperId,
selectedCrewIds: [...logbookCrewSelection.activeCrewIds],
crewSnapshotsById: { ...logbookCrewSelection.snapshotsById },
signSkipper: '',
signCrew: '',
events: day.events
@@ -280,6 +311,8 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
title,
yacht,
crews,
personPool,
logbookCrewSelection,
entries,
gpsTracks,
photos: [],
@@ -297,6 +330,7 @@ export function buildDemoEntryPayloads(): Array<{
entryPayload: Record<string, unknown>
trackData: { waypoints: unknown[]; gpxContent: string; filename: string; fileType: string }
}> {
const logbookCrewSelection = buildDemoLogbookCrewSelection(buildDemoPersonPool())
const days = buildDemoDays()
return days.map((day) => {
const entryId = crypto.randomUUID()
@@ -310,6 +344,9 @@ export function buildDemoEntryPayloads(): Array<{
destination: day.destination,
freshwater: { ...day.freshwater },
fuel: { ...day.fuel },
selectedSkipperId: logbookCrewSelection.activeSkipperId,
selectedCrewIds: [...logbookCrewSelection.activeCrewIds],
crewSnapshotsById: { ...logbookCrewSelection.snapshotsById },
signSkipper: '',
signCrew: '',
events: day.events
+53
View File
@@ -0,0 +1,53 @@
import { db } from './db.js'
import { encryptJson, decryptJson } from './crypto.js'
import { getActiveMasterKey } from './auth.js'
export interface EntryDraftRecord {
logbookId: string
entryId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export async function saveEntryDraft(
logbookId: string,
entryId: string,
payload: unknown
): Promise<void> {
const masterKey = getActiveMasterKey()
if (!masterKey) return
const { ciphertext, iv, tag } = await encryptJson(payload, masterKey)
await db.entryDrafts.put({
logbookId,
entryId,
encryptedData: ciphertext,
iv,
tag,
updatedAt: new Date().toISOString()
})
}
export async function loadEntryDraft<T = unknown>(
logbookId: string,
entryId: string
): Promise<T | null> {
const masterKey = getActiveMasterKey()
if (!masterKey) return null
const row = await db.entryDrafts.get([logbookId, entryId])
if (!row) return null
try {
return (await decryptJson(row.encryptedData, row.iv, row.tag, masterKey)) as T
} catch {
await db.entryDrafts.delete([logbookId, entryId])
return null
}
}
export async function clearEntryDraft(logbookId: string, entryId: string): Promise<void> {
await db.entryDrafts.delete([logbookId, entryId])
}
@@ -0,0 +1,106 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson } from './crypto.js'
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
import type { LogEventPayload } from '../utils/logEntryPayload.js'
import { LIVE_EVENT_CODES } from '../utils/liveEventCodes.js'
export interface EventSeriesPoint {
entryId: string
date: string
dayOfTravel: string
time: string
summary: string
}
export interface EventSeriesSummary {
pressure: EventSeriesPoint[]
wind: EventSeriesPoint[]
motor: EventSeriesPoint[]
}
function sortPoints(points: EventSeriesPoint[]): EventSeriesPoint[] {
return [...points].sort((a, b) => {
const dateCompare = a.date.localeCompare(b.date)
if (dateCompare !== 0) return dateCompare
return a.time.localeCompare(b.time)
})
}
export async function loadLogbookEventSeries(logbookId: string): Promise<EventSeriesSummary> {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const local = await db.entries.where({ logbookId }).toArray()
const decryptedEntries: Array<{
entryId: string
date: string
dayOfTravel: string
events: LogEventPayload[]
}> = []
for (const entry of local) {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
if (!decrypted) continue
decryptedEntries.push({
entryId: entry.payloadId,
date: String(decrypted.date || ''),
dayOfTravel: String(decrypted.dayOfTravel || ''),
events: (decrypted.events as LogEventPayload[]) || []
})
}
decryptedEntries.sort((a, b) =>
compareTravelDaysChronological(
{ date: a.date, dayOfTravel: a.dayOfTravel },
{ date: b.date, dayOfTravel: b.dayOfTravel }
)
)
const pressure: EventSeriesPoint[] = []
const wind: EventSeriesPoint[] = []
const motor: EventSeriesPoint[] = []
for (const entry of decryptedEntries) {
for (const event of entry.events) {
const base = {
entryId: entry.entryId,
date: entry.date,
dayOfTravel: entry.dayOfTravel,
time: event.time
}
if (event.windPressure?.trim()) {
pressure.push({
...base,
summary: `${event.windPressure} hPa`
})
}
if (event.windDirection?.trim() || event.windStrength?.trim()) {
wind.push({
...base,
summary: [event.windDirection, event.windStrength].filter(Boolean).join(' ')
})
}
const code = event.remarks?.trim() ?? ''
if (
code === LIVE_EVENT_CODES.MOTOR_START ||
code === LIVE_EVENT_CODES.MOTOR_STOP
) {
motor.push({
...base,
summary: code === LIVE_EVENT_CODES.MOTOR_START ? 'start' : 'stop'
})
}
}
}
return {
pressure: sortPoints(pressure),
wind: sortPoints(wind),
motor: sortPoints(motor)
}
}
+4
View File
@@ -214,6 +214,10 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
if (response.ok) {
const serverLb = await response.json()
if (serverLb.id !== localId) {
await saveLogbookKey(serverLb.id, logbookKey)
await db.logbookKeys.delete(localId)
}
await db.logbooks.put({
id: serverLb.id,
encryptedTitle: serverLb.encryptedTitle,
@@ -0,0 +1,75 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { decryptJson, encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import type { LogbookCrewSelectionData } from '../types/person.js'
import { emptyLogbookCrewSelection } from '../types/person.js'
import { buildLogbookCrewSelection } from '../utils/personSnapshots.js'
import type { PersonData } from '../types/person.js'
import { loadPersonPoolMap } from './personPool.js'
async function resolveLogbookKey(logbookId: string): Promise<ArrayBuffer> {
const key = (await getLogbookKey(logbookId)) || getActiveMasterKey()
if (!key) throw new Error('Encryption key not found. Please log in.')
return key
}
export async function loadLogbookCrewSelection(
logbookId: string
): Promise<LogbookCrewSelectionData> {
const record = await db.logbookCrewSelections.get(logbookId)
if (!record) return emptyLogbookCrewSelection()
const key = await resolveLogbookKey(logbookId)
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, key)) as
| LogbookCrewSelectionData
| null
if (!data) return emptyLogbookCrewSelection()
return {
activeSkipperId: data.activeSkipperId ?? null,
activeCrewIds: Array.isArray(data.activeCrewIds) ? data.activeCrewIds : [],
snapshotsById: data.snapshotsById && typeof data.snapshotsById === 'object' ? data.snapshotsById : {}
}
}
export async function saveLogbookCrewSelection(
logbookId: string,
selection: LogbookCrewSelectionData
): Promise<void> {
const key = await resolveLogbookKey(logbookId)
const encrypted = await encryptJson(selection, key)
const now = new Date().toISOString()
await db.logbookCrewSelections.put({
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'update',
type: 'logbookCrew',
payloadId: logbookId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
export async function saveLogbookCrewSelectionFromIds(
logbookId: string,
activeSkipperId: string | null,
activeCrewIds: string[],
poolOverride?: Map<string, PersonData>
): Promise<LogbookCrewSelectionData> {
const pool = poolOverride ?? (await loadPersonPoolMap())
const selection = buildLogbookCrewSelection(activeSkipperId, activeCrewIds, pool)
await saveLogbookCrewSelection(logbookId, selection)
return selection
}
@@ -0,0 +1,31 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parseNmeaFile } from './nmeaParse.js'
import { detectNmeaChanges } from './nmeaChangeDetection.js'
import { generateNmeaJournalCandidates } from './nmeaJournalGenerator.js'
const nmeaPath = resolve(import.meta.dirname, '../../../../testdata/tracks/kieler-foerde-5sm.nmea')
describe('kieler-foerde testdata', () => {
it('parses the sample NMEA log and yields journal candidates', () => {
const text = readFileSync(nmeaPath, 'utf8')
const result = parseNmeaFile(text, 'kieler-foerde-5sm.nmea')
expect(result.stats.checksumErrors).toBe(0)
expect(result.points.length).toBeGreaterThan(30)
expect(result.stats.sentenceTypes).toEqual(expect.arrayContaining(['RMC', 'GGA', 'MWV', 'DPT', 'MDA']))
const changes = detectNmeaChanges(result.points)
expect(changes.length).toBeGreaterThan(0)
expect(changes.some((c) => ['wind', 'engine_start', 'departure', 'speed', 'depth'].includes(c.type))).toBe(true)
const journal = generateNmeaJournalCandidates({
points: result.points,
mode: 'both',
intervalMinutes: 60,
t: (key) => key
})
expect(journal.candidates.length).toBeGreaterThanOrEqual(3)
})
})
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import type { NmeaTimePoint } from './nmeaTypes.js'
import { detectNmeaChanges } from './nmeaChangeDetection.js'
function point(
timestamp: number,
overrides: Partial<NmeaTimePoint> = {}
): NmeaTimePoint {
return { timestamp, ...overrides }
}
describe('detectNmeaChanges', () => {
it('detects significant course changes while underway', () => {
const points = [
point(0, { cog: 0, sog: 5 }),
point(60_000, { cog: 45, sog: 5 })
]
const events = detectNmeaChanges(points, {
courseDeltaDeg: 30,
windDirDeltaDeg: 30,
windSpeedDeltaKnots: 5,
pressureDeltaHpa: 2,
depthDeltaM: 1,
depthDeltaPercent: 25,
rpmIdle: 400,
rpmRunning: 800,
sogUnderWayKn: 2,
sogStoppedKn: 0.5,
anchorMinutes: 10,
speedDeltaKn: 2,
dedupeWindowMs: 60_000
})
expect(events.some((e) => e.type === 'course')).toBe(true)
const course = events.find((e) => e.type === 'course')
expect(course?.summaryParams).toMatchObject({ from: 0, to: 45 })
})
it('detects engine start when RPM rises above threshold', () => {
const points = [
point(0, { sog: 0, rpm: 0 }),
point(30_000, { sog: 3, rpm: 1200 })
]
const events = detectNmeaChanges(points)
expect(events.some((e) => e.type === 'engine_start')).toBe(true)
})
it('dedupes repeated events within the configured window', () => {
const points = [
point(0, { cog: 0, sog: 5 }),
point(10_000, { cog: 50, sog: 5 }),
point(20_000, { cog: 100, sog: 5 })
]
const events = detectNmeaChanges(points, {
courseDeltaDeg: 30,
windDirDeltaDeg: 30,
windSpeedDeltaKnots: 5,
pressureDeltaHpa: 2,
depthDeltaM: 1,
depthDeltaPercent: 25,
rpmIdle: 400,
rpmRunning: 800,
sogUnderWayKn: 2,
sogStoppedKn: 0.5,
anchorMinutes: 10,
speedDeltaKn: 2,
dedupeWindowMs: 120_000
})
const courseEvents = events.filter((e) => e.type === 'course')
expect(courseEvents.length).toBe(1)
})
})
@@ -0,0 +1,211 @@
import type { NmeaChangeEvent, NmeaDetectionConfig, NmeaTimePoint } from './nmeaTypes.js'
import { DEFAULT_NMEA_DETECTION_CONFIG } from './nmeaTypes.js'
import { angularDelta } from './nmeaTimeSeries.js'
function pushUnique(events: NmeaChangeEvent[], event: NmeaChangeEvent, minGapMs: number) {
const last = events[events.length - 1]
if (last && last.type === event.type && event.timestamp - last.timestamp < minGapMs) return
events.push(event)
}
export function detectNmeaChanges(
points: NmeaTimePoint[],
config: NmeaDetectionConfig = DEFAULT_NMEA_DETECTION_CONFIG
): NmeaChangeEvent[] {
const events: NmeaChangeEvent[] = []
if (points.length < 2) return events
let lastCourse: number | undefined
let lastWindDir: number | undefined
let lastWindSpeed: number | undefined
let lastPressure: number | undefined
let lastDepth: number | undefined
let lastWaterTemp: number | undefined
let lastFix: boolean | undefined
let engineRunning = false
let autopilot: boolean | undefined
let underWay = false
let stoppedSince: number | null = null
let lastSog: number | undefined
for (const p of points) {
const course = p.cog ?? p.hdt ?? p.hdm
if (course != null && lastCourse != null && (p.sog ?? 0) > 1) {
if (angularDelta(course, lastCourse) >= config.courseDeltaDeg) {
pushUnique(events, {
type: 'course',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: 'logs.nmea_change_course',
summaryParams: { from: Math.round(lastCourse), to: Math.round(course) },
data: p
}, config.dedupeWindowMs)
}
}
if (course != null) lastCourse = course
if (p.windDir != null && lastWindDir != null) {
if (angularDelta(p.windDir, lastWindDir) >= config.windDirDeltaDeg) {
pushUnique(events, {
type: 'wind',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: 'logs.nmea_change_wind',
summaryParams: { from: Math.round(lastWindDir), to: Math.round(p.windDir) },
data: p
}, config.dedupeWindowMs)
} else if (
p.windSpeedKnots != null &&
lastWindSpeed != null &&
Math.abs(p.windSpeedKnots - lastWindSpeed) >= config.windSpeedDeltaKnots
) {
pushUnique(events, {
type: 'wind',
timestamp: p.timestamp,
confidence: 'medium',
summaryKey: 'logs.nmea_change_wind_speed',
summaryParams: { from: lastWindSpeed.toFixed(1), to: p.windSpeedKnots.toFixed(1) },
data: p
}, config.dedupeWindowMs)
}
}
if (p.windDir != null) lastWindDir = p.windDir
if (p.windSpeedKnots != null) lastWindSpeed = p.windSpeedKnots
if (p.pressureHpa != null && lastPressure != null) {
if (Math.abs(p.pressureHpa - lastPressure) >= config.pressureDeltaHpa) {
pushUnique(events, {
type: 'pressure',
timestamp: p.timestamp,
confidence: 'medium',
summaryKey: 'logs.nmea_change_pressure',
summaryParams: { from: lastPressure.toFixed(1), to: p.pressureHpa.toFixed(1) },
data: p
}, config.dedupeWindowMs)
}
}
if (p.pressureHpa != null) lastPressure = p.pressureHpa
if (p.depthM != null && lastDepth != null) {
const delta = Math.abs(p.depthM - lastDepth)
const rel = lastDepth > 0 ? (delta / lastDepth) * 100 : 100
if (delta >= config.depthDeltaM || rel >= config.depthDeltaPercent) {
pushUnique(events, {
type: 'depth',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: 'logs.nmea_change_depth',
summaryParams: { from: lastDepth.toFixed(1), to: p.depthM.toFixed(1) },
data: p
}, config.dedupeWindowMs)
}
}
if (p.depthM != null) lastDepth = p.depthM
if (p.rpm != null) {
const running = p.rpm >= config.rpmRunning
const idle = p.rpm <= config.rpmIdle
if (running && !engineRunning) {
pushUnique(events, {
type: 'engine_start',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: 'logs.nmea_change_engine_start',
summaryParams: { rpm: Math.round(p.rpm) },
data: p
}, config.dedupeWindowMs)
engineRunning = true
} else if (idle && engineRunning) {
pushUnique(events, {
type: 'engine_stop',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: 'logs.nmea_change_engine_stop',
data: p
}, config.dedupeWindowMs)
engineRunning = false
}
}
if (p.autopilotEngaged != null && autopilot != null && p.autopilotEngaged !== autopilot) {
pushUnique(events, {
type: p.autopilotEngaged ? 'autopilot_on' : 'autopilot_off',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: p.autopilotEngaged ? 'logs.nmea_change_autopilot_on' : 'logs.nmea_change_autopilot_off',
data: p
}, config.dedupeWindowMs)
}
if (p.autopilotEngaged != null) autopilot = p.autopilotEngaged
if (p.fixValid != null && lastFix != null && p.fixValid !== lastFix) {
pushUnique(events, {
type: p.fixValid ? 'gps_fix_regained' : 'gps_fix_lost',
timestamp: p.timestamp,
confidence: 'high',
summaryKey: p.fixValid ? 'logs.nmea_change_gps_regained' : 'logs.nmea_change_gps_lost',
data: p
}, config.dedupeWindowMs)
}
if (p.fixValid != null) lastFix = p.fixValid
if (p.waterTempC != null && lastWaterTemp != null) {
if (Math.abs(p.waterTempC - lastWaterTemp) >= 2) {
pushUnique(events, {
type: 'water_temp',
timestamp: p.timestamp,
confidence: 'medium',
summaryKey: 'logs.nmea_change_water_temp',
summaryParams: { from: lastWaterTemp.toFixed(1), to: p.waterTempC.toFixed(1) },
data: p
}, config.dedupeWindowMs)
}
}
if (p.waterTempC != null) lastWaterTemp = p.waterTempC
const sog = p.sog ?? 0
if (sog >= config.sogUnderWayKn && !underWay) {
if (stoppedSince != null && p.timestamp - stoppedSince >= config.anchorMinutes * 60 * 1000) {
pushUnique(events, {
type: 'departure',
timestamp: p.timestamp,
confidence: 'medium',
summaryKey: 'logs.nmea_change_departure',
data: p
}, config.dedupeWindowMs)
}
underWay = true
stoppedSince = null
}
if (sog <= config.sogStoppedKn && underWay) {
underWay = false
stoppedSince = p.timestamp
}
if (sog <= config.sogStoppedKn && stoppedSince != null && !underWay) {
if (p.timestamp - stoppedSince >= config.anchorMinutes * 60 * 1000) {
pushUnique(events, {
type: 'anchor',
timestamp: p.timestamp,
confidence: 'medium',
summaryKey: 'logs.nmea_change_anchor',
data: p
}, config.dedupeWindowMs)
stoppedSince = null
}
}
if (lastSog != null && Math.abs(sog - lastSog) >= config.speedDeltaKn) {
pushUnique(events, {
type: 'speed',
timestamp: p.timestamp,
confidence: 'low',
summaryKey: 'logs.nmea_change_speed',
summaryParams: { from: lastSog.toFixed(1), to: sog.toFixed(1) },
data: p
}, config.dedupeWindowMs)
}
lastSog = sog
}
return events.sort((a, b) => a.timestamp - b.timestamp)
}
@@ -0,0 +1,139 @@
import type { TFunction } from 'i18next'
import type { LogEventPayload } from '../../utils/logEntryPayload.js'
import { normalizeLogEvent } from '../../utils/logEntryPayload.js'
import { formatCourseAngle } from '../../utils/courseAngle.js'
import { degreesToCardinal } from '../../utils/courseAngle.js'
import type {
NmeaChangeEvent,
NmeaImportMode,
NmeaJournalCandidate,
NmeaTimePoint
} from './nmeaTypes.js'
import { detectNmeaChanges } from './nmeaChangeDetection.js'
import { intervalTimestamps, sampleAt, timestampToHHMM } from './nmeaTimeSeries.js'
export interface GeneratedNmeaJournal {
candidates: Array<NmeaJournalCandidate & { event: LogEventPayload }>
}
function pointToLogEvent(
point: NmeaTimePoint,
remarks: string,
sailsOrMotor: string
): LogEventPayload {
const course = point.cog ?? point.hdt ?? point.hdm
const mgk = course != null ? formatCourseAngle(course) : ''
const windDir =
point.windDir != null ? degreesToCardinal(point.windDir) : ''
return normalizeLogEvent({
time: timestampToHHMM(point.timestamp),
mgk,
rwk: '',
windDirection: windDir,
windStrength: point.windSpeedKnots != null ? String(point.windSpeedKnots) : '',
windPressure: point.pressureHpa != null ? String(Math.round(point.pressureHpa)) : '',
gpsLat: point.lat != null ? point.lat.toFixed(6) : '',
gpsLng: point.lng != null ? point.lng.toFixed(6) : '',
logReading: point.logDistanceNm != null ? point.logDistanceNm.toFixed(2) : '',
sailsOrMotor,
remarks
})
}
function changeToSailsOrMotor(type: NmeaChangeEvent['type']): string {
if (type === 'engine_start') return 'Motor'
if (type === 'engine_stop') return 'Segel'
return ''
}
function buildRemarks(change: NmeaChangeEvent, t: TFunction): string {
const parts: string[] = []
parts.push(t(change.summaryKey, change.summaryParams ?? {}))
if (change.data?.depthM != null) {
parts.push(t('logs.nmea_remark_depth', { depth: change.data.depthM.toFixed(1) }))
}
if (change.confidence === 'low') {
parts.push(t('logs.nmea_remark_uncertain'))
}
return parts.join(' · ')
}
function dedupeCandidates(
items: Array<NmeaJournalCandidate & { event: LogEventPayload; timestamp: number }>,
windowMs: number
): Array<NmeaJournalCandidate & { event: LogEventPayload }> {
const sorted = [...items].sort((a, b) => a.timestamp - b.timestamp)
const kept: typeof sorted = []
for (const item of sorted) {
const near = kept.find((k) => Math.abs(k.timestamp - item.timestamp) <= windowMs)
if (!near) {
kept.push(item)
continue
}
if (item.source === 'change' && near.source === 'interval') {
const idx = kept.indexOf(near)
kept[idx] = {
...item,
event: {
...near.event,
remarks: [item.event.remarks, near.event.remarks].filter(Boolean).join(' · ')
}
}
}
}
return kept
}
export function generateNmeaJournalCandidates(options: {
points: NmeaTimePoint[]
mode: NmeaImportMode
intervalMinutes: number
t: TFunction
}): GeneratedNmeaJournal {
const { points, mode, intervalMinutes, t } = options
const items: Array<NmeaJournalCandidate & { event: LogEventPayload; timestamp: number }> = []
if (mode === 'interval' || mode === 'both') {
for (const ts of intervalTimestamps(points, intervalMinutes)) {
const sample = sampleAt(points, ts)
if (!sample) continue
items.push({
id: `interval-${ts}`,
timestamp: ts,
source: 'interval',
selected: true,
event: pointToLogEvent(sample, t('logs.nmea_remark_interval'), '')
})
}
}
if (mode === 'change' || mode === 'both') {
const changes = detectNmeaChanges(points)
for (const change of changes) {
const sample = change.data ?? sampleAt(points, change.timestamp)
if (!sample) continue
items.push({
id: `change-${change.type}-${change.timestamp}`,
timestamp: change.timestamp,
source: 'change',
changeType: change.type,
confidence: change.confidence,
selected: true,
event: pointToLogEvent(
{ ...sample, timestamp: change.timestamp },
buildRemarks(change, t),
changeToSailsOrMotor(change.type)
)
})
}
}
const deduped = mode === 'both'
? dedupeCandidates(items, 15 * 60 * 1000)
: items
return { candidates: deduped }
}
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import { nmeaPointsToWaypoints, parseNmeaFile } from './nmeaParse.js'
describe('parseNmeaFile', () => {
it('parses RMC position, course and speed', () => {
const text = [
'$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W',
'$GPRMC,133519,A,4808.038,N,01132.000,E,025.0,090.0,230394,003.1,W'
].join('\n')
const result = parseNmeaFile(text, 'test.nmea')
expect(result.stats.parsedLines).toBe(2)
expect(result.stats.sentenceTypes).toContain('RMC')
expect(result.points.length).toBeGreaterThanOrEqual(2)
const first = result.points[0]
expect(first.lat).toBeCloseTo(48.1173, 3)
expect(first.lng).toBeCloseTo(11.516667, 3)
expect(first.sog).toBe(22.4)
expect(first.cog).toBe(84.4)
expect(first.fixValid).toBe(true)
})
it('merges wind and depth sentences onto the same timestamp', () => {
const text = [
'$GPRMC,100000,A,5400.000,N,01000.000,E,5.0,180.0,010124,003.0,E',
'$IIMWV,270.0,R,12.5,N,A',
'$SDDPT,4.5,0.0'
].join('\n')
const result = parseNmeaFile(text, 'merged.nmea')
const last = result.points[result.points.length - 1]
expect(last.windDir).toBe(270)
expect(last.windSpeedKnots).toBe(12.5)
expect(last.depthM).toBe(4.5)
})
it('skips lines with invalid checksum', () => {
const text = '$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*FF'
const result = parseNmeaFile(text, 'bad.nmea')
expect(result.stats.checksumErrors).toBe(1)
expect(result.points).toHaveLength(0)
expect(result.warnings).toContain('no_samples')
})
it('warns when no position sentences are present', () => {
const text = '$IIMWV,090.0,R,8.0,N,A'
const result = parseNmeaFile(text, 'wind-only.nmea')
expect(result.warnings).toContain('no_position')
})
})
describe('nmeaPointsToWaypoints', () => {
it('maps points with coordinates to track waypoints', () => {
const waypoints = nmeaPointsToWaypoints([
{ timestamp: 1, lat: 54.0, lng: 10.0, sog: 6, cog: 90 },
{ timestamp: 2, windDir: 180 },
{ timestamp: 3, lat: 54.01, lng: 10.01, hdt: 95 }
])
expect(waypoints).toHaveLength(2)
expect(waypoints[0]).toMatchObject({ lat: 54, lng: 10, speedKnots: 6, heading: 90 })
expect(waypoints[1].heading).toBe(95)
})
})
+283
View File
@@ -0,0 +1,283 @@
import type { NmeaParseResult, NmeaParseStats, NmeaTimePoint } from './nmeaTypes.js'
function parseChecksum(line: string): boolean {
const star = line.lastIndexOf('*')
if (star < 0) return true
const expected = line.slice(star + 1, star + 3)
if (!/^[0-9A-Fa-f]{2}$/.test(expected)) return false
let sum = 0
for (let i = 1; i < star; i++) sum ^= line.charCodeAt(i)
return sum.toString(16).toUpperCase().padStart(2, '0') === expected.toUpperCase()
}
function sentenceType(field0: string): string {
return field0.length >= 3 ? field0.slice(-3) : field0
}
function parseLatLon(latStr: string, latHem: string, lonStr: string, lonHem: string): { lat?: number; lng?: number } {
const latVal = parseFloat(latStr)
const lonVal = parseFloat(lonStr)
if (Number.isNaN(latVal) || Number.isNaN(lonVal)) return {}
const latDeg = Math.floor(latVal / 100)
const latMin = latVal - latDeg * 100
let lat = latDeg + latMin / 60
if (latHem === 'S') lat = -lat
const lonDeg = Math.floor(lonVal / 100)
const lonMin = lonVal - lonDeg * 100
let lng = lonDeg + lonMin / 60
if (lonHem === 'W') lng = -lng
return { lat: Number(lat.toFixed(6)), lng: Number(lng.toFixed(6)) }
}
function parseRmcDateTime(timeStr: string, dateStr: string, baseYear = new Date().getFullYear()): number | null {
if (!timeStr || timeStr.length < 6) return null
const hh = parseInt(timeStr.slice(0, 2), 10)
const mm = parseInt(timeStr.slice(2, 4), 10)
const ss = parseInt(timeStr.slice(4, 6), 10)
if ([hh, mm, ss].some((n) => Number.isNaN(n))) return null
let year = baseYear
let month = 0
let day = 1
if (dateStr && dateStr.length >= 6) {
day = parseInt(dateStr.slice(0, 2), 10)
month = parseInt(dateStr.slice(2, 4), 10) - 1
const yy = parseInt(dateStr.slice(4, 6), 10)
year = yy >= 70 ? 1900 + yy : 2000 + yy
}
return Date.UTC(year, month, day, hh, mm, ss)
}
function parseWindSpeed(value: string, unit: string): number | undefined {
const speed = parseFloat(value)
if (Number.isNaN(speed)) return undefined
if (unit === 'N') return speed
if (unit === 'M') return speed * 1.94384
if (unit === 'K') return speed * 0.539957
return speed
}
interface MutableState extends NmeaTimePoint {
lastTimestamp: number | null
}
function snapshot(state: MutableState): NmeaTimePoint | null {
if (state.lastTimestamp == null) return null
const { lastTimestamp, ...rest } = state
void lastTimestamp
if (
rest.lat == null &&
rest.lng == null &&
rest.cog == null &&
rest.sog == null &&
rest.hdt == null &&
rest.windDir == null &&
rest.windSpeedKnots == null &&
rest.depthM == null &&
rest.rpm == null
) {
return null
}
return rest as NmeaTimePoint
}
function pushPoint(points: NmeaTimePoint[], state: MutableState) {
const snap = snapshot(state)
if (!snap) return
const last = points[points.length - 1]
if (last && last.timestamp === snap.timestamp) {
points[points.length - 1] = { ...last, ...snap }
return
}
points.push(snap)
}
function applySentence(state: MutableState, type: string, fields: string[], points: NmeaTimePoint[]) {
switch (type) {
case 'RMC': {
const status = fields[2]
const ts = parseRmcDateTime(fields[1], fields[9])
if (ts != null) {
state.timestamp = ts
state.lastTimestamp = ts
}
if (status === 'A') {
Object.assign(state, parseLatLon(fields[3], fields[4], fields[5], fields[6]))
state.fixValid = true
const sog = parseFloat(fields[7])
const cog = parseFloat(fields[8])
if (!Number.isNaN(sog)) state.sog = sog
if (!Number.isNaN(cog)) state.cog = cog
} else {
state.fixValid = false
}
pushPoint(points, state)
break
}
case 'GGA': {
const ts = parseRmcDateTime(fields[1], '')
if (ts != null) {
state.timestamp = ts
state.lastTimestamp = ts
}
Object.assign(state, parseLatLon(fields[2], fields[3], fields[4], fields[5]))
const quality = parseInt(fields[6], 10)
state.fixValid = !Number.isNaN(quality) && quality > 0
pushPoint(points, state)
break
}
case 'GLL': {
const ts = parseRmcDateTime(fields[5], fields[6] ?? '')
if (ts != null) {
state.timestamp = ts
state.lastTimestamp = ts
}
Object.assign(state, parseLatLon(fields[1], fields[2], fields[3], fields[4]))
state.fixValid = fields[7] === 'A'
pushPoint(points, state)
break
}
case 'VTG': {
const cog = parseFloat(fields[1])
const sog = parseFloat(fields[5] || fields[7])
if (!Number.isNaN(cog)) state.cog = cog
if (!Number.isNaN(sog)) state.sog = sog
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
case 'HDT':
state.hdt = parseFloat(fields[1])
if (state.lastTimestamp != null) pushPoint(points, state)
break
case 'HDM':
state.hdm = parseFloat(fields[1])
if (state.lastTimestamp != null) pushPoint(points, state)
break
case 'HDG': {
const hdg = parseFloat(fields[1])
if (!Number.isNaN(hdg)) state.hdm = hdg
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
case 'MWV': {
if (fields[5] !== 'A') break
const dir = parseFloat(fields[1])
const speed = parseWindSpeed(fields[3], fields[4])
if (!Number.isNaN(dir)) state.windDir = dir
if (speed != null) state.windSpeedKnots = Number(speed.toFixed(1))
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
case 'MWD': {
const dir = parseFloat(fields[1])
const speed = parseFloat(fields[5])
if (!Number.isNaN(dir)) state.windDir = dir
if (!Number.isNaN(speed)) state.windSpeedKnots = Number(speed.toFixed(1))
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
case 'DPT':
case 'DBT': {
const depth = parseFloat(fields[1])
if (!Number.isNaN(depth)) state.depthM = depth
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
case 'RPM': {
const rpm = parseFloat(fields[3] ?? fields[2])
if (!Number.isNaN(rpm)) state.rpm = rpm
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
case 'MDA': {
const inchHg = parseFloat(fields[3])
const hpaField = parseFloat(fields[15] ?? fields[4])
if (!Number.isNaN(hpaField) && hpaField > 800) state.pressureHpa = hpaField
else if (!Number.isNaN(inchHg)) state.pressureHpa = inchHg * 33.8639
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
case 'MTW': {
const temp = parseFloat(fields[1])
if (!Number.isNaN(temp)) state.waterTempC = temp
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
case 'VLW': {
const nm = parseFloat(fields[1] ?? fields[2])
if (!Number.isNaN(nm)) state.logDistanceNm = nm
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
case 'APA': {
const mode = fields[1]
state.autopilotEngaged = mode === '1' || mode?.toUpperCase() === 'A'
if (state.lastTimestamp != null) pushPoint(points, state)
break
}
default:
break
}
}
export function parseNmeaFile(text: string, filename: string): NmeaParseResult {
const warnings: string[] = []
const points: NmeaTimePoint[] = []
const typesSeen = new Set<string>()
let totalLines = 0
let parsedLines = 0
let checksumErrors = 0
const state: MutableState = { timestamp: 0, lastTimestamp: null }
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || (!line.startsWith('$') && !line.startsWith('!'))) continue
totalLines++
if (!parseChecksum(line)) {
checksumErrors++
continue
}
const star = line.indexOf('*')
const body = star >= 0 ? line.slice(0, star) : line
const fields = body.slice(1).split(',')
if (fields.length < 2) continue
const type = sentenceType(fields[0])
typesSeen.add(type)
applySentence(state, type, fields, points)
parsedLines++
}
if (points.length === 0) {
warnings.push('no_samples')
}
if (!typesSeen.has('RMC') && !typesSeen.has('GGA') && !typesSeen.has('GLL')) {
warnings.push('no_position')
}
const stats: NmeaParseStats = {
totalLines,
parsedLines,
checksumErrors,
sentenceTypes: [...typesSeen].sort()
}
return { points, stats, warnings, rawText: text, filename }
}
export function nmeaPointsToWaypoints(points: NmeaTimePoint[]): import('../trackUpload.js').TrackWaypoint[] {
return points
.filter((p) => p.lat != null && p.lng != null)
.map((p) => ({
timestamp: p.timestamp,
lat: p.lat!,
lng: p.lng!,
speedKnots: p.sog,
heading: p.cog ?? p.hdt ?? p.hdm
}))
}
@@ -0,0 +1,58 @@
import type { NmeaTimePoint } from './nmeaTypes.js'
/** Nearest sample at or before timestamp (carry-forward). */
export function sampleAt(points: NmeaTimePoint[], timestamp: number): NmeaTimePoint | null {
if (points.length === 0) return null
let best: NmeaTimePoint | null = null
for (const p of points) {
if (p.timestamp <= timestamp) best = p
else break
}
return best ?? points[0]
}
export function filterPointsForDate(points: NmeaTimePoint[], dateYmd: string): NmeaTimePoint[] {
if (!dateYmd || points.length === 0) return points
const [y, m, d] = dateYmd.split('-').map((v) => parseInt(v, 10))
if ([y, m, d].some((n) => Number.isNaN(n))) return points
const start = Date.UTC(y, m - 1, d, 0, 0, 0)
const end = Date.UTC(y, m - 1, d, 23, 59, 59)
const filtered = points.filter((p) => p.timestamp >= start && p.timestamp <= end)
return filtered.length > 0 ? filtered : points
}
export function timestampToHHMM(timestamp: number, timeZone?: string): string {
const opts: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: timeZone ?? undefined
}
const parts = new Intl.DateTimeFormat('en-GB', opts).formatToParts(new Date(timestamp))
const hh = parts.find((p) => p.type === 'hour')?.value ?? '00'
const mm = parts.find((p) => p.type === 'minute')?.value ?? '00'
return `${hh}:${mm}`
}
export function angularDelta(a: number, b: number): number {
const diff = Math.abs(a - b) % 360
return diff > 180 ? 360 - diff : diff
}
export function intervalTimestamps(
points: NmeaTimePoint[],
intervalMinutes: number
): number[] {
if (points.length === 0) return []
const start = points[0].timestamp
const end = points[points.length - 1].timestamp
const stepMs = intervalMinutes * 60 * 1000
const stamps: number[] = []
for (let t = start; t <= end; t += stepMs) {
stamps.push(t)
}
if (stamps[stamps.length - 1] !== end) stamps.push(end)
return stamps
}
+102
View File
@@ -0,0 +1,102 @@
export type NmeaChangeType =
| 'course'
| 'wind'
| 'pressure'
| 'engine_start'
| 'engine_stop'
| 'autopilot_on'
| 'autopilot_off'
| 'depth'
| 'anchor'
| 'departure'
| 'speed'
| 'gps_fix_lost'
| 'gps_fix_regained'
| 'water_temp'
| 'wind_shift'
export interface NmeaParseStats {
totalLines: number
parsedLines: number
checksumErrors: number
sentenceTypes: string[]
}
export interface NmeaTimePoint {
timestamp: number
lat?: number
lng?: number
cog?: number
sog?: number
hdt?: number
hdm?: number
windDir?: number
windSpeedKnots?: number
depthM?: number
rpm?: number
pressureHpa?: number
waterTempC?: number
logDistanceNm?: number
fixValid?: boolean
autopilotEngaged?: boolean
}
export interface NmeaChangeEvent {
type: NmeaChangeType
timestamp: number
confidence: 'high' | 'medium' | 'low'
summaryKey: string
summaryParams?: Record<string, string | number>
data?: Partial<NmeaTimePoint>
}
export interface NmeaParseResult {
points: NmeaTimePoint[]
stats: NmeaParseStats
warnings: string[]
rawText: string
filename: string
}
export type NmeaImportMode = 'interval' | 'change' | 'both'
export interface NmeaJournalCandidate {
id: string
timestamp: number
source: 'interval' | 'change'
changeType?: NmeaChangeType
confidence?: 'high' | 'medium' | 'low'
selected: boolean
}
export interface NmeaDetectionConfig {
courseDeltaDeg: number
windDirDeltaDeg: number
windSpeedDeltaKnots: number
pressureDeltaHpa: number
depthDeltaM: number
depthDeltaPercent: number
rpmIdle: number
rpmRunning: number
sogUnderWayKn: number
sogStoppedKn: number
anchorMinutes: number
speedDeltaKn: number
dedupeWindowMs: number
}
export const DEFAULT_NMEA_DETECTION_CONFIG: NmeaDetectionConfig = {
courseDeltaDeg: 28,
windDirDeltaDeg: 35,
windSpeedDeltaKnots: 4,
pressureDeltaHpa: 2,
depthDeltaM: 2,
depthDeltaPercent: 25,
rpmIdle: 400,
rpmRunning: 800,
sogUnderWayKn: 2,
sogStoppedKn: 0.5,
anchorMinutes: 10,
speedDeltaKn: 3,
dedupeWindowMs: 5 * 60 * 1000
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { isNmeaCrcAlreadyImported, type NmeaArchiveRecord } from './nmeaArchive.js'
import { nmeaFileCrc32 } from '../utils/crc32.js'
describe('nmeaArchive CRC tracking', () => {
it('detects duplicate file content by CRC32', () => {
const text = '$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W\n'
const record: NmeaArchiveRecord = {
filename: 'a.nmea',
rawText: '',
importedAt: '2026-05-29T10:00:00.000Z',
importedFiles: [{
crc32: nmeaFileCrc32(text),
filename: 'a.nmea',
importedAt: '2026-05-29T10:00:00.000Z'
}]
}
expect(isNmeaCrcAlreadyImported(record, text)).toBe(true)
expect(isNmeaCrcAlreadyImported(record, text.replace('\n', '\r\n'))).toBe(true)
expect(isNmeaCrcAlreadyImported(record, '$GPRMC,999999,A\n')).toBe(false)
expect(isNmeaCrcAlreadyImported(null, text)).toBe(false)
})
})
+146
View File
@@ -0,0 +1,146 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { encryptJson, decryptJson } from './crypto.js'
import { nmeaFileCrc32 } from '../utils/crc32.js'
export interface NmeaImportedFile {
crc32: string
filename: string
importedAt: string
}
export interface NmeaArchiveRecord {
filename: string
rawText: string
importedAt: string
importedFiles: NmeaImportedFile[]
}
function normalizeArchiveRecord(raw: Partial<NmeaArchiveRecord>): NmeaArchiveRecord {
const importedFiles = [...(raw.importedFiles ?? [])]
if (importedFiles.length === 0 && raw.rawText) {
importedFiles.push({
crc32: nmeaFileCrc32(raw.rawText),
filename: raw.filename ?? '',
importedAt: raw.importedAt ?? ''
})
}
return {
filename: raw.filename ?? '',
rawText: raw.rawText ?? '',
importedAt: raw.importedAt ?? '',
importedFiles
}
}
async function putNmeaArchiveRecord(
logbookId: string,
entryId: string,
payload: NmeaArchiveRecord
): Promise<void> {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const encrypted = await encryptJson(payload, masterKey)
await db.nmeaArchives.put({
entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: payload.importedAt || new Date().toISOString()
})
}
export async function getNmeaArchive(entryId: string): Promise<NmeaArchiveRecord | null> {
const record = await db.nmeaArchives.get(entryId)
if (!record) return null
const masterKey = await getLogbookKey(record.logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
try {
return normalizeArchiveRecord(
await decryptJson(record.encryptedData, record.iv, record.tag, masterKey) as Partial<NmeaArchiveRecord>
)
} catch {
return null
}
}
export function isNmeaCrcAlreadyImported(record: NmeaArchiveRecord | null, rawText: string): boolean {
if (!record) return false
const crc32 = nmeaFileCrc32(rawText)
return record.importedFiles.some((file) => file.crc32 === crc32)
}
/** Remember imported file by CRC (even when raw log is discarded). */
export async function recordNmeaFileImport(
logbookId: string,
entryId: string,
filename: string,
rawText: string
): Promise<string> {
const crc32 = nmeaFileCrc32(rawText)
const existing = await getNmeaArchive(entryId)
const importedFiles = [...(existing?.importedFiles ?? [])]
if (!importedFiles.some((file) => file.crc32 === crc32)) {
importedFiles.push({
crc32,
filename,
importedAt: new Date().toISOString()
})
}
const payload: NmeaArchiveRecord = {
filename: existing?.filename ?? '',
rawText: existing?.rawText ?? '',
importedAt: new Date().toISOString(),
importedFiles
}
await putNmeaArchiveRecord(logbookId, entryId, payload)
return crc32
}
export async function saveNmeaArchive(
logbookId: string,
entryId: string,
filename: string,
rawText: string
): Promise<void> {
const crc32 = nmeaFileCrc32(rawText)
const existing = await getNmeaArchive(entryId)
const importedFiles = [...(existing?.importedFiles ?? [])]
if (!importedFiles.some((file) => file.crc32 === crc32)) {
importedFiles.push({
crc32,
filename,
importedAt: new Date().toISOString()
})
}
const payload: NmeaArchiveRecord = {
filename,
rawText,
importedAt: new Date().toISOString(),
importedFiles
}
await putNmeaArchiveRecord(logbookId, entryId, payload)
}
export async function deleteNmeaArchive(entryId: string): Promise<void> {
await db.nmeaArchives.delete(entryId)
}
export function downloadNmeaArchive(record: NmeaArchiveRecord): void {
const blob = new Blob([record.rawText], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = record.filename || 'track.nmea'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
+110
View File
@@ -0,0 +1,110 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { decryptJson, encryptJson } from './crypto.js'
import type { PersonData } from '../types/person.js'
import { MAX_POOL_CREW_MEMBERS } from '../types/person.js'
import { syncPersonPool } from './personPoolSync.js'
export interface DecryptedPerson {
payloadId: string
data: PersonData
}
function requireMasterKey(): ArrayBuffer {
const key = getActiveMasterKey()
if (!key) throw new Error('Encryption key not found. Please log in.')
return key
}
export async function loadPersonPool(): Promise<DecryptedPerson[]> {
const masterKey = requireMasterKey()
const records = await db.personPool.toArray()
const result: DecryptedPerson[] = []
for (const record of records) {
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)) as
| PersonData
| null
if (data) {
result.push({ payloadId: record.payloadId, data })
}
}
result.sort((a, b) => {
if (a.data.role !== b.data.role) return a.data.role === 'skipper' ? -1 : 1
return a.data.name.localeCompare(b.data.name, undefined, { sensitivity: 'base' })
})
return result
}
export async function loadPersonPoolMap(): Promise<Map<string, PersonData>> {
const people = await loadPersonPool()
return new Map(people.map((p) => [p.payloadId, p.data]))
}
export async function savePerson(
payloadId: string,
data: PersonData,
isNew: boolean
): Promise<void> {
if (data.role === 'crew' && isNew) {
const crewCount = await db.personPool
.toArray()
.then(async (rows) => {
let count = 0
const masterKey = requireMasterKey()
for (const row of rows) {
const dec = (await decryptJson(row.encryptedData, row.iv, row.tag, masterKey)) as PersonData | null
if (dec?.role === 'crew') count++
}
return count
})
if (crewCount >= MAX_POOL_CREW_MEMBERS) {
throw new Error('MAX_CREW')
}
}
const masterKey = requireMasterKey()
const encrypted = await encryptJson(data, masterKey)
const now = new Date().toISOString()
await db.personPool.put({
payloadId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.userSyncQueue.put({
action: isNew ? 'create' : 'update',
type: 'person',
payloadId,
data: JSON.stringify(encrypted),
updatedAt: now
})
syncPersonPool().catch((err) => console.warn('Person pool sync failed:', err))
}
export async function deletePerson(payloadId: string): Promise<void> {
const now = new Date().toISOString()
await db.personPool.delete(payloadId)
await db.userSyncQueue.put({
action: 'delete',
type: 'person',
payloadId,
data: '',
updatedAt: now
})
syncPersonPool().catch((err) => console.warn('Person pool sync failed:', err))
}
export function filterSkippers(people: DecryptedPerson[]): DecryptedPerson[] {
return people.filter((p) => p.data.role === 'skipper')
}
export function filterCrew(people: DecryptedPerson[]): DecryptedPerson[] {
return people.filter((p) => p.data.role === 'crew')
}
+83
View File
@@ -0,0 +1,83 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { apiFetch } from './api.js'
const API_BASE = '/api/auth/person-pool'
function isNewer(timeA: string | Date, timeB: string | Date): boolean {
return new Date(timeA).getTime() > new Date(timeB).getTime()
}
export async function syncPersonPool(): Promise<void> {
if (!navigator.onLine || !getActiveMasterKey() || !localStorage.getItem('active_userid')) return
await pushPersonPool()
await pullPersonPool()
}
async function pushPersonPool(): Promise<void> {
const pending = await db.userSyncQueue.toArray()
if (pending.length === 0) return
try {
const response = await apiFetch(`${API_BASE}/push`, {
method: 'POST',
body: JSON.stringify({ items: pending })
})
if (!response.ok) {
console.warn('Person pool push rejected')
return
}
const { results } = await response.json()
for (let i = 0; i < results.length; i++) {
const res = results[i]
const item = pending[i]
if (!item) continue
if (res.status === 'success' && item.id !== undefined) {
await db.userSyncQueue.delete(item.id)
}
}
} catch (err) {
console.warn('Person pool push failed:', err)
}
}
async function pullPersonPool(): Promise<void> {
try {
const response = await apiFetch(API_BASE, { method: 'GET' })
if (!response.ok) return
const { persons } = await response.json()
if (!Array.isArray(persons)) return
const serverMap = new Map<string, (typeof persons)[0]>()
for (const p of persons) {
serverMap.set(p.payloadId, p)
const local = await db.personPool.get(p.payloadId)
if (!local || isNewer(p.updatedAt, local.updatedAt)) {
await db.personPool.put({
payloadId: p.payloadId,
encryptedData: p.encryptedData,
iv: p.iv,
tag: p.tag,
updatedAt: p.updatedAt
})
}
}
const localAll = await db.personPool.toArray()
for (const local of localAll) {
if (!serverMap.has(local.payloadId)) {
const pendingCreate = await db.userSyncQueue
.where({ payloadId: local.payloadId, action: 'create' })
.first()
if (!pendingCreate) {
await db.personPool.delete(local.payloadId)
}
}
}
} catch (err) {
console.warn('Person pool pull failed:', err)
}
}
+92
View File
@@ -0,0 +1,92 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
async function getEncryptionKey(logbookId: string): Promise<ArrayBuffer> {
const key = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!key) throw new Error('Encryption key not found. Please log in.')
return key
}
export async function saveEntryPhoto(options: {
logbookId: string
entryId: string
imageDataUrl: string
caption?: string
analyticsContext?: string
}): Promise<string> {
const { logbookId, entryId, imageDataUrl, caption = '', analyticsContext = 'logbook' } = options
const masterKey = await getEncryptionKey(logbookId)
const photoId = window.crypto.randomUUID()
const photoPayload = {
image: imageDataUrl,
caption: caption.trim()
}
const encrypted = await encryptJson(photoPayload, masterKey)
const now = new Date().toISOString()
await db.photos.put({
payloadId: photoId,
entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
caption: '',
updatedAt: now
})
await db.syncQueue.put({
action: 'create',
type: 'photo',
payloadId: photoId,
logbookId,
data: JSON.stringify({
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
entryId
}),
updatedAt: now
})
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))
return photoId
}
export async function deleteEntryPhoto(logbookId: string, photoId: string): Promise<void> {
const now = new Date().toISOString()
await db.photos.delete(photoId)
await db.syncQueue.put({
action: 'delete',
type: 'photo',
payloadId: photoId,
logbookId,
data: '',
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
/** Deletes the newest photo for an entry; returns its id or null. */
export async function removeLastPhotoForEntry(
logbookId: string,
entryId: string
): Promise<string | null> {
const photos = await db.photos.where({ entryId }).toArray()
if (photos.length === 0) return null
photos.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
)
const lastId = photos[0].payloadId
await deleteEntryPhoto(logbookId, lastId)
return lastId
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it, vi } from 'vitest'
import { tryDecryptEntryPayload } from './quickEventLog.js'
vi.mock('./crypto.js', () => ({
decryptJson: vi.fn(async (_c: string, _i: string, _t: string) => {
throw new Error('decrypt failed')
}),
encryptJson: vi.fn()
}))
describe('tryDecryptEntryPayload', () => {
it('returns null when decryption fails', async () => {
const result = await tryDecryptEntryPayload(
{ encryptedData: 'x', iv: 'y', tag: 'z' },
new ArrayBuffer(32)
)
expect(result).toBeNull()
})
})
+388
View File
@@ -0,0 +1,388 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { ensureLogbookKey, getLogbookKey } from './logbookKeys.js'
import { decryptJson, encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import {
buildLogEntryPayload,
normalizeLogEvent,
sortLogEventsByTime,
currentLocalTimeHHMM,
type LogEventPayload
} from '../utils/logEntryPayload.js'
import {
carryOverFromPreviousDay,
compareTravelDaysChronological,
getNextTravelDayNumber,
type LogEntryTankSource,
type TravelDaySortable
} from '../utils/logEntryTankLevels.js'
export interface LoadedEntry {
payloadId: string
updatedAt: string
data: Record<string, unknown>
}
type EncryptedRecord = {
encryptedData: string
iv: string
tag: string
}
async function getMasterKey(logbookId: string): Promise<ArrayBuffer> {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
return masterKey
}
/** Decrypt one record; skip corrupt or legacy entries instead of aborting the whole scan. */
export async function tryDecryptEntryPayload(
record: EncryptedRecord,
key: ArrayBuffer
): Promise<Record<string, unknown> | null> {
try {
return await decryptJson(record.encryptedData, record.iv, record.tag, key)
} catch {
return null
}
}
function sortEntriesNewestFirst<T extends { updatedAt: string }>(entries: T[]): T[] {
return [...entries].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
)
}
function tankLevelsFromData(data: Record<string, unknown>) {
const fw = (data.freshwater as Record<string, number> | undefined) ?? {
morning: 0, refilled: 0, evening: 0, consumption: 0
}
const fuel = (data.fuel as Record<string, number> | undefined) ?? {
morning: 0, refilled: 0, evening: 0, consumption: 0
}
const gw = data.greywater as { level?: number } | undefined
return { fw, fuel, gw }
}
function buildEncryptedPayload(
data: Record<string, unknown>,
options: {
events: LogEventPayload[]
departure?: string
destination?: string
freshwater?: { morning: number; refilled: number; evening: number; consumption: number }
fuel?: { morning: number; refilled: number; evening: number; consumption: number }
clearSignatures?: boolean
}
): Record<string, unknown> {
const { fw, fuel, gw } = tankLevelsFromData(data)
const trackDistance = data.trackDistanceNm
const trackSpeedMax = data.trackSpeedMaxKn
const trackSpeedAvg = data.trackSpeedAvgKn
const motorHoursRaw = data.motorHours
const freshwater = options.freshwater ?? {
morning: fw.morning || 0,
refilled: fw.refilled || 0,
evening: fw.evening || 0,
consumption: fw.consumption ?? 0
}
const fuelLevels = options.fuel ?? {
morning: fuel.morning || 0,
refilled: fuel.refilled || 0,
evening: fuel.evening || 0,
consumption: fuel.consumption ?? 0
}
const payload = buildLogEntryPayload({
date: String(data.date || ''),
dayOfTravel: String(data.dayOfTravel || ''),
departure: options.departure ?? String(data.departure || ''),
destination: options.destination ?? String(data.destination || ''),
freshwater,
fuel: fuelLevels,
greywater: gw ? { level: gw.level || 0 } : undefined,
trackDistanceNm:
trackDistance != null && trackDistance !== ''
? parseFloat(String(trackDistance))
: undefined,
trackSpeedMaxKn:
trackSpeedMax != null && trackSpeedMax !== ''
? parseFloat(String(trackSpeedMax))
: undefined,
trackSpeedAvgKn:
trackSpeedAvg != null && trackSpeedAvg !== ''
? parseFloat(String(trackSpeedAvg))
: undefined,
motorHours:
motorHoursRaw != null && motorHoursRaw !== ''
? parseFloat(String(motorHoursRaw))
: undefined,
events: options.events
})
const clear = options.clearSignatures
return {
...payload,
signSkipper: clear ? '' : (data.signSkipper ?? ''),
signCrew: clear ? '' : (data.signCrew ?? '')
}
}
export async function loadEntry(logbookId: string, entryId: string): Promise<LoadedEntry | null> {
const masterKey = await getMasterKey(logbookId)
const record = await db.entries.get(entryId)
if (!record) return null
const data = await tryDecryptEntryPayload(record, masterKey)
if (!data) return null
return { payloadId: record.payloadId, updatedAt: record.updatedAt, data }
}
export async function findTodayEntryId(logbookId: string): Promise<string | null> {
const todayStr = new Date().toISOString().substring(0, 10)
const masterKey = await getMasterKey(logbookId)
const local = sortEntriesNewestFirst(await db.entries.where({ logbookId }).toArray())
for (const entry of local) {
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
if (decrypted && String(decrypted.date) === todayStr) {
return entry.payloadId
}
}
return null
}
export async function createTodayEntry(logbookId: string): Promise<string> {
const masterKey = await getMasterKey(logbookId)
const localEntries = await db.entries.where({ logbookId }).toArray()
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
if (localEntries.length > 0) {
for (const entry of localEntries) {
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
if (decrypted) {
decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable)
}
}
}
decryptedEntries.sort(compareTravelDaysChronological)
const previousEntry = decryptedEntries.at(-1) ?? null
const { freshwater, fuel, greywaterLevel, departure } = carryOverFromPreviousDay(previousEntry)
const localId = window.crypto.randomUUID()
const nowStr = new Date().toISOString()
const todayStr = nowStr.substring(0, 10)
const initialPayload = {
date: todayStr,
dayOfTravel: getNextTravelDayNumber(decryptedEntries),
departure,
destination: '',
freshwater,
fuel,
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
signSkipper: '',
signCrew: '',
events: []
}
const encrypted = await encryptJson(initialPayload, masterKey)
await db.entries.put({
payloadId: localId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: nowStr
})
await db.syncQueue.put({
action: 'create',
type: 'entry',
payloadId: localId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: nowStr
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
return localId
}
export async function findOrCreateTodayEntry(logbookId: string): Promise<string> {
const id = logbookId.trim()
if (!id) throw new Error('Logbook id required')
await ensureLogbookKey(id)
const entryCount = await db.entries.where({ logbookId: id }).count()
if (entryCount === 0) {
return createTodayEntry(id)
}
const existing = await findTodayEntryId(id)
if (existing) return existing
return createTodayEntry(id)
}
export interface AppendQuickEventResult {
events: LogEventPayload[]
hadSignature: boolean
}
export async function appendQuickEvent(
logbookId: string,
entryId: string,
partialEvent: Partial<LogEventPayload>,
headerPatch?: { departure?: string; destination?: string }
): Promise<AppendQuickEventResult> {
const loaded = await loadEntry(logbookId, entryId)
if (!loaded) throw new Error('Entry not found')
const hadSignature = !!(loaded.data.signSkipper || loaded.data.signCrew)
const currentEvents = (loaded.data.events as LogEventPayload[]) || []
const newEvent = normalizeLogEvent({
time: currentLocalTimeHHMM(),
...partialEvent
})
const nextEvents = sortLogEventsByTime([...currentEvents, newEvent])
await persistEntry(logbookId, entryId, loaded.data, {
events: nextEvents,
departure: headerPatch?.departure,
destination: headerPatch?.destination,
clearSignatures: hadSignature
})
return { events: nextEvents, hadSignature }
}
/** Append multiple events in one load/encrypt/persist cycle (avoids UI freezes). */
export async function appendQuickEvents(
logbookId: string,
entryId: string,
partialEvents: Partial<LogEventPayload>[]
): Promise<AppendQuickEventResult> {
const loaded = await loadEntry(logbookId, entryId)
if (!loaded) throw new Error('Entry not found')
const hadSignature = !!(loaded.data.signSkipper || loaded.data.signCrew)
const currentEvents = (loaded.data.events as LogEventPayload[]) || []
if (partialEvents.length === 0) {
return { events: currentEvents, hadSignature }
}
const time = currentLocalTimeHHMM()
const newEvents = partialEvents.map((partial) =>
normalizeLogEvent({ time, ...partial })
)
const nextEvents = sortLogEventsByTime([...currentEvents, ...newEvents])
await persistEntry(logbookId, entryId, loaded.data, {
events: nextEvents,
clearSignatures: hadSignature
})
return { events: nextEvents, hadSignature }
}
async function persistEntry(
logbookId: string,
entryId: string,
data: Record<string, unknown>,
options: Parameters<typeof buildEncryptedPayload>[1]
): Promise<void> {
const hadSignature = !!(data.signSkipper || data.signCrew)
const entryData = buildEncryptedPayload(data, {
...options,
clearSignatures: options.clearSignatures ?? hadSignature
})
const masterKey = await getMasterKey(logbookId)
const encrypted = await encryptJson(entryData, masterKey)
const now = new Date().toISOString()
await db.entries.put({
payloadId: entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'update',
type: 'entry',
payloadId: entryId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
export async function removeLastEvent(
logbookId: string,
entryId: string
): Promise<LogEventPayload[]> {
const loaded = await loadEntry(logbookId, entryId)
if (!loaded) throw new Error('Entry not found')
const currentEvents = (loaded.data.events as LogEventPayload[]) || []
if (currentEvents.length === 0) return []
const nextEvents = sortLogEventsByTime(currentEvents.slice(0, -1))
await persistEntry(logbookId, entryId, loaded.data, { events: nextEvents })
return nextEvents
}
export async function appendTankRefill(
logbookId: string,
entryId: string,
tank: 'fuel' | 'freshwater',
addLiters: number,
event: Partial<LogEventPayload>
): Promise<AppendQuickEventResult> {
const loaded = await loadEntry(logbookId, entryId)
if (!loaded) throw new Error('Entry not found')
const { fw, fuel } = tankLevelsFromData(loaded.data)
const currentEvents = (loaded.data.events as LogEventPayload[]) || []
const newEvent = normalizeLogEvent({
time: currentLocalTimeHHMM(),
...event
})
const nextEvents = sortLogEventsByTime([...currentEvents, newEvent])
const tankPatch = tank === 'fuel'
? {
fuel: {
morning: fuel.morning || 0,
refilled: (fuel.refilled || 0) + addLiters,
evening: fuel.evening || 0,
consumption: fuel.consumption ?? 0
}
}
: {
freshwater: {
morning: fw.morning || 0,
refilled: (fw.refilled || 0) + addLiters,
evening: fw.evening || 0,
consumption: fw.consumption ?? 0
}
}
const hadSignature = !!(loaded.data.signSkipper || loaded.data.signCrew)
await persistEntry(logbookId, entryId, loaded.data, {
events: nextEvents,
...tankPatch,
clearSignatures: hadSignature
})
return { events: nextEvents, hadSignature }
}
+96 -5
View File
@@ -2,6 +2,12 @@ import { db, type SyncQueueItem } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { apiFetch } from './api.js'
import { getLogbookAccess } from './logbookAccess.js'
import {
clearSyncConflict,
reportSyncConflict,
type SyncConflict
} from './syncConflicts.js'
import { syncPersonPool } from './personPoolSync.js'
const API_BASE = '/api/sync'
const syncingLogbooks = new Set<string>()
@@ -56,6 +62,8 @@ async function entityExistsLocally(item: SyncQueueItem): Promise<boolean> {
return !!(await db.photos.get(item.payloadId))
case 'gpsTrack':
return !!(await db.gpsTracks.get(item.payloadId))
case 'logbookCrew':
return !!(await db.logbookCrewSelections.get(item.logbookId))
default:
return false
}
@@ -177,10 +185,19 @@ async function pushChanges(logbookId: string): Promise<boolean> {
const queueItem = pending[i]
if (!queueItem) continue
if (res.status === 'success' || res.status === 'conflict') {
if (res.status === 'success') {
if (queueItem.id !== undefined) {
await db.syncQueue.delete(queueItem.id)
}
clearSyncConflict(logbookId, res.payloadId ?? queueItem.payloadId, queueItem.type)
} else if (res.status === 'conflict') {
reportSyncConflict({
logbookId,
payloadId: res.payloadId ?? queueItem.payloadId,
type: queueItem.type,
reason: typeof res.reason === 'string' ? res.reason : 'Server version is newer',
queueItemId: queueItem.id
})
} else {
console.error(`Sync failed for item ${res.payloadId}:`, res.error)
}
@@ -210,6 +227,7 @@ async function flushPushQueue(logbookId: string): Promise<boolean> {
type PulledServerPayload = {
yacht?: { updatedAt: string } | null
deviation?: { updatedAt: string } | null
logbookCrewSelection?: { updatedAt: string } | null
crews?: Array<{ payloadId: string; updatedAt: string }>
entries?: Array<{ payloadId: string; updatedAt: string }>
photos?: Array<{ payloadId: string; updatedAt: string }>
@@ -227,6 +245,9 @@ async function pruneAcknowledgedQueueItems(
const serverTimes = new Map<string, string>()
if (server.yacht) serverTimes.set('yacht:' + logbookId, server.yacht.updatedAt)
if (server.deviation) serverTimes.set('deviation:' + logbookId, server.deviation.updatedAt)
if (server.logbookCrewSelection) {
serverTimes.set('logbookCrew:' + logbookId, server.logbookCrewSelection.updatedAt)
}
for (const c of server.crews ?? []) serverTimes.set('crew:' + c.payloadId, c.updatedAt)
for (const e of server.entries ?? []) serverTimes.set('entry:' + e.payloadId, e.updatedAt)
for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt)
@@ -243,7 +264,12 @@ async function pruneAcknowledgedQueueItems(
continue
}
const key = item.type === 'yacht' ? 'yacht:' + logbookId : `${item.type}:${item.payloadId}`
const key =
item.type === 'yacht'
? 'yacht:' + logbookId
: item.type === 'logbookCrew'
? 'logbookCrew:' + logbookId
: `${item.type}:${item.payloadId}`
const serverUpdatedAt = serverTimes.get(key)
if (serverUpdatedAt && !isNewer(item.updatedAt, serverUpdatedAt)) {
if (item.id !== undefined) staleIds.push(item.id)
@@ -269,8 +295,17 @@ async function pullChanges(logbookId: string): Promise<boolean> {
return false
}
const { yacht, deviation, crews, entries, photos, gpsTracks } = await response.json()
const serverSnapshot: PulledServerPayload = { yacht, deviation, crews, entries, photos, gpsTracks }
const { yacht, deviation, crews, logbookCrewSelection, entries, photos, gpsTracks } =
await response.json()
const serverSnapshot: PulledServerPayload = {
yacht,
deviation,
logbookCrewSelection,
crews,
entries,
photos,
gpsTracks
}
// 1. Sync Yacht Payload
if (yacht) {
@@ -300,7 +335,21 @@ async function pullChanges(logbookId: string): Promise<boolean> {
}
}
// 3. Sync Crew List Payloads
// 2b. Sync Logbook Crew Selection
if (logbookCrewSelection) {
const local = await db.logbookCrewSelections.get(logbookId)
if (!local || isNewer(logbookCrewSelection.updatedAt, local.updatedAt)) {
await db.logbookCrewSelections.put({
logbookId,
encryptedData: logbookCrewSelection.encryptedData,
iv: logbookCrewSelection.iv,
tag: logbookCrewSelection.tag,
updatedAt: logbookCrewSelection.updatedAt
})
}
}
// 3. Sync Crew List Payloads (legacy)
const serverCrewMap = new Map<string, any>()
if (crews && Array.isArray(crews)) {
for (const c of crews) {
@@ -476,6 +525,8 @@ export async function syncAllLogbooks(): Promise<void> {
syncAllInFlight++
recomputeSyncingState()
try {
await syncPersonPool()
// 1. Fetch latest logbook lists first (synchronizes db.logbooks index)
const logbooks = await db.logbooks.toArray()
@@ -525,3 +576,43 @@ export function stopBackgroundSync() {
syncIntervalId = null
}
}
/** Accept server version: pull latest and drop the conflicting queue item. */
export async function resolveSyncConflictUseServer(conflict: SyncConflict): Promise<void> {
if (conflict.queueItemId !== undefined) {
await db.syncQueue.delete(conflict.queueItemId)
} else {
const pending = await db.syncQueue
.where({ logbookId: conflict.logbookId })
.filter(
(item) => item.payloadId === conflict.payloadId && item.type === conflict.type
)
.toArray()
const ids = pending.map((p) => p.id).filter((id): id is number => id !== undefined)
if (ids.length > 0) await db.syncQueue.bulkDelete(ids)
}
clearSyncConflict(conflict.logbookId, conflict.payloadId, conflict.type)
await pullChanges(conflict.logbookId)
}
/** Keep local version: bump queue timestamp and retry push. */
export async function resolveSyncConflictKeepLocal(conflict: SyncConflict): Promise<void> {
const bump = new Date(Date.now() + 1000).toISOString()
if (conflict.queueItemId !== undefined) {
await db.syncQueue.update(conflict.queueItemId, { updatedAt: bump })
} else {
const pending = await db.syncQueue
.where({ logbookId: conflict.logbookId })
.filter(
(item) => item.payloadId === conflict.payloadId && item.type === conflict.type
)
.toArray()
for (const item of pending) {
if (item.id !== undefined) {
await db.syncQueue.update(item.id, { updatedAt: bump })
}
}
}
clearSyncConflict(conflict.logbookId, conflict.payloadId, conflict.type)
await flushPushQueue(conflict.logbookId)
}
+48
View File
@@ -0,0 +1,48 @@
export interface SyncConflict {
logbookId: string
payloadId: string
type: string
reason: string
queueItemId?: number
detectedAt: string
}
const conflicts = new Map<string, SyncConflict>()
const listeners = new Set<() => void>()
function conflictKey(logbookId: string, payloadId: string, type: string): string {
return `${logbookId}:${type}:${payloadId}`
}
export function getSyncConflicts(logbookId?: string): SyncConflict[] {
const all = Array.from(conflicts.values())
if (!logbookId) return all
return all.filter((c) => c.logbookId === logbookId)
}
export function hasSyncConflicts(logbookId?: string): boolean {
return getSyncConflicts(logbookId).length > 0
}
export function reportSyncConflict(conflict: Omit<SyncConflict, 'detectedAt'>): void {
const key = conflictKey(conflict.logbookId, conflict.payloadId, conflict.type)
conflicts.set(key, { ...conflict, detectedAt: new Date().toISOString() })
listeners.forEach((l) => l())
}
export function clearSyncConflict(logbookId: string, payloadId: string, type: string): void {
conflicts.delete(conflictKey(logbookId, payloadId, type))
listeners.forEach((l) => l())
}
export function clearSyncConflictsForLogbook(logbookId: string): void {
for (const key of conflicts.keys()) {
if (key.startsWith(`${logbookId}:`)) conflicts.delete(key)
}
listeners.forEach((l) => l())
}
export function subscribeSyncConflicts(listener: () => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
+61
View File
@@ -0,0 +1,61 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { PlausibleEvents } from './analytics.js'
const apiFetch = vi.fn()
const trackPlausibleEvent = vi.fn()
vi.mock('./api.js', () => ({ apiFetch }))
vi.mock('./analytics.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./analytics.js')>()
return {
...actual,
trackPlausibleEvent: (...args: unknown[]) => trackPlausibleEvent(...args)
}
})
vi.mock('./userPreferences.js', () => ({
getOwmApiKeyForActiveUser: () => ''
}))
describe('fetchOpenWeatherCurrent', () => {
beforeEach(() => {
apiFetch.mockReset()
trackPlausibleEvent.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('tracks OWM Weather Fetched on success when analyticsSource is set', async () => {
apiFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ coord: { lat: 54, lon: 10 }, main: { temp: 20 } })
})
const { fetchOpenWeatherCurrent } = await import('./weather.js')
await fetchOpenWeatherCurrent(
{ lat: '54.0', lon: '10.0' },
{ analyticsSource: 'live_log' }
)
expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.OWM_WEATHER_FETCHED, {
source: 'live_log'
})
})
it('does not track when the API request fails', async () => {
apiFetch.mockResolvedValue({
ok: false,
status: 500,
json: async () => ({ error: 'fail' })
})
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
await expect(
fetchOpenWeatherCurrent({ lat: '54', lon: '10' }, { analyticsSource: 'entry_editor' })
).rejects.toBeInstanceOf(WeatherApiError)
expect(trackPlausibleEvent).not.toHaveBeenCalled()
})
})
+37 -6
View File
@@ -1,5 +1,10 @@
import { apiFetch } from './api.js'
import { getOwmApiKeyForActiveUser } from './userPreferences.js'
import {
type OwmAnalyticsSource,
PlausibleEvents,
trackPlausibleEvent
} from './analytics.js'
export class WeatherApiError extends Error {
code: 'NO_KEY' | 'REQUEST_FAILED'
@@ -11,11 +16,16 @@ export class WeatherApiError extends Error {
}
}
export async function fetchOpenWeatherCurrent(params: {
lat?: string
lon?: string
q?: string
}): Promise<Record<string, unknown>> {
const OWM_FETCH_TIMEOUT_MS = 20_000
export async function fetchOpenWeatherCurrent(
params: {
lat?: string
lon?: string
q?: string
},
options?: { analyticsSource: OwmAnalyticsSource }
): Promise<Record<string, unknown>> {
const searchParams = new URLSearchParams()
if (params.lat && params.lon) {
@@ -31,7 +41,22 @@ export async function fetchOpenWeatherCurrent(params: {
const headers: Record<string, string> = {}
if (userKey) headers['X-OWM-Api-Key'] = userKey
const res = await apiFetch(`/api/weather/current?${searchParams.toString()}`, { headers })
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), OWM_FETCH_TIMEOUT_MS)
let res: Response
try {
res = await apiFetch(`/api/weather/current?${searchParams.toString()}`, {
headers,
signal: controller.signal
})
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new WeatherApiError('Weather request timed out')
}
throw err
} finally {
window.clearTimeout(timeoutId)
}
if (res.status === 503) {
throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY')
@@ -42,5 +67,11 @@ export async function fetchOpenWeatherCurrent(params: {
throw new WeatherApiError('Weather API rejected the request')
}
if (options?.analyticsSource) {
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, {
source: options.analyticsSource
})
}
return data
}
+61
View File
@@ -0,0 +1,61 @@
export type PersonRole = 'skipper' | 'crew'
export interface PersonData {
name: string
address: string
birthDate: string
phone: string
nationality: string
passportNumber: string
bloodType: string
allergies: string
diseases: string
role: PersonRole
photo?: string | null
}
export interface PersonSnapshot {
id: string
role: PersonRole
name: string
address: string
birthDate: string
phone: string
nationality: string
passportNumber: string
bloodType: string
allergies: string
diseases: string
photo?: string | null
}
export interface LogbookCrewSelectionData {
activeSkipperId: string | null
activeCrewIds: string[]
/** Denormalized for collaborators / offline display without account pool access */
snapshotsById: Record<string, PersonSnapshot>
}
export interface EntryCrewFields {
selectedSkipperId: string | null
selectedCrewIds: string[]
crewSnapshotsById: Record<string, PersonSnapshot>
}
export const MAX_POOL_CREW_MEMBERS = 12
export function emptyLogbookCrewSelection(): LogbookCrewSelectionData {
return {
activeSkipperId: null,
activeCrewIds: [],
snapshotsById: {}
}
}
export function emptyEntryCrewFields(): EntryCrewFields {
return {
selectedSkipperId: null,
selectedCrewIds: [],
crewSnapshotsById: {}
}
}
@@ -0,0 +1,33 @@
import { describe, expect, it, vi } from 'vitest'
import { captureVideoFrame, preferNativeCameraPicker } from './captureVideoFrame.js'
describe('preferNativeCameraPicker', () => {
it('returns true on Android user agents', () => {
vi.stubGlobal('navigator', { ...navigator, userAgent: 'Mozilla/5.0 (Linux; Android 14)' })
expect(preferNativeCameraPicker()).toBe(true)
vi.unstubAllGlobals()
})
it('returns false on desktop without touch', () => {
vi.stubGlobal('navigator', {
...navigator,
userAgent: 'Mozilla/5.0 (Windows NT 10.0)',
maxTouchPoints: 0
})
vi.stubGlobal('matchMedia', () => ({
matches: false,
addEventListener: () => {},
removeEventListener: () => {}
}))
Object.defineProperty(window, 'ontouchstart', { value: undefined, configurable: true })
expect(preferNativeCameraPicker()).toBe(false)
vi.unstubAllGlobals()
})
})
describe('captureVideoFrame', () => {
it('throws when video dimensions are zero', async () => {
const video = { videoWidth: 0, videoHeight: 0 } as HTMLVideoElement
await expect(captureVideoFrame(video)).rejects.toThrow('video_frame_not_ready')
})
})
+59
View File
@@ -0,0 +1,59 @@
/** Capture current video frame as JPEG blob (with Android-safe fallbacks). */
export async function captureVideoFrame(video: HTMLVideoElement, quality = 0.92): Promise<Blob> {
const width = video.videoWidth
const height = video.videoHeight
if (!width || !height) {
throw new Error('video_frame_not_ready')
}
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('canvas_context_unavailable')
}
ctx.drawImage(video, 0, 0, width, height)
const blob = await canvasToJpegBlob(canvas, quality)
if (blob) return blob
const dataUrl = canvas.toDataURL('image/jpeg', quality)
const response = await fetch(dataUrl)
const fallback = await response.blob()
if (!fallback.size) {
throw new Error('capture_encode_failed')
}
return fallback
}
function canvasToJpegBlob(canvas: HTMLCanvasElement, quality: number): Promise<Blob | null> {
return new Promise((resolve) => {
let settled = false
const finish = (blob: Blob | null) => {
if (settled) return
settled = true
window.clearTimeout(timer)
resolve(blob)
}
const timer = window.setTimeout(() => finish(null), 3000)
try {
canvas.toBlob((blob) => finish(blob), 'image/jpeg', quality)
} catch {
finish(null)
}
})
}
/** Mobile: native camera via file input is more reliable than getUserMedia + canvas. */
export function preferNativeCameraPicker(): boolean {
if (typeof window === 'undefined') return false
const ua = navigator.userAgent
if (/Android|iPhone|iPad|iPod/i.test(ua)) return true
const touch = 'ontouchstart' in window || navigator.maxTouchPoints > 0
const coarse = window.matchMedia('(pointer: coarse)').matches
const narrow = window.matchMedia('(max-width: 768px)').matches
return touch && (coarse || narrow)
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { crc32Hex, nmeaFileCrc32, normalizeNmeaTextForCrc } from './crc32.js'
describe('crc32', () => {
it('hashes known test vectors', () => {
expect(crc32Hex('')).toBe('00000000')
expect(crc32Hex('123456789')).toBe('CBF43926')
})
it('normalizes line endings before hashing NMEA content', () => {
const a = nmeaFileCrc32('$GPRMC,123519,A\r\n$GPGGA,123519\r\n')
const b = nmeaFileCrc32('$GPRMC,123519,A\n$GPGGA,123519\n')
expect(a).toBe(b)
expect(normalizeNmeaTextForCrc('a\r\nb\r')).toBe('a\nb')
})
})
+30
View File
@@ -0,0 +1,30 @@
/** Normalize NMEA text so identical content hashes the same across platforms. */
export function normalizeNmeaTextForCrc(text: string): string {
return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trimEnd()
}
const CRC32_TABLE = (() => {
const table = new Uint32Array(256)
for (let i = 0; i < 256; i++) {
let c = i
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
}
table[i] = c >>> 0
}
return table
})()
/** CRC-32 (IEEE / Ethernet polynomial), uppercase 8-char hex. */
export function crc32Hex(text: string): string {
const bytes = new TextEncoder().encode(text)
let crc = 0xffffffff
for (const byte of bytes) {
crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8)
}
return ((crc ^ 0xffffffff) >>> 0).toString(16).toUpperCase().padStart(8, '0')
}
export function nmeaFileCrc32(text: string): string {
return crc32Hex(normalizeNmeaTextForCrc(text))
}
+10
View File
@@ -0,0 +1,10 @@
/** Map unknown errors to a user-facing message (i18n key or fallback). */
export function getErrorMessage(err: unknown, fallback: string): string {
if (err instanceof Error && err.message.trim()) {
return err.message
}
if (typeof err === 'string' && err.trim()) {
return err
}
return fallback
}
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest'
import {
isMotorRunningFromEvents,
LIVE_EVENT_CODES,
liveCommentRemark,
liveSailsRemark,
liveSogRemark,
parseLiveCommentRemark,
livePhotoRemark,
parseLiveSailsRemark
} from './liveEventCodes.js'
import { formatEventSummary } from './formatEventSummary.js'
import { normalizeLogEvent } from './logEntryPayload.js'
const t = (key: string, opts?: Record<string, unknown>) => {
const map: Record<string, string> = {
'logs.live_motor_start': 'Motor Start',
'logs.live_motor_stop': 'Motor Stop',
'logs.live_cast_off': 'Cast off',
'logs.live_moor': 'Moor',
'logs.live_sails': `Sails: ${opts?.sails ?? ''}`,
'logs.live_fix': 'Fix',
'logs.live_fix_coords': `Fix ${opts?.lat}, ${opts?.lng}`,
'logs.live_event_generic': 'Event',
'logs.live_temp_entry': `Temperature ${opts?.temp} °C`,
'logs.live_pressure_entry': `Pressure ${opts?.value} hPa`,
'logs.live_wind_entry': `Wind ${opts?.value}`,
'logs.live_photo_entry': `Photo: ${opts?.caption}`,
'logs.live_photo_entry_plain': 'Photo captured',
'logs.live_course_entry': `Course ${opts?.course}`,
'logs.live_sog_entry': `SOG ${opts?.speed} kn`,
'logs.live_stw_entry': `STW ${opts?.speed} kn`,
'logs.event_mgk': 'Course',
'logs.event_wind_pressure': 'Pressure'
}
return map[key] ?? key
}
describe('liveEventCodes', () => {
it('derives motor running from last motor event', () => {
const events = [
{ remarks: LIVE_EVENT_CODES.MOTOR_START },
{ remarks: LIVE_EVENT_CODES.MOTOR_STOP },
{ remarks: LIVE_EVENT_CODES.MOTOR_START }
]
expect(isMotorRunningFromEvents(events)).toBe(true)
})
it('returns false when last motor event is stop', () => {
const events = [
{ remarks: LIVE_EVENT_CODES.MOTOR_START },
{ remarks: LIVE_EVENT_CODES.MOTOR_STOP }
]
expect(isMotorRunningFromEvents(events)).toBe(false)
})
it('parses sail and comment remarks', () => {
expect(parseLiveSailsRemark(liveSailsRemark('Main + Genoa'))).toBe('Main + Genoa')
expect(parseLiveCommentRemark(liveCommentRemark('Wind dreht'))).toBe('Wind dreht')
})
})
describe('formatEventSummary', () => {
it('formats live motor start', () => {
const event = normalizeLogEvent({ time: '08:10', remarks: LIVE_EVENT_CODES.MOTOR_START })
expect(formatEventSummary(event, t)).toBe('Motor Start')
})
it('formats sails remark', () => {
const event = normalizeLogEvent({
time: '08:20',
remarks: liveSailsRemark('Main + Genoa'),
sailsOrMotor: 'Main + Genoa'
})
expect(formatEventSummary(event, t)).toBe('Sails: Main + Genoa')
})
it('formats fix with coordinates', () => {
const event = normalizeLogEvent({
time: '09:00',
remarks: LIVE_EVENT_CODES.FIX,
gpsLat: '54.323000',
gpsLng: '10.145000'
})
expect(formatEventSummary(event, t)).toBe('Fix 54.323000, 10.145000')
})
it('formats pressure entry', () => {
const event = normalizeLogEvent({
time: '09:00',
remarks: LIVE_EVENT_CODES.PRESSURE,
windPressure: '1013'
})
expect(formatEventSummary(event, t)).toBe('Pressure 1013 hPa')
})
it('formats SOG entry', () => {
const event = normalizeLogEvent({
time: '10:15',
remarks: liveSogRemark('5.2')
})
expect(formatEventSummary(event, t)).toBe('SOG 5.2 kn')
})
it('formats STW entry', () => {
const event = normalizeLogEvent({
time: '10:20',
remarks: '__live:stw:4.8'
})
expect(formatEventSummary(event, t)).toBe('STW 4.8 kn')
})
it('formats photo entry', () => {
const plain = normalizeLogEvent({ time: '11:00', remarks: livePhotoRemark() })
expect(formatEventSummary(plain, t)).toBe('Photo captured')
const captioned = normalizeLogEvent({
time: '11:05',
remarks: livePhotoRemark('Mastbruch')
})
expect(formatEventSummary(captioned, t)).toBe('Photo: Mastbruch')
})
})
+100
View File
@@ -0,0 +1,100 @@
import type { TFunction } from 'i18next'
import type { LogEventPayload } from './logEntryPayload.js'
import {
LIVE_EVENT_CODES,
parseLiveCommentRemark,
parseLiveFuelRemark,
parseLivePhotoRemark,
parseLivePrecipRemark,
parseLiveSailsRemark,
parseLiveSogRemark,
parseLiveStwRemark,
parseLiveTempRemark,
parseLiveWaterRemark
} from './liveEventCodes.js'
export function formatEventSummary(event: LogEventPayload, t: TFunction): string {
const code = event.remarks.trim()
if (code === LIVE_EVENT_CODES.MOTOR_START) return t('logs.live_motor_start')
if (code === LIVE_EVENT_CODES.MOTOR_STOP) return t('logs.live_motor_stop')
if (code === LIVE_EVENT_CODES.CAST_OFF) return t('logs.live_cast_off')
if (code === LIVE_EVENT_CODES.MOOR) return t('logs.live_moor')
const sails = parseLiveSailsRemark(code)
if (sails) return t('logs.live_sails', { sails })
const comment = parseLiveCommentRemark(code)
if (comment) return comment
const photo = parseLivePhotoRemark(code)
if (photo !== null) {
return photo
? t('logs.live_photo_entry', { caption: photo })
: t('logs.live_photo_entry_plain')
}
const temp = parseLiveTempRemark(code)
if (temp) return t('logs.live_temp_entry', { temp })
const precip = parseLivePrecipRemark(code)
if (precip) return t('logs.live_precip_entry', { value: precip })
const fuel = parseLiveFuelRemark(code)
if (fuel) return t('logs.live_fuel_entry', { liters: fuel })
const water = parseLiveWaterRemark(code)
if (water) return t('logs.live_water_entry', { liters: water })
const sog = parseLiveSogRemark(code)
if (sog) return t('logs.live_sog_entry', { speed: sog })
const stw = parseLiveStwRemark(code)
if (stw) return t('logs.live_stw_entry', { speed: stw })
if (code === LIVE_EVENT_CODES.FIX || code === LIVE_EVENT_CODES.AUTO_POSITION) {
if (event.gpsLat && event.gpsLng) {
const label = code === LIVE_EVENT_CODES.AUTO_POSITION
? t('logs.live_auto_position')
: t('logs.live_fix')
return `${label} ${event.gpsLat}, ${event.gpsLng}`
}
return code === LIVE_EVENT_CODES.AUTO_POSITION
? t('logs.live_auto_position')
: t('logs.live_fix')
}
if (code === LIVE_EVENT_CODES.COURSE && event.mgk) {
return t('logs.live_course_entry', { course: event.mgk })
}
if (code === LIVE_EVENT_CODES.WIND) {
const wind = [event.windDirection, event.windStrength].filter(Boolean).join(' ')
return wind ? t('logs.live_wind_entry', { value: wind }) : t('logs.live_wind_btn')
}
if (code === LIVE_EVENT_CODES.PRESSURE && event.windPressure) {
return t('logs.live_pressure_entry', { value: event.windPressure })
}
if (code === LIVE_EVENT_CODES.SEA_STATE && event.seaState) {
return t('logs.live_sea_state_entry', { value: event.seaState })
}
if (code && !code.startsWith('__live:')) {
return code
}
const parts: string[] = []
if (event.sailsOrMotor) parts.push(event.sailsOrMotor)
if (event.mgk) parts.push(`${t('logs.event_mgk')} ${event.mgk}`)
if (event.windDirection || event.windStrength) {
parts.push([event.windDirection, event.windStrength].filter(Boolean).join(' '))
}
if (event.windPressure) parts.push(`${t('logs.event_wind_pressure')}: ${event.windPressure}`)
if (event.gpsLat && event.gpsLng) {
parts.push(`${event.gpsLat}, ${event.gpsLng}`)
}
return parts.join(' · ') || t('logs.live_event_generic')
}
+80
View File
@@ -0,0 +1,80 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
getCurrentPosition,
normalizeGpsCoordinates,
parseGpsCoordinate,
queryGeolocationPermission
} from './geolocation.js'
describe('geolocation helpers', () => {
it('parses coordinates with comma decimals', () => {
expect(parseGpsCoordinate('54,123')).toBeCloseTo(54.123)
})
it('normalizes valid lat/lng', () => {
expect(normalizeGpsCoordinates('54.1', '10.2')).toEqual({
lat: '54.100000',
lng: '10.200000'
})
})
it('rejects out-of-range values', () => {
expect(normalizeGpsCoordinates('91', '0')).toBeNull()
expect(normalizeGpsCoordinates('0', '181')).toBeNull()
})
it('reports unsupported when geolocation API is missing', async () => {
vi.stubGlobal('navigator', { geolocation: undefined })
await expect(getCurrentPosition({ timeoutMs: 100 })).rejects.toThrow('geolocation_unavailable')
})
it('rejects when the browser never calls back (watchdog)', async () => {
vi.useFakeTimers()
vi.stubGlobal('navigator', {
geolocation: {
getCurrentPosition: () => {
// Simulate a hung desktop location service.
}
}
})
const promise = getCurrentPosition({ timeoutMs: 50, enableHighAccuracy: false })
const assertion = expect(promise).rejects.toThrow('geolocation_timeout')
await vi.advanceTimersByTimeAsync(900)
await assertion
vi.useRealTimers()
})
it('resolves coordinates from getCurrentPosition', async () => {
vi.stubGlobal('navigator', {
geolocation: {
getCurrentPosition: (success: PositionCallback) => {
success({
coords: { latitude: 59.91, longitude: 10.75, speed: 2.5 }
} as GeolocationPosition)
}
}
})
await expect(getCurrentPosition({ timeoutMs: 1000, enableHighAccuracy: false })).resolves.toEqual({
lat: '59.910000',
lng: '10.750000',
speedKn: 4.9
})
})
it('reads permission state when supported', async () => {
vi.stubGlobal('navigator', {
geolocation: {},
permissions: {
query: vi.fn().mockResolvedValue({ state: 'denied' })
}
})
await expect(queryGeolocationPermission()).resolves.toBe('denied')
})
})
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
})
+112
View File
@@ -0,0 +1,112 @@
const MPS_TO_KNOTS = 1.9438444924406
/** Extra ms beyond the native timeout so hung browsers still reject. */
const TIMEOUT_GRACE_MS = 750
export interface GeoCoordinates {
lat: string
lng: string
/** SOG from GPS when available (kn), otherwise null. */
speedKn: number | null
}
export type GeolocationPermissionState = PermissionState | 'unsupported'
export interface GetPositionOptions {
timeoutMs?: number
/** Manual fixes may use high accuracy; background auto-position should not. */
enableHighAccuracy?: boolean
maximumAge?: number
}
export function parseGpsCoordinate(value: string): number | null {
const trimmed = value.trim()
if (!trimmed) return null
const n = parseFloat(trimmed.replace(',', '.'))
return Number.isFinite(n) ? n : null
}
/** Validates lat/lng and returns normalized strings for storage, or null. */
export function normalizeGpsCoordinates(
lat: string,
lng: string
): { lat: string; lng: string } | null {
const latN = parseGpsCoordinate(lat)
const lngN = parseGpsCoordinate(lng)
if (latN == null || lngN == null) return null
if (latN < -90 || latN > 90 || lngN < -180 || lngN > 180) return null
return { lat: latN.toFixed(6), lng: lngN.toFixed(6) }
}
export async function queryGeolocationPermission(): Promise<GeolocationPermissionState> {
if (!navigator.geolocation) return 'unsupported'
if (!navigator.permissions?.query) return 'prompt'
try {
const status = await navigator.permissions.query({ name: 'geolocation' })
return status.state
} catch {
return 'prompt'
}
}
function normalizeGetPositionOptions(
options: number | GetPositionOptions | undefined
): Required<GetPositionOptions> {
const opts = typeof options === 'number' ? { timeoutMs: options } : (options ?? {})
const enableHighAccuracy = opts.enableHighAccuracy ?? true
return {
timeoutMs: opts.timeoutMs ?? 15000,
enableHighAccuracy,
maximumAge: opts.maximumAge ?? (enableHighAccuracy ? 0 : 120_000)
}
}
function positionFromGeolocationPosition(pos: GeolocationPosition): GeoCoordinates {
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
: null
return {
lat: pos.coords.latitude.toFixed(6),
lng: pos.coords.longitude.toFixed(6),
speedKn
}
}
/**
* Resolves with coordinates or rejects. Uses both the native timeout and an outer
* watchdog so desktop browsers without GPS cannot hang indefinitely.
*/
export function getCurrentPosition(
options?: number | GetPositionOptions
): Promise<GeoCoordinates> {
const { timeoutMs, enableHighAccuracy, maximumAge } = normalizeGetPositionOptions(options)
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('geolocation_unavailable'))
return
}
let settled = false
const finish = (fn: () => void) => {
if (settled) return
settled = true
window.clearTimeout(watchdog)
fn()
}
const watchdog = window.setTimeout(() => {
finish(() => reject(new Error('geolocation_timeout')))
}, timeoutMs + TIMEOUT_GRACE_MS)
navigator.geolocation.getCurrentPosition(
(pos) => {
finish(() => resolve(positionFromGeolocationPosition(pos)))
},
(err) => {
finish(() => reject(err))
},
{ enableHighAccuracy, timeout: timeoutMs, maximumAge }
)
})
}
+46
View File
@@ -0,0 +1,46 @@
export const PHOTO_MAX_WIDTH = 1280
export const PHOTO_MAX_HEIGHT = 720
export const PHOTO_JPEG_QUALITY = 0.7
function loadImageFromDataUrl(dataUrl: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = () => reject(new Error('image_load_failed'))
img.src = dataUrl
})
}
export function compressImageElement(img: HTMLImageElement): string {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get canvas context')
let width = img.width
let height = img.height
if (width > PHOTO_MAX_WIDTH || height > PHOTO_MAX_HEIGHT) {
const ratio = Math.min(PHOTO_MAX_WIDTH / width, PHOTO_MAX_HEIGHT / height)
width = Math.round(width * ratio)
height = Math.round(height * ratio)
}
canvas.width = width
canvas.height = height
ctx.drawImage(img, 0, 0, width, height)
return canvas.toDataURL('image/jpeg', PHOTO_JPEG_QUALITY)
}
export async function blobToCompressedJpegDataUrl(blob: Blob): Promise<string> {
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => reject(new Error('image_read_failed'))
reader.readAsDataURL(blob)
})
const img = await loadImageFromDataUrl(dataUrl)
return compressImageElement(img)
}
export async function fileToCompressedJpegDataUrl(file: Blob): Promise<string> {
return blobToCompressedJpegDataUrl(file)
}
+186
View File
@@ -0,0 +1,186 @@
/** Machine-readable live-log markers stored in event.remarks (locale-independent). */
export const LIVE_EVENT_CODES = {
MOTOR_START: '__live:motor_start',
MOTOR_STOP: '__live:motor_stop',
CAST_OFF: '__live:cast_off',
MOOR: '__live:moor',
FIX: '__live:fix',
AUTO_POSITION: '__live:auto_position',
COURSE: '__live:course',
WIND: '__live:wind',
PRESSURE: '__live:pressure',
SEA_STATE: '__live:sea_state'
} as const
export type LiveEventCode = (typeof LIVE_EVENT_CODES)[keyof typeof LIVE_EVENT_CODES]
export function liveSailsRemark(sails: string): string {
return `__live:sails:${sails}`
}
export function liveCommentRemark(text: string): string {
return `__live:comment:${text}`
}
export function liveTempRemark(tempC: string): string {
return `__live:temp:${tempC}`
}
export function livePrecipRemark(text: string): string {
return `__live:precip:${text}`
}
export function liveFuelRemark(liters: string): string {
return `__live:fuel:${liters}`
}
export function liveWaterRemark(liters: string): string {
return `__live:water:${liters}`
}
export function livePhotoRemark(caption?: string): string {
const text = caption?.trim()
return text ? `__live:photo:${text}` : '__live:photo'
}
export function parseLivePhotoRemark(remarks: string): string | null {
if (remarks === '__live:photo') return ''
const prefix = '__live:photo:'
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
}
export function liveSogRemark(speedKn: string): string {
return `__live:sog:${speedKn}`
}
export function liveStwRemark(speedKn: string): string {
return `__live:stw:${speedKn}`
}
export function parseLiveSailsRemark(remarks: string): string | null {
const prefix = '__live:sails:'
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
}
export function parseLiveCommentRemark(remarks: string): string | null {
const prefix = '__live:comment:'
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
}
export function parseLiveTempRemark(remarks: string): string | null {
const prefix = '__live:temp:'
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
}
export function parseLivePrecipRemark(remarks: string): string | null {
const prefix = '__live:precip:'
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
}
export function parseLiveFuelRemark(remarks: string): string | null {
const prefix = '__live:fuel:'
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
}
export function parseLiveWaterRemark(remarks: string): string | null {
const prefix = '__live:water:'
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
}
export function parseLiveSogRemark(remarks: string): string | null {
const prefix = '__live:sog:'
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
}
export function parseLiveStwRemark(remarks: string): string | null {
const prefix = '__live:stw:'
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
}
/** Derive motor running state from event history (survives reload). */
export function isMotorRunningFromEvents(
events: Array<{ remarks: string }>,
motorStartCode: string = LIVE_EVENT_CODES.MOTOR_START,
motorStopCode: string = LIVE_EVENT_CODES.MOTOR_STOP
): boolean {
for (let i = events.length - 1; i >= 0; i--) {
const code = events[i].remarks.trim()
if (code === motorStartCode) return true
if (code === motorStopCode) return false
}
return false
}
export function eventTimestampMs(date: string, time: string): number | null {
const normalized = time.trim().match(/^(\d{1,2}):(\d{2})/)
if (!normalized || !date) return null
const hours = parseInt(normalized[1], 10)
const minutes = parseInt(normalized[2], 10)
if (hours > 23 || minutes > 59) return null
const parsed = new Date(`${date}T${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`)
return Number.isNaN(parsed.getTime()) ? null : parsed.getTime()
}
export function getLastAutoPositionMs(
events: Array<{ remarks: string; time: string }>,
entryDate: string
): number | null {
for (let i = events.length - 1; i >= 0; i--) {
if (events[i].remarks.trim() !== LIVE_EVENT_CODES.AUTO_POSITION) continue
return eventTimestampMs(entryDate, events[i].time)
}
return null
}
/** Max age of a logged GPS fix for OpenWeatherMap lookups in live log. */
export const LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS = 6 * 60 * 60 * 1000
export type LiveLogPositionSource = 'fix' | 'auto_position'
export interface LiveLogPositionFix {
lat: string
lng: string
loggedAtMs: number
source: LiveLogPositionSource
}
function isPositionEventCode(code: string): boolean {
return code === LIVE_EVENT_CODES.FIX || code === LIVE_EVENT_CODES.AUTO_POSITION
}
/** Latest FIX or auto-position event with GPS coordinates (any age). */
export function getLatestPositionFix(
events: Array<{ remarks: string; time: string; gpsLat?: string; gpsLng?: string }>,
entryDate: string
): LiveLogPositionFix | null {
for (let i = events.length - 1; i >= 0; i--) {
const event = events[i]
const code = event.remarks.trim()
if (!isPositionEventCode(code)) continue
const lat = event.gpsLat?.trim()
const lng = event.gpsLng?.trim()
if (!lat || !lng) continue
const loggedAtMs = eventTimestampMs(entryDate, event.time)
if (loggedAtMs == null) continue
return {
lat,
lng,
loggedAtMs,
source: code === LIVE_EVENT_CODES.FIX ? 'fix' : 'auto_position'
}
}
return null
}
/** GPS fix for weather if logged within `maxAgeMs` (default 6 h). */
export function getLastPositionFixWithin(
events: Array<{ remarks: string; time: string; gpsLat?: string; gpsLng?: string }>,
entryDate: string,
maxAgeMs: number = LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS,
nowMs: number = Date.now()
): LiveLogPositionFix | null {
const latest = getLatestPositionFix(events, entryDate)
if (!latest) return null
if (nowMs - latest.loggedAtMs > maxAgeMs) return null
return latest
}
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import {
getLastPositionFixWithin,
getLatestPositionFix,
LIVE_EVENT_CODES,
LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS
} from './liveEventCodes.js'
const entryDate = '2026-06-01'
describe('live log position fix', () => {
it('returns latest fix with coordinates', () => {
const events = [
{ remarks: LIVE_EVENT_CODES.FIX, time: '08:00', gpsLat: '54.1', gpsLng: '10.2' },
{ remarks: LIVE_EVENT_CODES.FIX, time: '12:30', gpsLat: '54.2', gpsLng: '10.3' }
]
const fix = getLatestPositionFix(events, entryDate)
expect(fix?.lat).toBe('54.2')
expect(fix?.source).toBe('fix')
})
it('accepts auto-position with GPS', () => {
const events = [
{
remarks: LIVE_EVENT_CODES.AUTO_POSITION,
time: '14:00',
gpsLat: '55.0',
gpsLng: '11.0'
}
]
expect(getLatestPositionFix(events, entryDate)?.source).toBe('auto_position')
})
it('rejects fix older than max age for weather', () => {
const noon = new Date(`${entryDate}T12:00:00`).getTime()
const events = [
{ remarks: LIVE_EVENT_CODES.FIX, time: '05:00', gpsLat: '54.0', gpsLng: '10.0' }
]
expect(
getLastPositionFixWithin(events, entryDate, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, noon)
).toBeNull()
expect(getLatestPositionFix(events, entryDate)).not.toBeNull()
})
it('accepts fix within six hours', () => {
const noon = new Date(`${entryDate}T12:00:00`).getTime()
const events = [
{ remarks: LIVE_EVENT_CODES.FIX, time: '07:00', gpsLat: '54.0', gpsLng: '10.0' }
]
expect(
getLastPositionFixWithin(events, entryDate, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, noon)
).not.toBeNull()
})
})
+8
View File
@@ -2,6 +2,7 @@ import {
normalizeCourseAngleString,
normalizeWindDirectionString
} from './courseAngle.js'
import type { EntryCrewFields } from '../types/person.js'
export interface LogEventPayload {
time: string
@@ -150,6 +151,7 @@ export interface LogEntryPayloadInput {
trackSpeedAvgKn?: number
motorHours?: number
events: LogEventPayload[]
entryCrew?: EntryCrewFields
}
export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string, unknown> {
@@ -177,5 +179,11 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string
}
}
if (input.entryCrew) {
payload.selectedSkipperId = input.entryCrew.selectedSkipperId
payload.selectedCrewIds = [...input.entryCrew.selectedCrewIds]
payload.crewSnapshotsById = { ...input.entryCrew.crewSnapshotsById }
}
return payload
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import {
formatWindStrengthBeaufort,
mpsToBeaufort,
parseOwmCurrentWeather
} from './openWeatherMap.js'
describe('openWeatherMap', () => {
it('maps m/s to Beaufort', () => {
expect(mpsToBeaufort(0)).toBe(0)
expect(mpsToBeaufort(5)).toBe(3)
expect(mpsToBeaufort(15)).toBe(7)
expect(formatWindStrengthBeaufort(5)).toBe('3 Bft (5.0 m/s)')
})
it('parses OWM current weather payload', () => {
const parsed = parseOwmCurrentWeather({
wind: { speed: 8.5, deg: 225 },
main: { pressure: 1018, temp: 17.4 },
weather: [{ icon: '04d', description: 'Bedeckt' }]
})
expect(parsed.windDirection).toBe('SW')
expect(parsed.windStrength).toBe('5 Bft (8.5 m/s)')
expect(parsed.windPressure).toBe('1018')
expect(parsed.tempC).toBe('17.4')
expect(parsed.precipText).toBe('Bedeckt')
expect(parsed.weatherIcon).toBe('04d')
})
})
+68
View File
@@ -0,0 +1,68 @@
import { degreesToCardinal } from './courseAngle.js'
export interface ParsedOwmCurrent {
windDirection: string
windStrength: string
windPressure: string
tempC: string | null
precipText: string | null
weatherIcon: string | null
}
/** Beaufort scale from wind speed in m/s (OWM `wind.speed`). */
export function mpsToBeaufort(mps: number): number {
if (mps < 0.3) return 0
if (mps < 1.6) return 1
if (mps < 3.4) return 2
if (mps < 5.5) return 3
if (mps < 8.0) return 4
if (mps < 10.8) return 5
if (mps < 13.9) return 6
if (mps < 17.2) return 7
if (mps < 20.8) return 8
if (mps < 24.5) return 9
if (mps < 28.5) return 10
if (mps < 32.7) return 11
return 12
}
export function formatWindStrengthBeaufort(mps: number): string {
const bft = mpsToBeaufort(mps)
return `${bft} Bft (${mps.toFixed(1)} m/s)`
}
export function parseOwmCurrentWeather(data: Record<string, unknown>): ParsedOwmCurrent {
const wind = data.wind as { speed?: number; deg?: number } | undefined
const main = data.main as { pressure?: number; temp?: number } | undefined
const rain = data.rain as { '1h'?: number } | undefined
const weatherArr = data.weather as Array<{ icon?: string; description?: string }> | undefined
const mps = wind?.speed ?? 0
const windStrength = formatWindStrengthBeaufort(mps)
const windDirection = wind?.deg !== undefined ? degreesToCardinal(wind.deg) : ''
const windPressure = main?.pressure != null ? String(main.pressure) : ''
let tempC: string | null = null
if (main?.temp != null && Number.isFinite(main.temp)) {
tempC = Number(main.temp).toFixed(1)
}
let precipText: string | null = null
const firstWeather = weatherArr?.[0]
if (firstWeather?.description?.trim()) {
precipText = firstWeather.description.trim()
} else if (rain?.['1h'] != null && Number.isFinite(rain['1h'])) {
precipText = `${rain['1h']} mm/h`
}
const weatherIcon = firstWeather?.icon?.trim() ? firstWeather.icon.trim() : null
return {
windDirection,
windStrength,
windPressure,
tempC,
precipText,
weatherIcon
}
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import {
isPasskeyCompatibleHostname,
isPasskeyInvalidDomainError,
isWebAuthnUserAbortError,
localizeWebAuthnError,
toPasskeyCompatibleUrl
} from './passkeyHost.js'
describe('isPasskeyCompatibleHostname', () => {
it('accepts localhost and real domains', () => {
expect(isPasskeyCompatibleHostname('localhost')).toBe(true)
expect(isPasskeyCompatibleHostname('kapteins-daagbok.eu')).toBe(true)
})
it('rejects IP addresses', () => {
expect(isPasskeyCompatibleHostname('127.0.0.1')).toBe(false)
})
})
describe('toPasskeyCompatibleUrl', () => {
it('rewrites 127.0.0.1 to localhost', () => {
expect(toPasskeyCompatibleUrl('http://127.0.0.1:5173/demo?lng=de')).toBe(
'http://localhost:5173/demo?lng=de'
)
})
})
describe('isPasskeyInvalidDomainError', () => {
it('detects simplewebauthn browser message', () => {
expect(isPasskeyInvalidDomainError('127.0.0.1 is an invalid domain')).toBe(true)
expect(isPasskeyInvalidDomainError('User cancelled')).toBe(false)
})
})
describe('isWebAuthnUserAbortError', () => {
it('detects NotAllowedError and timeout messages', () => {
expect(isWebAuthnUserAbortError({ name: 'NotAllowedError', message: 'timed out' })).toBe(true)
expect(
isWebAuthnUserAbortError(
new Error('The operation either timed out or was not allowed.')
)
).toBe(true)
expect(isWebAuthnUserAbortError({ name: 'SecurityError', message: 'bad rp' })).toBe(false)
})
})
describe('localizeWebAuthnError', () => {
it('maps cancellation to a friendly message', () => {
expect(
localizeWebAuthnError('The operation either timed out or was not allowed.', {
invalidHost: 'host',
cancelled: 'cancelled'
})
).toBe('cancelled')
})
})
+69
View File
@@ -0,0 +1,69 @@
/**
* WebAuthn / Passkeys require a valid domain (see WHATWG valid domain).
* IP addresses such as 127.0.0.1 are rejected by browsers and @simplewebauthn/browser.
*/
export function isPasskeyCompatibleHostname(hostname: string): boolean {
return (
hostname === 'localhost' ||
/^((xn--[a-z0-9-]+|[a-z0-9]+(-[a-z0-9]+)*)\.)+([a-z]{2,}|xn--[a-z0-9-]+)$/i.test(hostname)
)
}
export function isPasskeyCompatibleLocation(loc: Location = window.location): boolean {
return isPasskeyCompatibleHostname(loc.hostname)
}
/** Same page on localhost — for dev links when opened via 127.0.0.1. */
export function toPasskeyCompatibleUrl(href: string): string {
const url = new URL(href)
if (url.hostname === '127.0.0.1' || url.hostname === '[::1]' || url.hostname === '::1') {
url.hostname = 'localhost'
}
return url.toString()
}
/**
* Redirect 127.0.0.1 / ::1 to localhost (dev). Returns true if navigation was started.
*/
export function redirectToPasskeyCompatibleHostIfNeeded(loc: Location = window.location): boolean {
if (isPasskeyCompatibleHostname(loc.hostname)) return false
const target = toPasskeyCompatibleUrl(loc.href)
if (target === loc.href) return false
window.location.replace(target)
return true
}
export function isPasskeyInvalidDomainError(message: string): boolean {
return /is an invalid domain$/i.test(message)
}
export function localizePasskeyHostError(message: string, invalidHostMessage: string): string {
return isPasskeyInvalidDomainError(message) ? invalidHostMessage : message
}
/** User dismissed or denied the platform passkey prompt (do not auto-retry WebAuthn). */
export function isWebAuthnUserAbortError(err: unknown): boolean {
if (!err || typeof err !== 'object') return false
const name = 'name' in err ? String((err as { name: string }).name) : ''
if (name === 'NotAllowedError' || name === 'AbortError') return true
const message = 'message' in err ? String((err as { message: string }).message) : String(err)
return /timed out|not allowed|cancel/i.test(message)
}
export function localizeWebAuthnError(
message: string,
messages: {
invalidHost: string
cancelled: string
invalidRpId?: string
}
): string {
if (isPasskeyInvalidDomainError(message)) return messages.invalidHost
if (/timed out|not allowed|cancel/i.test(message)) return messages.cancelled
if (/invalid for this domain/i.test(message) && messages.invalidRpId) {
return messages.invalidRpId
}
return message
}
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import type { PersonData } from '../types/person.js'
import {
legacyCrewRecordsToLogbookSelection,
pickActiveSkipperId
} from './personSnapshots.js'
function person(overrides: Partial<PersonData> & { role: PersonData['role'] }): PersonData {
return {
name: overrides.name ?? 'Test',
address: '',
birthDate: '',
phone: '',
nationality: '',
passportNumber: '',
bloodType: '',
allergies: '',
diseases: '',
role: overrides.role
}
}
describe('pickActiveSkipperId', () => {
it('returns null for empty list', () => {
expect(pickActiveSkipperId([])).toBeNull()
})
it('prefers canonical skipper payload id', () => {
expect(pickActiveSkipperId(['other-skipper', 'skipper', 'third'])).toBe('skipper')
})
it('keeps first skipper when canonical id is absent', () => {
expect(pickActiveSkipperId(['alpha', 'beta'])).toBe('alpha')
})
})
describe('legacyCrewRecordsToLogbookSelection', () => {
it('does not let a later skipper overwrite the active skipper', () => {
const selection = legacyCrewRecordsToLogbookSelection([
{ payloadId: 'skipper', data: person({ role: 'skipper', name: 'Primary' }) },
{ payloadId: 'co-skipper', data: person({ role: 'skipper', name: 'Secondary' }) },
{ payloadId: 'crew-1', data: person({ role: 'crew', name: 'Crew' }) }
])
expect(selection.activeSkipperId).toBe('skipper')
expect(selection.activeCrewIds).toEqual(['crew-1'])
expect(Object.keys(selection.snapshotsById)).toEqual(['skipper', 'co-skipper', 'crew-1'])
})
it('uses first skipper when canonical id is missing', () => {
const selection = legacyCrewRecordsToLogbookSelection([
{ payloadId: 'first-skip', data: person({ role: 'skipper', name: 'First' }) },
{ payloadId: 'second-skip', data: person({ role: 'skipper', name: 'Second' }) }
])
expect(selection.activeSkipperId).toBe('first-skip')
})
})
+112
View File
@@ -0,0 +1,112 @@
import type { LogbookCrewSelectionData, PersonData, PersonSnapshot } from '../types/person.js'
/** Prefer canonical legacy id `skipper`, otherwise keep the first skipper encountered. */
export function pickActiveSkipperId(skipperIds: readonly string[]): string | null {
if (skipperIds.length === 0) return null
return skipperIds.find((id) => id === 'skipper') ?? skipperIds[0]
}
export function isSkipperRecord(payloadId: string, data: PersonData): boolean {
return payloadId === 'skipper' || data.role === 'skipper'
}
/** Build logbook crew selection from legacy per-logbook crew records (read-only share / migration). */
export function legacyCrewRecordsToLogbookSelection(
crews: Array<{ payloadId: string; data: PersonData }>
): LogbookCrewSelectionData {
const snapshotsById: Record<string, PersonSnapshot> = {}
const skipperIds: string[] = []
const activeCrewIds: string[] = []
for (const c of crews) {
snapshotsById[c.payloadId] = personToSnapshot(c.payloadId, c.data)
if (isSkipperRecord(c.payloadId, c.data)) {
if (!skipperIds.includes(c.payloadId)) skipperIds.push(c.payloadId)
} else {
activeCrewIds.push(c.payloadId)
}
}
return {
activeSkipperId: pickActiveSkipperId(skipperIds),
activeCrewIds,
snapshotsById
}
}
export function personToSnapshot(id: string, data: PersonData): PersonSnapshot {
return {
id,
role: data.role,
name: data.name,
address: data.address,
birthDate: data.birthDate,
phone: data.phone,
nationality: data.nationality,
passportNumber: data.passportNumber,
bloodType: data.bloodType,
allergies: data.allergies,
diseases: data.diseases,
photo: data.photo ?? null
}
}
export function buildSnapshotsForSelection(
activeSkipperId: string | null,
activeCrewIds: string[],
pool: Map<string, PersonData>
): Record<string, PersonSnapshot> {
const snapshotsById: Record<string, PersonSnapshot> = {}
if (activeSkipperId) {
const skipper = pool.get(activeSkipperId)
if (skipper) snapshotsById[activeSkipperId] = personToSnapshot(activeSkipperId, skipper)
}
for (const crewId of activeCrewIds) {
const crew = pool.get(crewId)
if (crew) snapshotsById[crewId] = personToSnapshot(crewId, crew)
}
return snapshotsById
}
export function buildLogbookCrewSelection(
activeSkipperId: string | null,
activeCrewIds: string[],
pool: Map<string, PersonData>
): LogbookCrewSelectionData {
return {
activeSkipperId,
activeCrewIds: [...activeCrewIds],
snapshotsById: buildSnapshotsForSelection(activeSkipperId, activeCrewIds, pool)
}
}
export function entryCrewFromLogbookSelection(
selection: LogbookCrewSelectionData
): {
selectedSkipperId: string | null
selectedCrewIds: string[]
crewSnapshotsById: Record<string, PersonSnapshot>
} {
return {
selectedSkipperId: selection.activeSkipperId,
selectedCrewIds: [...selection.activeCrewIds],
crewSnapshotsById: { ...selection.snapshotsById }
}
}
export function entryCrewFromPreviousEntry(entry: Record<string, unknown>): {
selectedSkipperId: string | null
selectedCrewIds: string[]
crewSnapshotsById: Record<string, PersonSnapshot>
} {
const selectedSkipperId =
typeof entry.selectedSkipperId === 'string' ? entry.selectedSkipperId : null
const selectedCrewIds = Array.isArray(entry.selectedCrewIds)
? entry.selectedCrewIds.filter((id): id is string => typeof id === 'string')
: []
const crewSnapshotsById =
entry.crewSnapshotsById && typeof entry.crewSnapshotsById === 'object'
? (entry.crewSnapshotsById as Record<string, PersonSnapshot>)
: {}
return { selectedSkipperId, selectedCrewIds, crewSnapshotsById }
}
+39
View File
@@ -0,0 +1,39 @@
/** Resize and compress an image file to a JPEG data URL (max 800×600). */
export function resizeImageFile(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (event) => {
const img = new Image()
img.onload = () => {
try {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get canvas context')
let width = img.width
let height = img.height
const MAX_WIDTH = 800
const MAX_HEIGHT = 600
if (width > MAX_WIDTH || height > MAX_HEIGHT) {
const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height)
width = Math.round(width * ratio)
height = Math.round(height * ratio)
}
canvas.width = width
canvas.height = height
ctx.drawImage(img, 0, 0, width, height)
resolve(canvas.toDataURL('image/jpeg', 0.7))
} catch (err) {
reject(err)
}
}
img.onerror = () => reject(new Error('Invalid image file'))
img.src = event.target?.result as string
}
reader.onerror = () => reject(new Error('Failed to read file'))
reader.readAsDataURL(file)
})
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import {
dedupeSailNames,
isSailInSelection,
joinSailSelection,
splitSailSelection,
toggleSailInSelection
} from './sailSelection.js'
describe('toggleSailInSelection', () => {
it('adds a second sail without removing the first', () => {
const first = toggleSailInSelection([], 'Mainsail')
expect(first).toEqual(['Mainsail'])
const second = toggleSailInSelection(first, 'Genoa')
expect(second).toEqual(['Mainsail', 'Genoa'])
})
it('removes sail when toggled again', () => {
const selected = toggleSailInSelection(
toggleSailInSelection([], 'Mainsail'),
'Genoa'
)
expect(toggleSailInSelection(selected, 'Mainsail')).toEqual(['Genoa'])
})
it('matches case-insensitively', () => {
expect(toggleSailInSelection(['genua'], 'Genua')).toEqual([])
expect(isSailInSelection(['Großsegel'], 'großsegel')).toBe(true)
})
})
describe('joinSailSelection / splitSailSelection', () => {
it('round-trips multiple sails', () => {
const joined = joinSailSelection(['Großsegel', 'Genua'])
expect(joined).toBe('Großsegel + Genua')
expect(splitSailSelection(joined)).toEqual(['Großsegel', 'Genua'])
})
})
describe('dedupeSailNames', () => {
it('removes duplicate names', () => {
expect(dedupeSailNames(['Genua', 'genua', 'Fock'])).toEqual(['Genua', 'Fock'])
})
})
+42
View File
@@ -0,0 +1,42 @@
/** Toggle one sail label in a multi-select list (case-insensitive). */
export function toggleSailInSelection(selected: readonly string[], sail: string): string[] {
const normalized = sail.trim()
if (!normalized) return [...selected]
return selected.some((s) => s.toLowerCase() === normalized.toLowerCase())
? selected.filter((s) => s.toLowerCase() !== normalized.toLowerCase())
: [...selected, normalized]
}
export function isSailInSelection(selected: readonly string[], sail: string): boolean {
const normalized = sail.trim().toLowerCase()
if (!normalized) return false
return selected.some((s) => s.toLowerCase() === normalized)
}
/** Join selected sails for logbook `sailsOrMotor` (matches LogEntryEditor). */
export function joinSailSelection(selected: readonly string[]): string {
return selected.map((s) => s.trim()).filter(Boolean).join(' + ')
}
export function splitSailSelection(value: string): string[] {
return value
.split(/\s*(?:\+|\bplus\b|,)\s*/i)
.map((s) => s.trim())
.filter(Boolean)
}
/** Deduplicate sail names for picker UI (case-insensitive, keeps first spelling). */
export function dedupeSailNames(sails: readonly string[]): string[] {
const seen = new Set<string>()
const result: string[] = []
for (const sail of sails) {
const trimmed = sail.trim()
if (!trimmed) continue
const key = trimmed.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
result.push(trimmed)
}
return result
}
+17
View File
@@ -0,0 +1,17 @@
/** Request durable IndexedDB storage (important on iOS Safari). */
export async function requestPersistentStorage(): Promise<{
persisted: boolean
supported: boolean
}> {
if (!('storage' in navigator) || !navigator.storage.persist) {
return { persisted: false, supported: false }
}
try {
const persisted = await navigator.storage.persisted()
if (persisted) return { persisted: true, supported: true }
const granted = await navigator.storage.persist()
return { persisted: granted, supported: true }
} catch {
return { persisted: false, supported: true }
}
}
+2
View File
@@ -46,6 +46,8 @@ export default defineConfig({
include: ['leaflet']
},
server: {
// Passkeys require localhost or a real domain — not 127.0.0.1
host: 'localhost',
port: 5173,
proxy: {
'/api': {
+7 -5
View File
@@ -4,13 +4,14 @@ services:
container_name: daagbox-prod-db
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: daagbox
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
POSTGRES_DB: ${POSTGRES_DB:-daagbox}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d daagbox"]
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
# Not published to the host — reachable only on the Compose network (do not add ports: here)
interval: 5s
timeout: 5s
retries: 5
@@ -23,9 +24,10 @@ services:
restart: always
environment:
PORT: 5000
DATABASE_URL: "postgresql://postgres:postgres@db:5432/daagbox?schema=public"
DATABASE_URL: "postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-daagbox}?schema=public"
RP_ID: ${RP_ID:-localhost}
ORIGIN: ${ORIGIN:-http://localhost}
TRUST_PROXY: ${TRUST_PROXY:-1}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu}
+121
View File
@@ -0,0 +1,121 @@
# Code-Statistik — Kapteins Daagbok
Erstellt am **31. Mai 2026** mit [cloc](https://github.com/AlDanial/cloc) v1.98.
## Methode
```bash
cloc . \
--exclude-dir=node_modules,dist,.git,userfeedback,.cursor,.planning \
--md
```
Ausgeschlossen: Build-Artefakte (`dist/`), Abhängigkeiten (`node_modules/`), lokales Feedback, Cursor-/Planungs-Artefakte.
## Gesamtübersicht
| Language | files | blank | comment | code |
| :--- | ---: | ---: | ---: | ---: |
| TypeScript | 145 | 3012 | 540 | 23599 |
| JSON | 14 | 4 | 0 | 15005 |
| CSS | 3 | 743 | 45 | 4837 |
| XML | 3 | 0 | 0 | 4302 |
| HTML | 5 | 160 | 0 | 1411 |
| Markdown | 8 | 390 | 12 | 1077 |
| JavaScript | 8 | 117 | 43 | 709 |
| Bourne Shell | 3 | 81 | 21 | 412 |
| YAML | 1 | 3 | 0 | 55 |
| Dockerfile | 2 | 20 | 21 | 39 |
| SVG | 4 | 0 | 0 | 27 |
| **SUM** | **196** | **4530** | **682** | **51473** |
### Anwendungscode (TypeScript, JavaScript, CSS)
Ohne JSON, GPX/XML, HTML, Docs und Assets — näher an der eigentlichen Implementierung:
| Language | files | blank | comment | code |
| :--- | ---: | ---: | ---: | ---: |
| TypeScript | 145 | 3012 | 540 | 23599 |
| CSS | 3 | 743 | 45 | 4837 |
| JavaScript | 8 | 117 | 43 | 709 |
| **SUM** | **156** | **3872** | **628** | **29145** |
> **Hinweis:** Der hohe JSON-Anteil (~15k Zeilen) stammt überwiegend aus i18n-Locale-Dateien (`client/src/i18n/locales/*.json`). XML (~4,3k Zeilen) sind Demo-GPX-Tracks unter `client/src/assets/demo/`.
## Aufteilung nach Bereich
| Bereich | Dateien | Leer | Kommentar | Code |
| :--- | ---: | ---: | ---: | ---: |
| `client/` | 154 | 3398 | 557 | 43534 |
| `server/` | 20 | 399 | 54 | 4426 |
| `scripts/` | 9 | 193 | 59 | 1065 |
| `docs/` | 8 | 418 | 0 | 2079 |
### `client/`
| Language | files | blank | comment | code |
| :--- | ---: | ---: | ---: | ---: |
| TypeScript | 129 | 2625 | 499 | 21291 |
| JSON | 10 | 4 | 0 | 12898 |
| CSS | 3 | 743 | 45 | 4837 |
| XML | 3 | 0 | 0 | 4302 |
| Markdown | 1 | 13 | 0 | 60 |
| JavaScript | 2 | 5 | 5 | 56 |
| HTML | 1 | 0 | 0 | 47 |
| SVG | 4 | 0 | 0 | 27 |
| Dockerfile | 1 | 8 | 8 | 16 |
| **SUM** | **154** | **3398** | **557** | **43534** |
### `server/`
| Language | files | blank | comment | code |
| :--- | ---: | ---: | ---: | ---: |
| TypeScript | 16 | 387 | 41 | 2308 |
| JSON | 3 | 0 | 0 | 2095 |
| Dockerfile | 1 | 12 | 13 | 23 |
| **SUM** | **20** | **399** | **54** | **4426** |
### `scripts/`
| Language | files | blank | comment | code |
| :--- | ---: | ---: | ---: | ---: |
| JavaScript | 6 | 112 | 38 | 653 |
| Bourne Shell | 3 | 81 | 21 | 412 |
| **SUM** | **9** | **193** | **59** | **1065** |
## Größte Quelldateien (TypeScript & CSS)
| Datei | blank | comment | code |
| :--- | ---: | ---: | ---: |
| `client/src/App.css` | 730 | 31 | 4430 |
| `client/src/components/LogEntryEditor.tsx` | 176 | 17 | 1929 |
| `client/src/components/UserProfilePage.tsx` | 52 | 0 | 746 |
| `client/src/components/LiveLogView.tsx` | 50 | 2 | 711 |
| `client/src/App.tsx` | 85 | 21 | 656 |
| `client/src/components/CrewForm.tsx` | 82 | 117 | 644 |
| `client/src/components/VesselForm.tsx` | 55 | 8 | 558 |
| `client/src/services/auth.ts` | 80 | 66 | 556 |
| `client/src/services/logbookBackup.ts` | 56 | 0 | 545 |
| `client/src/components/AuthOnboarding.tsx` | 49 | 25 | 542 |
| `client/src/components/StatsDashboard.tsx` | 43 | 0 | 521 |
| `client/src/components/LogbookDashboard.tsx` | 46 | 2 | 508 |
| `client/src/components/InvitationAcceptance.tsx` | 59 | 0 | 461 |
| `client/src/components/LogEntriesList.tsx` | 50 | 4 | 447 |
| `client/src/services/sync.ts` | 70 | 29 | 428 |
## Kurzfassung
- **~51k** physische Codezeilen gesamt (inkl. Locales, Demo-GPX, Docs).
- **~29k** Zeilen reiner Anwendungscode (TS/JS/CSS).
- **~21k** TypeScript im Client, **~2,3k** im Server.
- Größte Einzeldatei: `App.css` (~4,4k Zeilen), größte Komponente: `LogEntryEditor.tsx` (~1,9k Zeilen).
## Report aktualisieren
```bash
cloc . \
--exclude-dir=node_modules,dist,.git,userfeedback,.cursor,.planning \
--md > docs/cloc-report-raw.md
```
Für eine reine Markdown-Tabelle reicht `--md`; dieser Report fasst mehrere cloc-Läufe manuell zusammen.
+60
View File
@@ -0,0 +1,60 @@
# Deployment: Nginx Proxy Manager & Security (Sprint 1)
Kapteins Daagbok läuft öffentlich unter **https://kapteins-daagbok.eu/** hinter **Nginx Proxy Manager** (NPM, z. B. `172.16.10.10`) mit Upstream auf den App-Stack (`172.16.10.110`).
## NPM Proxy Host
| Einstellung | Wert |
|-------------|------|
| Domain | `kapteins-daagbok.eu` |
| Scheme | `https` |
| Forward Hostname / IP | `172.16.10.110` (oder Container-Port auf dem Host) |
| Forward Port | `80` (Frontend-Nginx) |
| Websockets | an, falls genutzt |
| Block Common Exploits | an |
| SSL | Let's Encrypt o. ä. |
### Custom Nginx (Advanced) — empfohlen
NPM setzt `X-Forwarded-*` in der Regel automatisch. Falls nicht, im Proxy-Host unter **Advanced**:
```nginx
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
```
## Backend-Umgebung (`.env` auf dem Server)
```env
ORIGIN=https://kapteins-daagbok.eu
RP_ID=kapteins-daagbok.eu
SESSION_SECRET=<min. 32 Zeichen, openssl rand -base64 48>
TRUST_PROXY=172.16.10.10
# oder TRUST_PROXY=1 für genau einen Proxy-Hop
```
`ORIGIN` muss **exakt** der Browser-URL entsprechen (ohne trailing slash).
## Security-Header
- **HSTS, CSP (optional restriktiver):** können in NPM unter „Custom Headers“ oder im Advanced-Block gesetzt werden.
- **Basis-Header** für statische Dateien setzt [`client/nginx.conf`](../../client/nginx.conf) (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, CSP inkl. Plausible).
### Plausible Analytics
Script-Host: `https://plausible.elpatron.me` — in CSP als `script-src` und `connect-src` erlaubt. Gemessene Site: `data-domain="kapteins-daagbok.eu"`.
Optional später: `analytics.kapteins-daagbok.eu` als Alias auf dieselbe Plausible-Instanz.
## Nach Deploy prüfen
1. https://kapteins-daagbok.eu/api/health — `status: ok`
2. Passkey Login / Registrierung
3. DevTools → Application → Cookie `daagbok_session`: `Secure`, `HttpOnly`, `SameSite=Lax`
4. Response-Header auf `index.html`: CSP, `X-Frame-Options`
5. Zwei Geräte hinter NAT: unabhängige Rate-Limits (nicht alle als eine IP)
## Docker Compose
Keine Default-Passwörter in Produktion: starkes `POSTGRES_PASSWORD` (siehe [postgres-password.md](postgres-password.md)) und `SESSION_SECRET` in `.env` setzen (siehe [`.env.example`](../../.env.example)).
+42
View File
@@ -0,0 +1,42 @@
# PostgreSQL absichern (Produktion)
## Ist-Zustand
- Die Datenbank läuft im Container `daagbox-prod-db` **ohne** Host-Port (nur Docker-Netz `db:5432`) — gut.
- Das Passwort wird beim **ersten** Start des Volumes gesetzt; ein späteres Ändern nur von `POSTGRES_PASSWORD` in `.env` **ändert nicht** das laufende Passwort.
- Nach Sprint 1 war auf dem Server noch das Legacy-Passwort `postgres` möglich → per Skript rotieren.
## Empfohlene Schritte
1. **Backup/Snapshot** (hast du laut Vorgabe).
2. Auf dem Server im Repo:
```bash
cd /opt/kapteins-daagbok
git pull
chmod +x scripts/rotate-postgres-password.sh
./scripts/rotate-postgres-password.sh
```
3. Inhalt von `.postgres-credentials.<timestamp>` in den Passwort-Manager übernehmen, Datei auf dem Server löschen:
```bash
shred -u .postgres-credentials.* # oder rm nach manuellem Notieren
```
### Optional: eigener App-Benutzer (statt `postgres` für Prisma)
```bash
./scripts/rotate-postgres-password.sh --app-user daagbok
```
- **`daagbok`**: Login für Backend/Prisma (kein Superuser)
- **`postgres`**: nur noch Admin (Passwort in `POSTGRES_ADMIN_PASSWORD` in `.env`)
## Lokale Entwicklung
`scripts/start-dev.sh` nutzt weiterhin `postgres/postgres` auf localhost — nur für Dev. Produktion nie dieses Passwort wiederverwenden.
## Verifikation
```bash
docker exec daagbox-prod-backend wget -qO- http://127.0.0.1:5000/api/health
curl -sf https://kapteins-daagbok.eu/api/health
```
+42
View File
@@ -0,0 +1,42 @@
# Pre-Deploy-Checks (ohne CI)
Vor jedem Update auf **https://kapteins-daagbok.eu/** lokal ausführen:
```bash
npm run check
```
Das Skript [`scripts/predeploy-check.sh`](../../scripts/predeploy-check.sh) führt aus:
1. i18n-Key-Validierung (`validate:i18n`)
2. Client: `test``build` (TypeScript via `tsc -b`)
3. Server: `test``build`
## Einzelbefehle (Repo-Root)
| Befehl | Inhalt |
|--------|--------|
| `npm run lint` | ESLint (Client) — optional, noch nicht Teil von `check` |
| `npm run test` | Vitest Client + Server |
| `npm run build` | Production-Build beider Pakete |
| `npm run predeploy` | Alias für `npm run check` |
## Server-Tests
Smoke-Tests in `server/src/api.smoke.test.ts` — keine echte Datenbank (Prisma gemockt). Prüfen u. a. Health, 401 ohne Session, öffentliche Collaboration-Validierung.
```bash
cd server && npm test
```
## Nach erfolgreichem Check
[`scripts/update-prod.sh`](../../scripts/update-prod.sh) führt `predeploy-check.sh` **automatisch** aus (nach Release-Vorbereitung, vor dem SSH-Deploy).
```bash
./scripts/update-prod.sh
```
Notfall ohne Checks (nur wenn nötig): `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-prod.sh`
Manuell auf dem Server: `git pull`, `docker compose build`, `docker compose up -d` (siehe [npm-security.md](npm-security.md)).
+3 -1
View File
@@ -157,7 +157,7 @@
display: flex;
gap: 2.5mm;
align-items: flex-start;
font-size: 10.5pt;
font-size: 8.5pt;
line-height: 1.28;
color: #e2e8f0;
}
@@ -320,6 +320,8 @@
<div class="feature"><span class="feature-icon"></span><span>Simpelt login uden adgangskode Passkey.</span></div>
<div class="feature"><span class="feature-icon"></span><span>Ende-til-ende-kryptering</span></div>
<div class="feature"><span class="feature-icon"></span><span>Upload af GPS-spor (GPX/KML) med kortvisning</span></div>
<div class="feature"><span class="feature-icon"></span><span>Automatisk loggenerering fra NMEA-data</span></div>
<div class="feature"><span class="feature-icon"></span><span>Live-log (klik-til-log)</span></div>
<div class="feature"><span class="feature-icon"></span><span>Rute-statistik</span></div>
<div class="feature"><span class="feature-icon"></span><span>Vedhæftede billeder pr. rejsedag</span></div>
<div class="feature"><span class="feature-icon"></span><span>Fotoavatarbilleder til skipper og besætning</span></div>

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