Compare commits

...

111 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 10:03:46 +02:00
elpatron e1cb2754c4 fix: keep session when leaving admin
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 09:32:30 +02:00
elpatron 5dedb8fac0 feat: add admin dashboard with usage stats
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 09:26:55 +02:00
elpatron 78f1659db4 chore: release v0.1.1.15 2026-06-04 19:30:53 +02:00
elpatron 935c263648 style: link KnorrLabs to dashy with compass icon badge 2026-06-04 19:29:59 +02:00
elpatron 29ac96f892 chore: release v0.1.1.14 2026-06-04 19:27:12 +02:00
elpatron 4d3b7210b3 style: replace name with email address in footer mail link badge 2026-06-04 19:27:00 +02:00
elpatron 369bca2ef1 chore: release v0.1.1.13 2026-06-04 19:23:13 +02:00
elpatron 2fcc741f5e style: style email link in footer as icon badge similar to Ko-Fi badge 2026-06-04 19:22:43 +02:00
elpatron 27722186d1 AI Video updated 2026-06-04 19:18:10 +02:00
elpatron 5710c74706 feat: add square sticker/sharepic image 2026-06-04 19:09:27 +02:00
elpatron cd27dfa27d Add AI generated marketing video 2026-06-04 19:05:36 +02:00
elpatron c4c7d42de4 feat: add portrait sharepic and update generation script 2026-06-04 18:39:07 +02:00
elpatron 71025b3d61 style: add QR code to sharepic layout 2026-06-04 18:36:51 +02:00
elpatron f790a6adcc feat: add sharepic HTML template, generation script, and client package task 2026-06-04 18:36:12 +02:00
elpatron de5a46938b chore: release v0.1.1.12 2026-06-04 18:26:15 +02:00
elpatron 16944c1a26 chore: update contact email in footer to moin@kapteins-daagbok.eu 2026-06-04 18:26:02 +02:00
elpatron fae7b20f90 chore: release v0.1.1.11 2026-06-03 19:39:37 +02:00
elpatron 73e7613a1b feat(logbook): attribute log events to creator and show in exports 2026-06-03 19:39:15 +02:00
elpatron 6c8aa5af4c chore: release v0.1.1.10 2026-06-03 19:17:02 +02:00
elpatron 9554f4b66e style(client): center PWA update and install banners properly 2026-06-03 19:16:56 +02:00
elpatron 5c77bbfdc3 style(client): hide version footer on mobile when bottom navigation is active 2026-06-03 19:15:09 +02:00
elpatron 979b572136 chore: release v0.1.1.9 2026-06-03 19:11:29 +02:00
elpatron f189317dfc chore: remove visual debug logs panel from voice recording modal 2026-06-03 19:11:25 +02:00
elpatron c54f834311 chore: release v0.1.1.8 2026-06-03 19:07:07 +02:00
elpatron 9d05005bb7 fix: allow blob and data urls in Content-Security-Policy media-src directive 2026-06-03 19:07:03 +02:00
elpatron 40c4874156 chore: release v0.1.1.7 2026-06-03 18:56:39 +02:00
elpatron 2de0636608 fix: call load() to force mobile browsers to fetch blob URL metadata and fix player duration 2026-06-03 18:56:32 +02:00
elpatron 9e7c6f4397 chore: release v0.1.1.6 2026-06-03 18:51:14 +02:00
elpatron 6600ceafce debug: add verbose console logging and on-screen logs area to LiveVoiceCapture 2026-06-03 18:51:08 +02:00
elpatron d7a497a4a2 chore: release v0.1.1.5 2026-06-03 18:44:56 +02:00
elpatron 4c04086d63 fix: solve audio recording on iOS/Safari and fix Dockerfile health check 2026-06-03 18:44:51 +02:00
elpatron 79ce42bec6 chore: release v0.1.1.4 2026-06-03 18:33:39 +02:00
elpatron 72c956162c fix: resolve 0-second duration issue on WebM voice recordings in Chrome/Android 2026-06-03 18:33:35 +02:00
elpatron 3080b59dc8 chore: release v0.1.1.3 2026-06-03 18:27:00 +02:00
elpatron d054e42cc0 style: add sunset background image to login screen 2026-06-03 18:26:52 +02:00
elpatron d299fc1d93 chore: release v0.1.1.2 2026-06-03 18:23:23 +02:00
elpatron 6447e95d7d fix: defer stopping media stream tracks until media recorder finishes stopping 2026-06-03 18:22:30 +02:00
elpatron 7ec5a1eccc chore: release v0.1.1.1 2026-06-03 18:14:13 +02:00
elpatron 4cf70a3431 style: increase footer and Ko-Fi badge font-size 2026-06-03 18:14:07 +02:00
elpatron 6ed8b2a8e7 chore: release v0.1.1.0 2026-06-03 18:10:37 +02:00
elpatron bff00cf0a3 fix: camera error modal rendering and voice memo player box-sizing 2026-06-03 18:10:20 +02:00
elpatron 3cab735754 refactor: replace parseFloat with parseAppDecimal and formatAppDecimal for improved number handling
Updated various components to utilize parseAppDecimal and formatAppDecimal for consistent decimal parsing and formatting. This change enhances the handling of numeric inputs across the application, ensuring better accuracy and user experience in forms and displays.
2026-06-03 18:07:22 +02:00
elpatron 79762a0baf fix(gps): Genauigkeitsanzeige unter und ab 100 m unterscheiden
Der tote Ternär lieferte in beiden Zweigen dieselbe Rundung; ab 100 m
wird jetzt auf 10 m gerundet, damit schwache Fixes nicht falsch präzise wirken.

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 15:33:56 +02:00
111 changed files with 7713 additions and 1633 deletions
+17
View File
@@ -15,6 +15,11 @@ DeepLAPIKey=
# Production (kapteins-daagbok.eu): # Production (kapteins-daagbok.eu):
# RP_ID=kapteins-daagbok.eu # RP_ID=kapteins-daagbok.eu
# ORIGIN=https://kapteins-daagbok.eu # ORIGIN=https://kapteins-daagbok.eu
# Staging (staging.kapteins-daagbok.eu):
# RP_ID=staging.kapteins-daagbok.eu
# ORIGIN=https://staging.kapteins-daagbok.eu
# POSTGRES_DB=daagbox_staging
# NTFY_TOPIC=kapteins-daagbok-staging-feedback
RP_ID=localhost RP_ID=localhost
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost) # Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
ORIGIN=http://localhost:5173 ORIGIN=http://localhost:5173
@@ -29,6 +34,8 @@ ORIGIN=http://localhost:5173
# POSTGRES_USER=postgres # POSTGRES_USER=postgres
# POSTGRES_PASSWORD= # POSTGRES_PASSWORD=
# POSTGRES_DB=daagbox # POSTGRES_DB=daagbox
# Optional: lock Docker Compose to a specific configuration file (e.g. staging or production) on the server:
# COMPOSE_FILE=docker-compose.staging.yml
# Optional: comma-separated CORS origins (defaults to ORIGIN; 127.0.0.1 may be allowed for CORS but not for login) # Optional: comma-separated CORS origins (defaults to ORIGIN; 127.0.0.1 may be allowed for CORS but not for login)
# CORS_ORIGINS=http://localhost:5173 # CORS_ORIGINS=http://localhost:5173
@@ -36,6 +43,10 @@ ORIGIN=http://localhost:5173
# Generate: openssl rand -base64 48 # Generate: openssl rand -base64 48
SESSION_SECRET= SESSION_SECRET=
# Admin dashboard access — comma-separated list of User IDs (UUIDs)
# Example: ADMIN_USER_IDS=11111111-2222-3333-4444-555555555555,22222222-3333-4444-5555-666666666666
ADMIN_USER_IDS=
# Web Push (VAPID) — generate with: npx web-push generate-vapid-keys # Web Push (VAPID) — generate with: npx web-push generate-vapid-keys
# Public key may also be set on the client as VITE_VAPID_PUBLIC_KEY # Public key may also be set on the client as VITE_VAPID_PUBLIC_KEY
VAPID_PUBLIC_KEY= VAPID_PUBLIC_KEY=
@@ -47,3 +58,9 @@ VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
NTFY_SERVER=https://ntfy.sh NTFY_SERVER=https://ntfy.sh
NTFY_TOPIC=kapteins-daagbok-feedback NTFY_TOPIC=kapteins-daagbok-feedback
NTFY_TOKEN=tk_example_ntfy_access_token NTFY_TOKEN=tk_example_ntfy_access_token
# Plausible Analytics (frontend container — see docs/plausible-events.md)
# Production: PLAUSIBLE_ENABLED=true, data-domain = current hostname (kapteins-daagbok.eu)
# Staging: PLAUSIBLE_ENABLED=false (default in docker-compose.staging.yml)
PLAUSIBLE_ENABLED=true
PLAUSIBLE_HOST=https://plausible.elpatron.me
+16 -2
View File
@@ -251,15 +251,27 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen). Führ
```bash ```bash
./scripts/update-prod.sh ./scripts/update-remotes.sh -dest prod
``` ```
Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar. Standard-Ziel Prod: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
Auf dem Server müssen `.env` u. a. `POSTGRES_PASSWORD`, `RP_ID`, `ORIGIN` (`https://kapteins-daagbok.eu`), `SESSION_SECRET` (≥ 32 Zeichen), `TRUST_PROXY` (NPM, z. B. `172.16.10.10` oder `1`) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container. Auf dem Server müssen `.env` u. a. `POSTGRES_PASSWORD`, `RP_ID`, `ORIGIN` (`https://kapteins-daagbok.eu`), `SESSION_SECRET` (≥ 32 Zeichen), `TRUST_PROXY` (NPM, z. B. `172.16.10.10` oder `1`) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
Prod-Deploy legt vor dem Update automatisch ein Server-Backup an (DB, `.env`, Compose, App-Code). Tägliches Cron-Backup und Restore: [docs/deployment/backup.md](docs/deployment/backup.md).
Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deployment/npm-security.md). Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deployment/npm-security.md).
### Staging
Testumgebung unter [staging.kapteins-daagbok.eu](https://staging.kapteins-daagbok.eu) — Deploy ohne Release-Tag:
```bash
./scripts/update-remotes.sh -dest stage
```
Standard-Ziel Staging: `root@10.0.0.27:/opt/kapteins-daagbok-staging` — per `REMOTE_HOST`, `REMOTE_DIR`, `DEPLOY_BRANCH` überschreibbar. Details: [docs/deployment/staging.md](docs/deployment/staging.md).
## Dokumentation ## Dokumentation
| Dokument | Inhalt | | Dokument | Inhalt |
@@ -267,6 +279,8 @@ Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deploymen
| [docs/deployment/npm-security.md](docs/deployment/npm-security.md) | NPM, TLS, `trust proxy`, Security-Header | | [docs/deployment/npm-security.md](docs/deployment/npm-security.md) | NPM, TLS, `trust proxy`, Security-Header |
| [docs/deployment/predeploy.md](docs/deployment/predeploy.md) | Pre-Deploy-Checks ohne CI | | [docs/deployment/predeploy.md](docs/deployment/predeploy.md) | Pre-Deploy-Checks ohne CI |
| [docs/deployment/postgres-password.md](docs/deployment/postgres-password.md) | PostgreSQL-Passwort rotieren / App-Rolle | | [docs/deployment/postgres-password.md](docs/deployment/postgres-password.md) | PostgreSQL-Passwort rotieren / App-Rolle |
| [docs/deployment/backup.md](docs/deployment/backup.md) | Server-Backup, Crontab, Restore (Prod) |
| [docs/deployment/staging.md](docs/deployment/staging.md) | Staging-VM, Deploy, `.env` |
| [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics | | [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics |
| [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan | | [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan |
| [docs/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan | | [docs/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan |
+1 -1
View File
@@ -1 +1 @@
0.1.0.111 0.1.1.28
+8 -5
View File
@@ -18,15 +18,18 @@ RUN npm run build
FROM nginx:1.25-alpine FROM nginx:1.25-alpine
WORKDIR /usr/share/nginx/html WORKDIR /usr/share/nginx/html
# Copy custom Nginx configuration RUN apk add --no-cache gettext
COPY client/nginx.conf /etc/nginx/conf.d/default.conf
COPY client/nginx.conf.template /etc/nginx/templates/default.conf.template
COPY client/docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# Copy built assets from builder # Copy built assets from builder
COPY --from=builder /app/dist . COPY --from=builder /app/dist .
# Expose HTTP port
EXPOSE 80 EXPOSE 80
# Health check to verify Nginx is actively running
HEALTHCHECK --interval=30s --timeout=5s --start-period=3s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=3s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1 CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:80/ || exit 1
ENTRYPOINT ["/docker-entrypoint.sh"]
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
set -eu
PLAUSIBLE_ENABLED="${PLAUSIBLE_ENABLED:-true}"
PLAUSIBLE_HOST="${PLAUSIBLE_HOST:-https://plausible.elpatron.me}"
PLAUSIBLE_HOST="${PLAUSIBLE_HOST%/}"
case "$(printf '%s' "$PLAUSIBLE_ENABLED" | tr '[:upper:]' '[:lower:]')" in
true|1|yes)
PLAUSIBLE_ENABLED_JSON=true
PLAUSIBLE_CSP=" ${PLAUSIBLE_HOST}"
;;
*)
PLAUSIBLE_ENABLED_JSON=false
PLAUSIBLE_CSP=""
;;
esac
export PLAUSIBLE_CSP
envsubst '${PLAUSIBLE_CSP}' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf
cat > /usr/share/nginx/html/runtime-config.json <<EOF
{"plausibleEnabled":${PLAUSIBLE_ENABLED_JSON},"plausibleHost":"${PLAUSIBLE_HOST}"}
EOF
exec nginx -g 'daemon off;'
+1 -1
View File
@@ -22,6 +22,7 @@
<meta name="apple-mobile-web-app-title" content="Daagbok" /> <meta name="apple-mobile-web-app-title" content="Daagbok" />
<meta name="theme-color" content="#0b0c10" /> <meta name="theme-color" content="#0b0c10" />
<script src="/appearance-bootstrap.js"></script> <script src="/appearance-bootstrap.js"></script>
<script src="/plausible-bootstrap.js"></script>
<script src="/bootstrap-watchdog.js"></script> <script src="/bootstrap-watchdog.js"></script>
<link rel="apple-touch-icon" href="/logo.png" /> <link rel="apple-touch-icon" href="/logo.png" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
@@ -38,7 +39,6 @@
<meta name="twitter:description" content="Kostenlos und werbefrei: sicheres, E2E-verschlüsseltes Logbuch für Skipper. Reisetage, GPS-Tracks, Crew- und Schiffsdaten Passkey-Anmeldung und Offline-PWA." /> <meta name="twitter:description" content="Kostenlos und werbefrei: sicheres, E2E-verschlüsseltes Logbuch für Skipper. Reisetage, GPS-Tracks, Crew- und Schiffsdaten Passkey-Anmeldung und Offline-PWA." />
<meta name="twitter:image" content="https://kapteins-daagbok.eu/logo.png" /> <meta name="twitter:image" content="https://kapteins-daagbok.eu/logo.png" />
<meta name="twitter:image:alt" content="Kapteins Daagbok Logo" /> <meta name="twitter:image:alt" content="Kapteins Daagbok Logo" />
<script defer data-domain="kapteins-daagbok.eu" src="https://plausible.elpatron.me/js/script.tagged-events.js"></script>
<title>Kapteins Daagbok Kostenloses digitales Yacht-Logbuch (werbefrei)</title> <title>Kapteins Daagbok Kostenloses digitales Yacht-Logbuch (werbefrei)</title>
</head> </head>
<body> <body>
+2 -51
View File
@@ -1,51 +1,2 @@
server { # Generated at container start from PLAUSIBLE_* — see client/nginx.conf.template and docker-entrypoint.sh
listen 80; # Local Docker Compose uses the template via client/Dockerfile entrypoint.
server_name localhost;
client_max_body_size 50M;
# Security headers (TLS/HSTS at NPM — see docs/deployment/npm-security.md)
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
# Service worker and app shell must revalidate so PWA updates are detected
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location = /index.html {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:5000/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
+51
View File
@@ -0,0 +1,51 @@
server {
listen 80;
server_name localhost;
client_max_body_size 50M;
# Security headers (TLS/HSTS at NPM — see docs/deployment/npm-security.md)
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
# Service worker and app shell must revalidate so PWA updates are detected
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location = /index.html {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:5000/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
+1
View File
@@ -13,6 +13,7 @@
"generate:flyer:png": "node ../scripts/generate-beta-flyer.mjs --png", "generate:flyer:png": "node ../scripts/generate-beta-flyer.mjs --png",
"generate:flyer:all": "node ../scripts/generate-beta-flyer.mjs --all", "generate:flyer:all": "node ../scripts/generate-beta-flyer.mjs --all",
"generate:flyer:setup": "playwright install chromium", "generate:flyer:setup": "playwright install chromium",
"generate:sharepic": "node ../scripts/generate-sharepic.mjs",
"translate:locales": "node ../scripts/translate-locales.mjs", "translate:locales": "node ../scripts/translate-locales.mjs",
"translate:flyer": "node ../scripts/translate-flyer.mjs", "translate:flyer": "node ../scripts/translate-flyer.mjs",
"validate:i18n": "node ../scripts/validate-i18n-keys.mjs" "validate:i18n": "node ../scripts/validate-i18n-keys.mjs"
Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

+25
View File
@@ -0,0 +1,25 @@
/**
* Loads Plausible when enabled via /runtime-config.json (from .env in Docker / Vite dev).
* data-domain is always the current hostname (prod vs staging).
*/
(function () {
function load(cfg) {
if (!cfg || !cfg.plausibleEnabled || !cfg.plausibleHost) return
var host = String(cfg.plausibleHost).replace(/\/$/, '')
if (!host) return
var s = document.createElement('script')
s.defer = true
s.dataset.domain = window.location.hostname
s.src = host + '/js/script.tagged-events.js'
document.head.appendChild(s)
}
fetch('/runtime-config.json', { cache: 'no-store' })
.then(function (r) {
return r.ok ? r.json() : null
})
.then(load)
.catch(function () {
/* analytics optional */
})
})()
+757 -52
View File
File diff suppressed because it is too large Load Diff
+79 -4
View File
@@ -36,6 +36,7 @@ import { syncAppearancePrefs } from './services/appearancePrefs.js'
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js' import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx' import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
import DemoViewer from './components/DemoViewer.tsx' import DemoViewer from './components/DemoViewer.tsx'
import AdminDashboard from './admin/AdminDashboard.tsx'
import PwaInstallPrompt from './components/PwaInstallPrompt.tsx' import PwaInstallPrompt from './components/PwaInstallPrompt.tsx'
import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx' import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx'
import AppFooter from './components/AppFooter.tsx' import AppFooter from './components/AppFooter.tsx'
@@ -49,6 +50,8 @@ import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, La
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx' import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx' import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
import ProfileHeaderButton from './components/ProfileHeaderButton.tsx' import ProfileHeaderButton from './components/ProfileHeaderButton.tsx'
import AdminHeaderButton from './components/AdminHeaderButton.tsx'
import { checkAdminAccess } from './services/adminApi.js'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cycleAppLanguage } from './utils/i18nLanguages.js' import { cycleAppLanguage } from './utils/i18nLanguages.js'
import { import {
@@ -92,6 +95,10 @@ function App() {
// Public demo mode (no account required) // Public demo mode (no account required)
const [isDemoMode, setIsDemoMode] = useState(() => window.location.pathname === '/demo') const [isDemoMode, setIsDemoMode] = useState(() => window.location.pathname === '/demo')
const [isAdminRoute, setIsAdminRoute] = useState(() => window.location.pathname.startsWith('/admin'))
const [isAdminUser, setIsAdminUser] = useState(false)
const [sessionChecked, setSessionChecked] = useState(false)
const [serverSessionActive, setServerSessionActive] = useState(false)
const syncQueueCount = useLiveQuery( const syncQueueCount = useLiveQuery(
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(), () => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
@@ -160,14 +167,23 @@ function App() {
}) })
}, []) }, [])
const refreshAdminAccess = useCallback(async () => {
const isAdmin = await checkAdminAccess()
setIsAdminUser(isAdmin)
}, [])
useEffect(() => { useEffect(() => {
if (!isAuthenticated) return if (!isAuthenticated) {
setIsAdminUser(false)
return
}
const userId = localStorage.getItem('active_userid') const userId = localStorage.getItem('active_userid')
if (!userId) return if (!userId) return
void syncAppearancePrefs(userId) void syncAppearancePrefs(userId)
void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool()) void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool())
void migrateLegacyYachtsToPoolIfNeeded().then(() => syncVesselPool()) void migrateLegacyYachtsToPoolIfNeeded().then(() => syncVesselPool())
}, [isAuthenticated]) void refreshAdminAccess()
}, [isAuthenticated, refreshAdminAccess])
useEffect(() => { useEffect(() => {
const handleOnline = () => { const handleOnline = () => {
@@ -199,6 +215,13 @@ function App() {
const hashParams = new URLSearchParams(window.location.hash.substring(1)) const hashParams = new URLSearchParams(window.location.hash.substring(1))
const path = window.location.pathname const path = window.location.pathname
if (path.startsWith('/admin')) {
setIsAdminRoute(true)
return
}
setIsAdminRoute(false)
if (path === '/demo') { if (path === '/demo') {
setIsDemoMode(true) setIsDemoMode(true)
setIsViewerMode(false) setIsViewerMode(false)
@@ -240,6 +263,7 @@ function App() {
const clearAuthenticatedAppState = useCallback(() => { const clearAuthenticatedAppState = useCallback(() => {
setIsAuthenticated(false) setIsAuthenticated(false)
setIsAdminUser(false)
setActiveLogbookId(null) setActiveLogbookId(null)
setActiveLogbookTitle(null) setActiveLogbookTitle(null)
setShowUserProfile(false) setShowUserProfile(false)
@@ -249,7 +273,7 @@ function App() {
/** After PWA/bfcache resume, React state may still say "logged in" while the master key is gone. */ /** After PWA/bfcache resume, React state may still say "logged in" while the master key is gone. */
const enforceUnlockedSession = useCallback(() => { const enforceUnlockedSession = useCallback(() => {
if (isViewerMode || isDemoMode || isAcceptingInvite) return if (isViewerMode || isDemoMode || isAcceptingInvite || isAdminRoute) return
// Require full local session (incl. userId) so API calls are not left headless. // Require full local session (incl. userId) so API calls are not left headless.
if (isAuthenticated && !hasUnlockedLocalSession()) { if (isAuthenticated && !hasUnlockedLocalSession()) {
clearAuthenticatedAppState() clearAuthenticatedAppState()
@@ -259,6 +283,7 @@ function App() {
isViewerMode, isViewerMode,
isDemoMode, isDemoMode,
isAcceptingInvite, isAcceptingInvite,
isAdminRoute,
clearAuthenticatedAppState clearAuthenticatedAppState
]) ])
@@ -293,6 +318,8 @@ function App() {
const session = await checkServerSession() const session = await checkServerSession()
if (cancelled) return if (cancelled) return
setServerSessionActive(session.authenticated)
if (session.authenticated) { if (session.authenticated) {
persistSessionUserId(session.userId) persistSessionUserId(session.userId)
} }
@@ -312,6 +339,10 @@ function App() {
if (!cancelled) { if (!cancelled) {
console.warn('Session restore failed:', err) console.warn('Session restore failed:', err)
} }
} finally {
if (!cancelled) {
setSessionChecked(true)
}
} }
})() })()
@@ -333,6 +364,14 @@ function App() {
setIsAcceptingInvite(false) setIsAcceptingInvite(false)
}, []) }, [])
const openAdmin = useCallback(() => {
window.history.pushState({}, document.title, '/admin')
setIsAdminRoute(true)
setIsDemoMode(false)
setIsViewerMode(false)
setIsAcceptingInvite(false)
}, [])
const selectLogbook = useCallback((id: string, title: string) => { const selectLogbook = useCallback((id: string, title: string) => {
setActiveLogbookId(id) setActiveLogbookId(id)
setActiveLogbookTitle(title) setActiveLogbookTitle(title)
@@ -497,6 +536,7 @@ function App() {
if (!(await confirmLeave())) return if (!(await confirmLeave())) return
void logoutUser() void logoutUser()
setIsAuthenticated(false) setIsAuthenticated(false)
setIsAdminUser(false)
setActiveLogbookId(null) setActiveLogbookId(null)
setActiveLogbookTitle(null) setActiveLogbookTitle(null)
setShowUserProfile(false) setShowUserProfile(false)
@@ -524,6 +564,28 @@ function App() {
syncRouteFromLocation() syncRouteFromLocation()
} }
const handleBackFromAdmin = () => {
window.history.replaceState({}, document.title, '/')
setIsAdminRoute(false)
syncRouteFromLocation()
}
if (isAdminRoute) {
if (!isAuthenticated) {
return (
<div className="auth-screen">
<AuthOnboarding onAuthenticated={handleAuthenticated} onOpenDemo={openDemo} />
</div>
)
}
return (
<div style={{ display: 'contents' }}>
<AdminDashboard onBack={handleBackFromAdmin} />
</div>
)
}
if (isDemoMode) { if (isDemoMode) {
return ( return (
<div style={{ display: 'contents' }}> <div style={{ display: 'contents' }}>
@@ -564,7 +626,17 @@ function App() {
if (!isAuthenticated) { if (!isAuthenticated) {
return ( return (
<div className="auth-screen"> <div className="auth-screen">
<AuthOnboarding onAuthenticated={handleAuthenticated} onOpenDemo={openDemo} /> {!sessionChecked ? (
<div className="auth-card glass">
<p className="dashboard-status-msg">{t('auth.restore_checking')}</p>
</div>
) : (
<AuthOnboarding
restoreSession={serverSessionActive}
onAuthenticated={handleAuthenticated}
onOpenDemo={openDemo}
/>
)}
</div> </div>
) )
} }
@@ -597,6 +669,7 @@ function App() {
onSelectLogbook={selectLogbook} onSelectLogbook={selectLogbook}
onLogout={handleLogout} onLogout={handleLogout}
onOpenProfile={() => setShowUserProfile(true)} onOpenProfile={() => setShowUserProfile(true)}
onOpenAdmin={isAdminUser ? openAdmin : undefined}
/> />
</div> </div>
) )
@@ -647,6 +720,8 @@ function App() {
<Languages size={18} /> <Languages size={18} />
</button> </button>
{isAdminUser && <AdminHeaderButton onClick={openAdmin} />}
<ProfileHeaderButton onClick={() => setShowUserProfile(true)} /> <ProfileHeaderButton onClick={() => setShowUserProfile(true)} />
<DisclaimerHeaderButton /> <DisclaimerHeaderButton />
+252
View File
@@ -0,0 +1,252 @@
import { useEffect, useState, type ReactNode } from 'react'
import {
fetchAdminMe,
fetchAdminSummary,
fetchAdminTimeSeries,
type AdminSummary,
type AdminTimeSeriesResponse,
type AdminTimeBucket
} from '../services/adminApi.js'
import { BarChart2, Bookmark, ChevronLeft, Database, Image, MapPin, Mic, Users } from 'lucide-react'
function formatNumber(value: number): string {
return value.toLocaleString()
}
function formatBytes(bytes: number | undefined): string {
if (bytes === undefined || bytes === null) return '—'
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
const num = bytes / Math.pow(k, i)
return `${num.toFixed(1)} ${sizes[i]}`
}
function KpiCard({
icon,
label,
value
}: {
icon: ReactNode
label: string
value: number | string
}) {
return (
<div className="stats-kpi-card glass">
<div className="stats-kpi-icon">{icon}</div>
<div className="stats-kpi-body">
<span className="stats-kpi-label">{label}</span>
<span className="stats-kpi-value">{typeof value === 'number' ? formatNumber(value) : value}</span>
</div>
</div>
)
}
function TimeSeriesChart({
title,
seriesKey,
data
}: {
title: string
seriesKey: string
data: AdminTimeSeriesResponse | null
}) {
if (!data) {
return null
}
const metric = data.series.find((s) => s.metric === seriesKey)
if (!metric || metric.points.length === 0) {
return (
<div className="form-card glass">
<div className="form-header">
<BarChart2 className="form-icon" />
<h2>{title}</h2>
</div>
<p className="dashboard-status-msg">Keine Daten im gewählten Zeitraum.</p>
</div>
)
}
const max = metric.points.reduce((acc, p) => (p.count > acc ? p.count : acc), 0) || 1
return (
<div className="form-card glass">
<div className="form-header">
<BarChart2 className="form-icon" />
<h2>{title}</h2>
</div>
<div className="stats-bar-chart" role="img" aria-label={title}>
{metric.points.map((point) => {
const heightPct = Math.max(2, (point.count / max) * 100)
return (
<div key={point.date} className="stats-bar-column" title={`${point.date}: ${point.count}`}>
<span className="stats-bar-value">{point.count > 0 ? String(point.count) : ''}</span>
<div className="stats-bar-track">
<div className="stats-bar stats-bar--distance" style={{ height: `${heightPct}%` }} />
</div>
<span className="stats-bar-label">{point.date.slice(5)}</span>
</div>
)
})}
</div>
</div>
)
}
interface AdminDashboardProps {
onBack: () => void
}
export default function AdminDashboard({ onBack }: AdminDashboardProps) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [summary, setSummary] = useState<AdminSummary | null>(null)
const [timeSeries, setTimeSeries] = useState<AdminTimeSeriesResponse | null>(null)
const [bucket, setBucket] = useState<AdminTimeBucket>('day')
const [windowDays, setWindowDays] = useState(90)
useEffect(() => {
let cancelled = false
async function load() {
try {
setLoading(true)
setError(null)
await fetchAdminMe()
const [summaryRes, tsRes] = await Promise.all([
fetchAdminSummary(),
fetchAdminTimeSeries({ bucket, windowDays })
])
if (!cancelled) {
setSummary(summaryRes)
setTimeSeries(tsRes)
}
} catch (err: unknown) {
if (!cancelled) {
const message =
err instanceof Error && err.message ? err.message : 'Fehler beim Laden des Admin-Dashboards'
setError(message)
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
void load()
return () => {
cancelled = true
}
}, [bucket, windowDays])
if (loading && !summary) {
return (
<div className="admin-page">
<p className="dashboard-status-msg">Admin-Dashboard wird geladen</p>
</div>
)
}
if (error) {
return (
<div className="admin-page">
<header className="admin-header">
<button type="button" className="btn-back" onClick={onBack}>
<ChevronLeft size={16} />
Zur App
</button>
</header>
<p className="dashboard-status-msg">{error}</p>
</div>
)
}
if (!summary) {
return (
<div className="admin-page">
<p className="dashboard-status-msg">Keine Admin-Daten verfügbar.</p>
</div>
)
}
return (
<div className="admin-page">
<header className="admin-header">
<div className="admin-header-left">
<button type="button" className="btn-back" onClick={onBack}>
<ChevronLeft size={16} />
Zur App
</button>
<div>
<h1 className="admin-title">Admin-Dashboard</h1>
<p className="admin-subtitle">
Übersicht über Nutzung und Wachstum von Kapteins Daagbok.
</p>
</div>
</div>
</header>
<main className="admin-main">
<section className="stats-kpi-grid admin-kpi-grid">
<KpiCard icon={<Users size={20} />} label="Registrierte Benutzer" value={summary.totalUsers} />
<KpiCard icon={<Bookmark size={20} />} label="Logbücher" value={summary.totalLogbooks} />
<KpiCard icon={<Image size={20} />} label="Fotos" value={summary.totalPhotos} />
<KpiCard icon={<Mic size={20} />} label="Sprachmemos" value={summary.totalVoiceMemos} />
<KpiCard icon={<MapPin size={20} />} label="GPS-Tracks" value={summary.totalGpsTracks} />
<KpiCard
icon={<BarChart2 size={20} />}
label="Einträge mit AI-Zusammenfassung"
value={summary.aiSummaryEntries}
/>
<KpiCard icon={<Database size={20} />} label="Datenbankgröße" value={formatBytes(summary.dbSize)} />
</section>
<section className="admin-controls">
<div className="admin-control-group">
<span className="admin-control-label">Zeitraum</span>
<div className="admin-control-buttons">
{[30, 90, 365].map((days) => (
<button
key={days}
type="button"
className={days === windowDays ? 'btn primary' : 'btn secondary'}
onClick={() => setWindowDays(days)}
>
{days} Tage
</button>
))}
</div>
</div>
<div className="admin-control-group">
<span className="admin-control-label">Aggregation</span>
<div className="admin-control-buttons">
{(['day', 'week', 'month'] as AdminTimeBucket[]).map((b) => (
<button
key={b}
type="button"
className={b === bucket ? 'btn primary' : 'btn secondary'}
onClick={() => setBucket(b)}
>
{b === 'day' ? 'Tag' : b === 'week' ? 'Woche' : 'Monat'}
</button>
))}
</div>
</div>
</section>
<section className="admin-charts-grid">
<TimeSeriesChart title="Neue Benutzer" seriesKey="users_created" data={timeSeries} />
<TimeSeriesChart title="Neue Logbücher" seriesKey="logbooks_created" data={timeSeries} />
<TimeSeriesChart title="Foto-Aktivität" seriesKey="photos_updated" data={timeSeries} />
<TimeSeriesChart title="Datenbankgröße (MB)" seriesKey="database_size" data={timeSeries} />
</section>
</main>
</div>
)
}
@@ -0,0 +1,23 @@
import { useTranslation } from 'react-i18next'
import { LayoutDashboard } from 'lucide-react'
interface AdminHeaderButtonProps {
onClick: () => void
}
export default function AdminHeaderButton({ onClick }: AdminHeaderButtonProps) {
const { t } = useTranslation()
return (
<button
type="button"
className="btn-icon skipper-badge"
onClick={onClick}
title={t('nav.admin')}
aria-label={t('nav.admin')}
>
<LayoutDashboard size={18} aria-hidden="true" />
<span className="skipper-badge__name">{t('nav.admin')}</span>
</button>
)
}
+45 -7
View File
@@ -1,8 +1,13 @@
import { Coffee, Mail, Compass } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev' const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'
const KOFI_URL = 'https://ko-fi.com/kapteinsdaagbok'
export default function AppFooter() { export default function AppFooter() {
const { t } = useTranslation()
return ( return (
<footer className="app-version-footer"> <footer className="app-version-footer">
<span className="app-version-footer__version">v{APP_VERSION}</span> <span className="app-version-footer__version">v{APP_VERSION}</span>
@@ -10,14 +15,47 @@ export default function AppFooter() {
· ·
</span> </span>
<span className="app-version-footer__copyright"> <span className="app-version-footer__copyright">
© 2026 KnorrLabs/ © 2026
<a
href="mailto:elpatron+kd@mailbox.org"
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
>
Markus F.J. Busche
</a>
</span> </span>
<span className="app-version-footer__sep" aria-hidden="true">
·
</span>
<a
className="knorrlabs-footer-badge"
href="https://dashy.elpatron.me/"
target="_blank"
rel="noopener noreferrer"
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
>
<Compass size={14} aria-hidden="true" />
<span>KnorrLabs</span>
</a>
<span className="app-version-footer__sep" aria-hidden="true">
·
</span>
<a
className="mail-footer-badge"
href="mailto:moin@kapteins-daagbok.eu"
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
>
<Mail size={14} aria-hidden="true" />
<span>moin@kapteins-daagbok.eu</span>
</a>
<span className="app-version-footer__sep" aria-hidden="true">
·
</span>
<a
className="kofi-footer-badge"
href={KOFI_URL}
target="_blank"
rel="noopener noreferrer"
title={t('footer.kofi_title')}
aria-label={t('footer.kofi_title')}
onClick={() => trackPlausibleEvent(PlausibleEvents.KOFI_LINK_CLICKED)}
>
<Coffee size={14} aria-hidden="true" />
<span>{t('footer.kofi_label')}</span>
</a>
</footer> </footer>
) )
} }
+133 -5
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js' import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import { import {
@@ -12,7 +12,8 @@ import {
getKnownUsernames, getKnownUsernames,
forgetUsername, forgetUsername,
hasUnlockedLocalSession, hasUnlockedLocalSession,
logoutUser logoutUser,
resolveRestoreUsername
} from '../services/auth.js' } from '../services/auth.js'
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react' import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx' import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
@@ -27,9 +28,15 @@ import {
interface AuthOnboardingProps { interface AuthOnboardingProps {
onAuthenticated: () => void onAuthenticated: () => void
onOpenDemo?: () => void onOpenDemo?: () => void
/** Server session cookie is valid but the in-memory master key was lost (e.g. after reload). */
restoreSession?: boolean
} }
export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnboardingProps) { export default function AuthOnboarding({
onAuthenticated,
onOpenDemo,
restoreSession = false
}: AuthOnboardingProps) {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -60,7 +67,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
const [isNewRegistration, setIsNewRegistration] = useState(false) const [isNewRegistration, setIsNewRegistration] = useState(false)
const [showDisclaimer, setShowDisclaimer] = useState(false) const [showDisclaimer, setShowDisclaimer] = useState(false)
const [showHelp, setShowHelp] = useState(false) const [showHelp, setShowHelp] = useState(false)
const [showStandardLogin, setShowStandardLogin] = useState(false)
const autoUnlockAttempted = useRef(false)
const isRestoreFlow = restoreSession && !showStandardLogin
const passkeyHostOk = isPasskeyCompatibleLocation() const passkeyHostOk = isPasskeyCompatibleLocation()
const passkeyCompatibleUrl = passkeyHostOk ? null : toPasskeyCompatibleUrl(window.location.href) const passkeyCompatibleUrl = passkeyHostOk ? null : toPasskeyCompatibleUrl(window.location.href)
@@ -144,6 +154,23 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
} }
} }
useEffect(() => {
if (!isRestoreFlow || autoUnlockAttempted.current) return
const user = resolveRestoreUsername()
if (user && hasLocalPin(user)) {
autoUnlockAttempted.current = true
setUsername(user)
setShowPinLogin(true)
return
}
if (user && passkeyHostOk) {
autoUnlockAttempted.current = true
void handleLogin(user)
}
}, [isRestoreFlow, passkeyHostOk])
const handleRecoverySubmit = async (e: React.FormEvent) => { const handleRecoverySubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!recoveryInput.trim() || !encryptedPayloads) return if (!recoveryInput.trim() || !encryptedPayloads) return
@@ -347,10 +374,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
<div className="auth-card glass"> <div className="auth-card glass">
<div className="auth-header"> <div className="auth-header">
<KeyRound className="auth-icon accent" size={48} /> <KeyRound className="auth-icon accent" size={48} />
<h2>{t('auth.enter_pin_title')}</h2> <h2>{isRestoreFlow ? t('auth.restore_title') : t('auth.enter_pin_title')}</h2>
</div> </div>
<p className="recovery-warning"> <p className="recovery-warning">
{t('auth.enter_pin_warning')} {isRestoreFlow ? t('auth.restore_pin_warning') : t('auth.enter_pin_warning')}
</p> </p>
<form onSubmit={handlePinLoginSubmit} className="auth-form"> <form onSubmit={handlePinLoginSubmit} className="auth-form">
@@ -397,6 +424,12 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
type="button" type="button"
className="btn secondary" className="btn secondary"
onClick={() => { onClick={() => {
if (isRestoreFlow) {
setShowPinLogin(false)
setPinLoginInput('')
setError(null)
return
}
void (async () => { void (async () => {
setShowPinLogin(false) setShowPinLogin(false)
setPinLoginInput('') setPinLoginInput('')
@@ -480,6 +513,101 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
) )
} }
// Render: Session restore (active server cookie, master key lost after reload)
if (isRestoreFlow) {
const restoreUser = resolveRestoreUsername()
const restoreKnownUsers = getKnownUsernames()
return (
<div className="auth-card glass">
<div className="auth-header">
<KeyRound className="auth-icon accent" size={48} />
<h2>{t('auth.restore_title')}</h2>
</div>
<p className="recovery-warning">{t('auth.restore_subtitle')}</p>
{loading && (
<p className="dashboard-status-msg" style={{ marginTop: '12px' }}>
{t('auth.restore_unlocking')}
</p>
)}
{error && <div className="auth-error">{error}</div>}
{!loading && (
<div className="auth-actions" style={{ flexDirection: 'column', gap: '10px', marginTop: '16px' }}>
{restoreUser && passkeyHostOk && (
<button
type="button"
className="btn primary"
onClick={() => handleLogin(restoreUser)}
style={{ width: '100%' }}
>
{t('auth.restore_with_passkey', { name: restoreUser })}
</button>
)}
{restoreUser && hasLocalPin(restoreUser) && (
<button
type="button"
className="btn secondary"
onClick={() => {
setUsername(restoreUser)
setShowPinLogin(true)
}}
style={{ width: '100%' }}
>
{t('auth.restore_with_pin')}
</button>
)}
{restoreKnownUsers.length > 1 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', width: '100%' }}>
<span style={{ fontSize: '12px', color: '#64748b', textTransform: 'uppercase' }}>
{t('auth.quick_login')}
</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', width: '100%' }}>
{restoreKnownUsers.map((name) => (
<button
key={name}
type="button"
onClick={() => {
if (hasLocalPin(name)) {
setUsername(name)
setShowPinLogin(true)
} else {
void handleLogin(name)
}
}}
disabled={loading}
className="btn secondary"
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
>
<UserRound size={16} />
{name}
</button>
))}
</div>
</div>
)}
<button
type="button"
className="btn secondary"
onClick={() => {
setShowStandardLogin(true)
setError(null)
}}
style={{ width: '100%' }}
>
{t('auth.restore_other_account')}
</button>
</div>
)}
</div>
)
}
// Render 3: Standard Login / Registration options form // Render 3: Standard Login / Registration options form
return ( return (
<> <>
+141
View File
@@ -0,0 +1,141 @@
import React from 'react'
interface PersonSnapshot {
name: string
photo?: string | null
role?: string
}
interface CreatorAvatarProps {
creatorId?: string
crewSnapshotsById?: Record<string, PersonSnapshot>
fallbackName?: string
size?: number
}
const colors = [
'#2563eb', // blue
'#059669', // emerald
'#d97706', // amber
'#dc2626', // red
'#7c3aed', // violet
'#db2777', // pink
'#0891b2', // cyan
'#4f46e5', // indigo
'#0f766e', // teal
'#9333ea', // purple
]
function getAvatarColor(name: string): string {
let hash = 0
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash)
}
const index = Math.abs(hash) % colors.length
return colors[index]
}
export default function CreatorAvatar({
creatorId,
crewSnapshotsById,
fallbackName,
size = 28
}: CreatorAvatarProps) {
let name = ''
let photo: string | null = null
let role = ''
if (creatorId && crewSnapshotsById) {
let snap: PersonSnapshot | undefined = crewSnapshotsById[creatorId]
// Fallback: If not found directly by key, search by role or name or active user
if (!snap) {
if (creatorId === 'skipper') {
snap = Object.values(crewSnapshotsById).find((s) => s.role === 'skipper')
} else {
// Try to match name case-insensitively
snap = Object.values(crewSnapshotsById).find(
(s) => (s.name || '').trim().toLowerCase() === creatorId.trim().toLowerCase()
)
// Try to match active username/userid to the skipper snapshot
if (!snap) {
const activeUsername = localStorage.getItem('active_username')
const activeUserId = localStorage.getItem('active_userid')
if (
(activeUsername && creatorId.toLowerCase() === activeUsername.toLowerCase()) ||
(activeUserId && creatorId === activeUserId)
) {
snap = Object.values(crewSnapshotsById).find((s) => s.role === 'skipper')
}
}
}
}
if (snap) {
name = snap.name || ''
photo = snap.photo || null
role = snap.role || ''
}
}
// Fallback to active username if owner or no crew pool matches
if (!name) {
if (creatorId === 'skipper') {
name = fallbackName || localStorage.getItem('active_username') || 'Skipper'
role = 'skipper'
} else if (fallbackName) {
name = fallbackName
} else if (creatorId) {
// If creatorId is a username itself (fallback from LiveLogView)
name = creatorId
} else {
name = '?'
}
}
const initial = name ? name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '?' : '?'
const bgColor = name === '?' ? '#64748b' : getAvatarColor(name)
const style: React.CSSProperties = {
width: `${size}px`,
height: `${size}px`,
borderRadius: '50%',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: `${Math.round(size * 0.45)}px`,
fontWeight: 'bold',
color: '#ffffff',
backgroundColor: bgColor,
flexShrink: 0,
verticalAlign: 'middle',
overflow: 'hidden',
border: '1px solid rgba(255, 255, 255, 0.15)',
boxSizing: 'border-box'
}
const roleText = role ? (role === 'skipper' ? 'Skipper' : 'Crew') : ''
const tooltip = name + (roleText ? ` (${roleText})` : '')
if (photo) {
return (
<img
src={photo}
alt={name}
title={tooltip}
style={{
...style,
objectFit: 'cover',
backgroundColor: 'transparent'
}}
/>
)
}
return (
<div style={style} title={tooltip} className="creator-avatar-fallback">
{initial}
</div>
)
}
+1 -1
View File
@@ -818,7 +818,7 @@ export default function CrewForm({
<button className="btn-icon" onClick={() => openEditMember(m)} title="Edit"> <button className="btn-icon" onClick={() => openEditMember(m)} title="Edit">
<Edit2 size={14} /> <Edit2 size={14} />
</button> </button>
<button className="btn-icon logout" onClick={() => handleDeleteMember(m.payloadId)} title="Delete"> <button className="btn-icon danger" onClick={() => handleDeleteMember(m.payloadId)} title="Delete">
<Trash2 size={14} /> <Trash2 size={14} />
</button> </button>
</div> </div>
+3 -2
View File
@@ -6,6 +6,7 @@ import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js' import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js' import { syncLogbook } from '../services/sync.js'
import { Compass, Save, Check } from 'lucide-react' import { Compass, Save, Check } from 'lucide-react'
import { parseAppDecimalOrZero } from '../utils/numberFormat.js'
interface DeviationFormProps { interface DeviationFormProps {
logbookId: string logbookId: string
@@ -97,8 +98,8 @@ export default function DeviationForm({ logbookId, readOnly = false, preloadedDa
const sanitizedDeviations: Record<number, number> = {} const sanitizedDeviations: Record<number, number> = {}
headings.forEach((h) => { headings.forEach((h) => {
const val = deviations[h] || '' const val = deviations[h] || ''
const parsed = parseFloat(val.replace('+', '').trim()) const parsed = parseAppDecimalOrZero(val.replace('+', '').trim())
sanitizedDeviations[h] = isNaN(parsed) ? 0 : parsed sanitizedDeviations[h] = parsed
}) })
const dataToSave = { const dataToSave = {
+69 -44
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Users } from 'lucide-react' import { Users, ChevronDown, ChevronUp } from 'lucide-react'
import type { EntryCrewFields, PersonSnapshot } from '../types/person.js' import type { EntryCrewFields, PersonSnapshot } from '../types/person.js'
import { loadPersonPool } from '../services/personPool.js' import { loadPersonPool } from '../services/personPool.js'
import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js' import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js'
@@ -24,6 +24,7 @@ export default function EntryCrewSection({
preloadedPool preloadedPool
}: EntryCrewSectionProps) { }: EntryCrewSectionProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [collapsed, setCollapsed] = useState(true)
const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map()) const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map())
useEffect(() => { useEffect(() => {
@@ -90,54 +91,78 @@ export default function EntryCrewSection({
return ( return (
<div className="form-card" data-tour="entry-crew"> <div className="form-card" data-tour="entry-crew">
<div className="form-header"> <div
<Users size={22} className="form-icon" /> className="form-header accordion-header"
<h3>{t('entry_crew.title')}</h3> onClick={() => setCollapsed(!collapsed)}
</div> onKeyDown={(e) => {
<p className="help-text mb-3">{t('entry_crew.subtitle')}</p> if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
<div className="input-group mb-3"> setCollapsed(!collapsed)
<label>{t('entry_crew.day_skipper')}</label> }
{skippers.length === 0 ? ( }}
<p className="help-text">{t('entry_crew.no_skipper')}</p> role="button"
aria-expanded={!collapsed}
tabIndex={0}
>
<div className="accordion-header-title">
<Users size={22} className="form-icon" />
<h3>{t('entry_crew.title')}</h3>
</div>
{collapsed ? (
<ChevronDown size={20} className="accordion-chevron" />
) : ( ) : (
<div className="crew-selection-list"> <ChevronUp size={20} className="accordion-chevron" />
{skippers.map(([id, data]) => (
<label key={id} className="crew-selection-item">
<input
type="radio"
name={`entry-skipper-${logbookId}`}
checked={value.selectedSkipperId === id}
onChange={() => !readOnly && applyChange(id, value.selectedCrewIds)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)} )}
</div> </div>
<div className="input-group"> {!collapsed && (
<label>{t('entry_crew.day_crew')}</label> <>
{crewEntries.length === 0 ? ( <p className="help-text mb-3" style={{ marginTop: '16px' }}>{t('entry_crew.subtitle')}</p>
<p className="help-text">{t('entry_crew.no_crew')}</p>
) : ( <div className="input-group mb-3">
<div className="crew-selection-list"> <label>{t('entry_crew.day_skipper')}</label>
{crewEntries.map(([id, data]) => ( {skippers.length === 0 ? (
<label key={id} className="crew-selection-item"> <p className="help-text">{t('entry_crew.no_skipper')}</p>
<input ) : (
type="checkbox" <div className="crew-selection-list">
checked={value.selectedCrewIds.includes(id)} {skippers.map(([id, data]) => (
onChange={() => toggleCrew(id)} <label key={id} className="crew-selection-item">
disabled={readOnly} <input
/> type="radio"
<span>{data.name || t('logbook_crew.unnamed')}</span> name={`entry-skipper-${logbookId}`}
</label> checked={value.selectedSkipperId === id}
))} onChange={() => !readOnly && applyChange(id, value.selectedCrewIds)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)}
</div> </div>
)}
</div> <div className="input-group">
<label>{t('entry_crew.day_crew')}</label>
{crewEntries.length === 0 ? (
<p className="help-text">{t('entry_crew.no_crew')}</p>
) : (
<div className="crew-selection-list">
{crewEntries.map(([id, data]) => (
<label key={id} className="crew-selection-item">
<input
type="checkbox"
checked={value.selectedCrewIds.includes(id)}
onChange={() => toggleCrew(id)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)}
</div>
</>
)}
</div> </div>
) )
} }
+108 -8
View File
@@ -1,39 +1,139 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Mic, Loader2 } from 'lucide-react'
import type { LogEventPayload } from '../utils/logEntryPayload.js' import type { LogEventPayload } from '../utils/logEntryPayload.js'
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js' import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
import { formatEventSummary } from '../utils/formatEventSummary.js' import { formatEventSummary } from '../utils/formatEventSummary.js'
import VoiceMemoPlayer, { type PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx' import VoiceMemoPlayer, { type PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx'
import { useDialog } from './ModalDialog.tsx'
import { updateVoiceMemoTranscript } from '../services/voiceAttachments.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getAiAuthorized } from '../services/userPreferences.js'
interface EventRemarksCellProps { interface EventRemarksCellProps {
event: LogEventPayload event: LogEventPayload
logbookId: string logbookId: string
voiceMemoLookup?: Map<string, PreloadedVoiceMemo> voiceMemoLookup?: Map<string, PreloadedVoiceMemo>
readOnly?: boolean
} }
export default function EventRemarksCell({ export default function EventRemarksCell({
event, event,
logbookId, logbookId,
voiceMemoLookup voiceMemoLookup,
readOnly = false
}: EventRemarksCellProps) { }: EventRemarksCellProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { showAlert } = useDialog()
const voiceId = parseLiveVoiceRemark(event.remarks.trim()) const voiceId = parseLiveVoiceRemark(event.remarks.trim())
const preloaded = voiceId ? voiceMemoLookup?.get(voiceId) : undefined const preloaded = voiceId ? voiceMemoLookup?.get(voiceId) : undefined
const [transcribing, setTranscribing] = useState(false)
const [isOnline, setIsOnline] = useState(navigator.onLine)
useEffect(() => {
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
const handleTranscribe = async (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (transcribing || !preloaded?.audio || !voiceId) return
if (!getAiAuthorized()) {
void showAlert(
t('profile.ai_unauthorized_alert_desc'),
t('profile.ai_unauthorized_alert_title')
)
return
}
setTranscribing(true)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 15000)
try {
const res = await fetch('/api/ai/transcribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ audioDataUrl: preloaded.audio }),
signal: controller.signal
})
clearTimeout(timeoutId)
if (!res.ok) {
throw new Error(`Server returned status ${res.status}`)
}
const data = await res.json()
const text = (data.text || '').trim()
if (!text) {
throw new Error('Transcription returned empty text')
}
await updateVoiceMemoTranscript(logbookId, voiceId, text)
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
status: 'success',
mode: 'manual'
})
} catch (err) {
clearTimeout(timeoutId)
console.error('[EventRemarksCell] Transcription failed:', err)
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
status: 'failed',
mode: 'manual'
})
void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn'))
} finally {
setTranscribing(false)
}
}
let summary = formatEventSummary(event, t) let summary = formatEventSummary(event, t)
if (voiceId && preloaded?.caption) { if (voiceId && preloaded?.caption) {
summary = t('logs.live_voice_entry', { caption: preloaded.caption }) summary = t('logs.live_voice_entry', { caption: preloaded.caption })
} }
return ( return (
<div className="event-remarks-cell"> <div className={`event-remarks-cell${voiceId ? ' event-remarks-cell--voice' : ''}`}>
<span>{summary}</span> <span>{summary}</span>
{voiceId && ( {voiceId && (
<VoiceMemoPlayer <div style={{ display: 'inline-flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px', marginTop: '4px' }}>
audioId={voiceId} <VoiceMemoPlayer
logbookId={logbookId} audioId={voiceId}
preloaded={preloaded} logbookId={logbookId}
compact preloaded={preloaded}
/> compact
/>
{!readOnly && preloaded && preloaded.transcribed === false && isOnline && (
<button
type="button"
className="btn-icon-text link-sec"
style={{
fontSize: '0.8rem',
padding: '2px 6px',
height: 'auto',
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
margin: 0
}}
onClick={handleTranscribe}
disabled={transcribing}
title={t('logs.live_voice_transcribe_action')}
>
{transcribing ? (
<Loader2 size={12} className="spin" />
) : (
<Mic size={12} />
)}
{transcribing ? t('logs.live_voice_transcribing') : t('logs.live_voice_transcribe_action')}
</button>
)}
</div>
)} )}
</div> </div>
) )
+47
View File
@@ -0,0 +1,47 @@
import { useTranslation } from 'react-i18next'
import { Signal } from 'lucide-react'
import {
formatGpsAccuracyMeters,
gpsQualityI18nKey,
type GpsSignalQuality
} from '../utils/geolocation.js'
const SIGNAL_BARS: Record<GpsSignalQuality, number> = {
excellent: 4,
good: 3,
fair: 2,
poor: 1,
unknown: 0
}
interface GpsSignalHintProps {
quality: GpsSignalQuality
accuracyM: number | null
className?: string
}
export default function GpsSignalHint({ quality, accuracyM, className = '' }: GpsSignalHintProps) {
const { t } = useTranslation()
const bars = SIGNAL_BARS[quality]
const i18nParams = accuracyM != null ? { accuracy: formatGpsAccuracyMeters(accuracyM) } : undefined
return (
<p
className={`gps-signal-hint gps-signal-${quality} ${className}`.trim()}
role="status"
>
<span className="gps-signal-hint-label">
<Signal size={14} aria-hidden className="gps-signal-icon" />
<span className="gps-signal-bars" aria-hidden>
{[1, 2, 3, 4].map((level) => (
<span
key={level}
className={`gps-signal-bar ${level <= bars ? 'is-active' : ''}`}
/>
))}
</span>
<span>{t(gpsQualityI18nKey(quality), i18nParams)}</span>
</span>
</p>
)
}
+45 -15
View File
@@ -1,6 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Camera, X } from 'lucide-react' import { Camera, X } from 'lucide-react'
import {
cameraErrorKeyFromDomException,
probeCameraAvailability
} from '../utils/cameraAvailability.js'
import { import {
captureVideoFrame, captureVideoFrame,
preferNativeCameraPicker preferNativeCameraPicker
@@ -15,7 +19,7 @@ interface LiveCameraCaptureProps {
onCapture: (blob: Blob) => void onCapture: (blob: Blob) => void
} }
type Phase = 'live' | 'preview' | 'native' type Phase = 'checking' | 'live' | 'preview' | 'native'
export default function LiveCameraCapture({ export default function LiveCameraCapture({
open, open,
@@ -34,7 +38,7 @@ export default function LiveCameraCapture({
const [cameraError, setCameraError] = useState<string | null>(null) const [cameraError, setCameraError] = useState<string | null>(null)
const [ready, setReady] = useState(false) const [ready, setReady] = useState(false)
const [capturing, setCapturing] = useState(false) const [capturing, setCapturing] = useState(false)
const [phase, setPhase] = useState<Phase>(() => (preferNativeCameraPicker() ? 'native' : 'live')) const [phase, setPhase] = useState<Phase>('checking')
const [previewUrl, setPreviewUrl] = useState<string | null>(null) const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null) const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
const [streamGeneration, setStreamGeneration] = useState(0) const [streamGeneration, setStreamGeneration] = useState(0)
@@ -87,12 +91,37 @@ export default function LiveCameraCapture({
clearPreview() clearPreview()
setCameraError(null) setCameraError(null)
setCapturing(false) setCapturing(false)
setPhase(preferNativeCameraPicker() ? 'native' : 'live') setPhase('checking')
return return
} }
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
let cancelled = false
clearPreview() clearPreview()
}, [open, stopStream, clearPreview]) setCameraError(null)
setCapturing(false)
setPhase('checking')
const probe = async () => {
const availability = await probeCameraAvailability()
if (cancelled) return
if (availability === 'unsupported') {
setCameraError(t('logs.live_photo_camera_unavailable'))
return
}
if (availability === 'none') {
setCameraError(t('logs.live_photo_no_camera'))
return
}
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
}
void probe()
return () => {
cancelled = true
}
}, [open, clearPreview, stopStream, t])
useEffect(() => { useEffect(() => {
if (!open || phase !== 'live') { if (!open || phase !== 'live') {
@@ -105,11 +134,6 @@ export default function LiveCameraCapture({
const start = async () => { const start = async () => {
setCameraError(null) setCameraError(null)
setReady(false) setReady(false)
if (!navigator.mediaDevices?.getUserMedia) {
setCameraError(t('logs.live_photo_camera_unavailable'))
return
}
try { try {
const stream = await navigator.mediaDevices.getUserMedia({ const stream = await navigator.mediaDevices.getUserMedia({
video: { video: {
@@ -141,7 +165,7 @@ export default function LiveCameraCapture({
} catch (err) { } catch (err) {
console.error('Camera access failed:', err) console.error('Camera access failed:', err)
if (!cancelled) { if (!cancelled) {
setCameraError(t('logs.live_photo_camera_denied')) setCameraError(t(cameraErrorKeyFromDomException(err)))
} }
} }
} }
@@ -216,7 +240,7 @@ export default function LiveCameraCapture({
className="btn secondary live-camera-close" className="btn secondary live-camera-close"
onClick={onClose} onClick={onClose}
disabled={busy} disabled={busy}
aria-label={t('logs.confirm_no')} aria-label={t('logs.live_cancel')}
> >
<X size={18} /> <X size={18} />
</button> </button>
@@ -243,6 +267,12 @@ export default function LiveCameraCapture({
className="live-camera-preview live-camera-preview-still" className="live-camera-preview live-camera-preview-still"
/> />
</div> </div>
) : cameraError ? (
<div className="live-camera-preview-wrap">
<p className="live-camera-loading">{cameraError}</p>
</div>
) : phase === 'checking' ? (
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
) : phase === 'native' ? ( ) : phase === 'native' ? (
<div className="live-camera-native-prompt"> <div className="live-camera-native-prompt">
<p className="live-log-modal-hint">{t('logs.live_photo_native_hint')}</p> <p className="live-log-modal-hint">{t('logs.live_photo_native_hint')}</p>
@@ -256,7 +286,7 @@ export default function LiveCameraCapture({
{t('logs.live_photo_open_camera_btn')} {t('logs.live_photo_open_camera_btn')}
</button> </button>
</div> </div>
) : cameraError && !ready ? null : ( ) : phase === 'live' ? (
<div className="live-camera-preview-wrap"> <div className="live-camera-preview-wrap">
<video <video
ref={videoRef} ref={videoRef}
@@ -269,7 +299,7 @@ export default function LiveCameraCapture({
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p> <p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
)} )}
</div> </div>
)} ) : null}
{onCaptionChange && ( {onCaptionChange && (
<div className="input-group live-camera-caption"> <div className="input-group live-camera-caption">
@@ -287,7 +317,7 @@ export default function LiveCameraCapture({
<div className="live-log-modal-actions live-camera-actions"> <div className="live-log-modal-actions live-camera-actions">
<button type="button" className="btn secondary" onClick={onClose} disabled={busy}> <button type="button" className="btn secondary" onClick={onClose} disabled={busy}>
{t('logs.confirm_no')} {t('logs.live_cancel')}
</button> </button>
{showPreview ? ( {showPreview ? (
+349 -125
View File
@@ -22,19 +22,20 @@ import {
Zap Zap
} from 'lucide-react' } from 'lucide-react'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getAiAuthorized } from '../services/userPreferences.js'
import { import {
appendQuickEvent, appendQuickEvent as apiAppendQuickEvent,
appendQuickEvents, appendQuickEvents as apiAppendQuickEvents,
appendTankRefill, appendTankRefill as apiAppendTankRefill,
findOrCreateTodayEntry, findOrCreateTodayEntry,
loadEntry, loadEntry,
removeLastEvent removeLastEvent
} from '../services/quickEventLog.js' } from '../services/quickEventLog.js'
import { formatEventSummary } from '../utils/formatEventSummary.js' import CreatorAvatar from './CreatorAvatar.tsx'
import { import {
getLastAutoPositionMs, getLastAutoPositionMs,
getLastPositionFixWithin, getLastLoggedPositionWithin,
getLatestPositionFix, getLatestLoggedPosition,
isMotorRunningFromEvents, isMotorRunningFromEvents,
LIVE_EVENT_CODES, LIVE_EVENT_CODES,
LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS,
@@ -42,7 +43,6 @@ import {
liveFuelRemark, liveFuelRemark,
livePhotoRemark, livePhotoRemark,
liveVoiceRemark, liveVoiceRemark,
parseLiveVoiceRemark,
livePrecipRemark, livePrecipRemark,
liveSailsRemark, liveSailsRemark,
liveSogRemark, liveSogRemark,
@@ -50,12 +50,22 @@ import {
liveTempRemark, liveTempRemark,
liveWaterRemark liveWaterRemark
} from '../utils/liveEventCodes.js' } from '../utils/liveEventCodes.js'
import { formatAppDecimal, formatTankLiters, parseAppDecimal } from '../utils/numberFormat.js'
const formatSpeedKn = (speedKn: number) =>
formatAppDecimal(speedKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js' import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js' import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
import { import {
geolocationErrorI18nKey,
getCurrentPosition, getCurrentPosition,
getGeolocationErrorReason,
hasSeenGeolocationLiveIntro,
markGeolocationLiveIntroSeen,
normalizeGpsCoordinates, normalizeGpsCoordinates,
queryGeolocationPermission queryGeolocationPermission,
type GeolocationErrorReason,
type GpsSignalQuality
} from '../utils/geolocation.js' } from '../utils/geolocation.js'
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js' import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
import { import {
@@ -66,9 +76,10 @@ import {
} from '../utils/sailSelection.js' } from '../utils/sailSelection.js'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import CourseDialInput from './CourseDialInput.tsx' import CourseDialInput from './CourseDialInput.tsx'
import GpsSignalHint from './GpsSignalHint.tsx'
import LiveCameraCapture from './LiveCameraCapture.tsx' import LiveCameraCapture from './LiveCameraCapture.tsx'
import LiveVoiceCapture from './LiveVoiceCapture.tsx' import LiveVoiceCapture from './LiveVoiceCapture.tsx'
import VoiceMemoPlayer from './VoiceMemoPlayer.tsx' import EventRemarksCell from './EventRemarksCell.tsx'
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js' import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
import { saveEntryVoiceMemo, deleteEntryVoiceMemo } from '../services/voiceAttachments.js' import { saveEntryVoiceMemo, deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js' import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
@@ -96,7 +107,7 @@ type LiveModal =
| 'water' | 'water'
| 'sog' | 'sog'
| 'stw' | 'stw'
| 'fix' | 'position'
| 'photo' | 'photo'
| 'voice' | 'voice'
@@ -142,18 +153,46 @@ function lastWindDirectionFromEvents(events: LogEventPayload[]): string {
return '' return ''
} }
function gpsFailureAlertBody(
t: (key: string) => string,
reason: GeolocationErrorReason
): string {
return `${t(geolocationErrorI18nKey(reason))}\n\n${t('logs.live_position_manual_hint')}`
}
function findActiveCreatorId(
activeUsername: string | null,
crewSnapshotsById: Record<string, any>,
selectedSkipperId: string | null
): string {
const username = (activeUsername || '').trim()
if (username) {
const matchEntry = Object.entries(crewSnapshotsById).find(
([_, snap]) => (snap?.name || '').trim().toLowerCase() === username.toLowerCase()
)
if (matchEntry) {
return matchEntry[0]
}
return username
}
return selectedSkipperId || 'skipper'
}
export default function LiveLogView({ export default function LiveLogView({
logbookId, logbookId,
onOpenEditor, onOpenEditor,
onSwitchToList onSwitchToList
}: LiveLogViewProps) { }: LiveLogViewProps) {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const { showAlert } = useDialog() const { showAlert, showConfirm } = useDialog()
const [geolocationAccessEpoch, setGeolocationAccessEpoch] = useState(0)
const [entryId, setEntryId] = useState<string | null>(null) const [entryId, setEntryId] = useState<string | null>(null)
const [dayOfTravel, setDayOfTravel] = useState('') const [dayOfTravel, setDayOfTravel] = useState('')
const [date, setDate] = useState('') const [date, setDate] = useState('')
const [events, setEvents] = useState<LogEventPayload[]>([]) const [events, setEvents] = useState<LogEventPayload[]>([])
const [crewSnapshotsById, setCrewSnapshotsById] = useState<Record<string, any>>({})
const [selectedSkipperId, setSelectedSkipperId] = useState<string | null>(null)
const [yachtSails, setYachtSails] = useState<string[]>([]) const [yachtSails, setYachtSails] = useState<string[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
@@ -167,10 +206,15 @@ export default function LiveLogView({
const [valueInputSecondary, setValueInputSecondary] = useState('') const [valueInputSecondary, setValueInputSecondary] = useState('')
const [selectedSails, setSelectedSails] = useState<string[]>([]) const [selectedSails, setSelectedSails] = useState<string[]>([])
const [undoVisible, setUndoVisible] = useState(false) const [undoVisible, setUndoVisible] = useState(false)
const [fixLat, setFixLat] = useState('') const [positionLat, setPositionLat] = useState('')
const [fixLng, setFixLng] = useState('') const [positionLng, setPositionLng] = useState('')
const [fixGpsLoading, setFixGpsLoading] = useState(false) const [positionGpsLoading, setPositionGpsLoading] = useState(false)
const [fixGpsUnavailable, setFixGpsUnavailable] = useState(false) const [positionGpsUnavailable, setPositionGpsUnavailable] = useState(false)
const [positionGpsErrorReason, setPositionGpsErrorReason] = useState<GeolocationErrorReason | null>(null)
const [positionGpsSignal, setPositionGpsSignal] = useState<{
quality: GpsSignalQuality
accuracyM: number | null
} | null>(null)
const [photoCaption, setPhotoCaption] = useState('') const [photoCaption, setPhotoCaption] = useState('')
const [photoSaving, setPhotoSaving] = useState(false) const [photoSaving, setPhotoSaving] = useState(false)
const [voiceCaption, setVoiceCaption] = useState('') const [voiceCaption, setVoiceCaption] = useState('')
@@ -190,6 +234,51 @@ export default function LiveLogView({
dateRef.current = date dateRef.current = date
busyRef.current = busy busyRef.current = busy
const getActiveCreatorId = useCallback(() => {
const activeUsername = localStorage.getItem('active_username')
return findActiveCreatorId(activeUsername, crewSnapshotsById, selectedSkipperId)
}, [crewSnapshotsById, selectedSkipperId])
const appendQuickEvent = useCallback((
logbookId: string,
entryId: string,
partialEvent: Partial<LogEventPayload>,
headerPatch?: { departure?: string; destination?: string }
) => {
return apiAppendQuickEvent(
logbookId,
entryId,
{ creatorId: getActiveCreatorId(), ...partialEvent },
headerPatch
)
}, [getActiveCreatorId])
const appendQuickEvents = useCallback((
logbookId: string,
entryId: string,
partialEvents: Partial<LogEventPayload>[]
) => {
const creatorId = getActiveCreatorId()
const mapped = partialEvents.map((p) => ({ creatorId, ...p }))
return apiAppendQuickEvents(logbookId, entryId, mapped)
}, [getActiveCreatorId])
const appendTankRefill = useCallback((
logbookId: string,
entryId: string,
tank: 'fuel' | 'freshwater',
addLiters: number,
event: Partial<LogEventPayload>
) => {
return apiAppendTankRefill(
logbookId,
entryId,
tank,
addLiters,
{ creatorId: getActiveCreatorId(), ...event }
)
}, [getActiveCreatorId])
const defaultSails = useMemo( const defaultSails = useMemo(
() => (i18n.language === 'de' () => (i18n.language === 'de'
? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker'] ? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker']
@@ -202,8 +291,8 @@ export default function LiveLogView({
) )
const motorRunning = isMotorRunningFromEvents(events) const motorRunning = isMotorRunningFromEvents(events)
const motorLabel = t('logs.motor_propulsion') const motorLabel = t('logs.motor_propulsion')
const hasPositionFix = useMemo( const hasLoggedPosition = useMemo(
() => (date ? getLatestPositionFix(events, date) != null : false), () => (date ? getLatestLoggedPosition(events, date) != null : false),
[events, date] [events, date]
) )
const voiceMemoLookup = useEntryVoiceMemos(logbookId, entryId) const voiceMemoLookup = useEntryVoiceMemos(logbookId, entryId)
@@ -213,6 +302,8 @@ export default function LiveLogView({
setDayOfTravel(String(loaded.data.dayOfTravel || '')) setDayOfTravel(String(loaded.data.dayOfTravel || ''))
setDate(String(loaded.data.date || '')) setDate(String(loaded.data.date || ''))
setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e })))) setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e }))))
setCrewSnapshotsById((loaded.data.crewSnapshotsById as Record<string, any>) || {})
setSelectedSkipperId(typeof loaded.data.selectedSkipperId === 'string' ? loaded.data.selectedSkipperId : null)
}, []) }, [])
const refreshEntry = useCallback(async (id: string) => { const refreshEntry = useCallback(async (id: string) => {
@@ -310,6 +401,56 @@ export default function LiveLogView({
} }
}, [loading, entryId]) }, [loading, entryId])
useEffect(() => {
if (loading || !entryId || !navigator.geolocation) return
let cancelled = false
void (async () => {
const permission = await queryGeolocationPermission()
if (cancelled) return
if (permission === 'granted') {
markGeolocationLiveIntroSeen()
setGeolocationAccessEpoch((n) => n + 1)
return
}
// Only ask when the browser has not granted location yet (state "prompt").
if (permission !== 'prompt' || hasSeenGeolocationLiveIntro()) return
const allow = await showConfirm(
t('logs.gps_live_intro_body'),
t('logs.gps_live_intro_title'),
t('logs.gps_live_intro_allow'),
t('logs.gps_live_intro_later')
)
markGeolocationLiveIntroSeen()
if (cancelled || !allow) return
try {
await getCurrentPosition({
timeoutMs: 15_000,
enableHighAccuracy: false,
maximumAge: 0
})
if (!cancelled) setGeolocationAccessEpoch((n) => n + 1)
} catch (err) {
const reason = getGeolocationErrorReason(err)
if (reason === 'permission_denied') {
await showAlert(
`${t('logs.gps_permission_denied')}\n\n${t('logs.gps_enable_in_settings_hint')}`,
t('logs.live_title')
)
}
}
})()
return () => {
cancelled = true
}
}, [loading, entryId, showAlert, showConfirm, t])
useEffect(() => { useEffect(() => {
streamEndRef.current?.scrollIntoView({ behavior: 'smooth' }) streamEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [events.length]) }, [events.length])
@@ -358,7 +499,7 @@ export default function LiveLogView({
}) })
await refreshEntry(entryId) await refreshEntry(entryId)
} catch { } catch {
// Best-effort; hint banner shows when no position fix exists yet. // Best-effort; hint banner shows when no position has been logged yet.
} finally { } finally {
autoPositionBusyRef.current = false autoPositionBusyRef.current = false
} }
@@ -377,7 +518,7 @@ export default function LiveLogView({
if (startTimer !== undefined) window.clearTimeout(startTimer) if (startTimer !== undefined) window.clearTimeout(startTimer)
if (intervalRef !== undefined) window.clearInterval(intervalRef) if (intervalRef !== undefined) window.clearInterval(intervalRef)
} }
}, [entryId, loading, logbookId, refreshEntry]) }, [entryId, loading, logbookId, refreshEntry, geolocationAccessEpoch])
const runQuickAction = async ( const runQuickAction = async (
action: () => Promise<boolean | void>, action: () => Promise<boolean | void>,
@@ -453,16 +594,26 @@ export default function LiveLogView({
}, 'moor') }, 'moor')
} }
const openFixModal = async () => { const reportPositionGpsFailure = async (reason: GeolocationErrorReason) => {
setFixLat('') setPositionGpsUnavailable(true)
setFixLng('') setPositionGpsErrorReason(reason)
setFixGpsUnavailable(false) setPositionGpsSignal(null)
setFixGpsLoading(true) await showAlert(gpsFailureAlertBody(t, reason), t('logs.live_position'))
setModal('fix') }
const openPositionModal = async () => {
setPositionLat('')
setPositionLng('')
setPositionGpsUnavailable(false)
setPositionGpsErrorReason(null)
setPositionGpsSignal(null)
setPositionGpsLoading(true)
setModal('position')
try { try {
const permission = await queryGeolocationPermission() const permission = await queryGeolocationPermission()
if (permission !== 'granted') { if (permission !== 'granted') {
setFixGpsUnavailable(true) const reason = permission === 'denied' ? 'permission_denied' : 'unavailable'
await reportPositionGpsFailure(reason)
return return
} }
const coords = await getCurrentPosition({ const coords = await getCurrentPosition({
@@ -470,26 +621,26 @@ export default function LiveLogView({
enableHighAccuracy: false, enableHighAccuracy: false,
maximumAge: 60_000 maximumAge: 60_000
}) })
setFixLat(coords.lat) setPositionLat(coords.lat)
setFixLng(coords.lng) setPositionLng(coords.lng)
} catch { setPositionGpsSignal({ quality: coords.signalQuality, accuracyM: coords.accuracyM })
setFixGpsUnavailable(true) } catch (err) {
await reportPositionGpsFailure(getGeolocationErrorReason(err))
} finally { } finally {
setFixGpsLoading(false) setPositionGpsLoading(false)
} }
} }
const retryFixGps = async () => { const retryPositionGps = async () => {
setFixGpsLoading(true) setPositionGpsLoading(true)
setFixGpsUnavailable(false) setPositionGpsUnavailable(false)
setPositionGpsErrorReason(null)
setPositionGpsSignal(null)
try { try {
const permission = await queryGeolocationPermission() const permission = await queryGeolocationPermission()
if (permission !== 'granted') { if (permission !== 'granted') {
setFixGpsUnavailable(true) const reason = permission === 'denied' ? 'permission_denied' : 'unavailable'
await showAlert( await reportPositionGpsFailure(reason)
`${t('logs.live_gps_error')}\n\n${t('logs.live_gps_start_hint')}`,
t('logs.live_fix')
)
return return
} }
const coords = await getCurrentPosition({ const coords = await getCurrentPosition({
@@ -497,23 +648,21 @@ export default function LiveLogView({
enableHighAccuracy: false, enableHighAccuracy: false,
maximumAge: 60_000 maximumAge: 60_000
}) })
setFixLat(coords.lat) setPositionLat(coords.lat)
setFixLng(coords.lng) setPositionLng(coords.lng)
} catch { setPositionGpsUnavailable(false)
setFixGpsUnavailable(true) setPositionGpsSignal({ quality: coords.signalQuality, accuracyM: coords.accuracyM })
await showAlert( } catch (err) {
`${t('logs.live_gps_error')}\n\n${t('logs.live_gps_start_hint')}`, await reportPositionGpsFailure(getGeolocationErrorReason(err))
t('logs.live_fix')
)
} finally { } finally {
setFixGpsLoading(false) setPositionGpsLoading(false)
} }
} }
const confirmFix = () => { const confirmPosition = () => {
const coords = normalizeGpsCoordinates(fixLat, fixLng) const coords = normalizeGpsCoordinates(positionLat, positionLng)
if (!coords) { if (!coords) {
void showAlert(t('logs.live_fix_invalid'), t('logs.live_fix')) void showAlert(t('logs.live_position_invalid'), t('logs.live_position'))
return return
} }
setModal('none') setModal('none')
@@ -522,9 +671,9 @@ export default function LiveLogView({
await appendQuickEvent(logbookId, entryId, { await appendQuickEvent(logbookId, entryId, {
gpsLat: coords.lat, gpsLat: coords.lat,
gpsLng: coords.lng, gpsLng: coords.lng,
remarks: LIVE_EVENT_CODES.FIX remarks: LIVE_EVENT_CODES.POSITION
}) })
}, 'fix') }, 'position')
} }
const handleFetchOwmWeather = () => { const handleFetchOwmWeather = () => {
@@ -534,17 +683,17 @@ export default function LiveLogView({
return return
} }
const position = getLastPositionFixWithin( const position = getLastLoggedPositionWithin(
events, events,
date, date,
LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS
) )
if (!position) { if (!position) {
const latest = getLatestPositionFix(events, date) const latest = getLatestLoggedPosition(events, date)
void showAlert( void showAlert(
latest latest
? t('logs.live_weather_fix_stale') ? t('logs.live_weather_position_stale')
: t('logs.live_weather_fix_required'), : t('logs.live_weather_position_required'),
t('logs.live_weather_owm_btn') t('logs.live_weather_owm_btn')
) )
return return
@@ -563,13 +712,27 @@ export default function LiveLogView({
{ analyticsSource: 'live_log' } { analyticsSource: 'live_log' }
) )
} catch (err) { } catch (err) {
if (err instanceof WeatherApiError && err.code === 'OFFLINE') { if (err instanceof WeatherApiError) {
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn')) if (err.code === 'OFFLINE') {
return void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
} return
if (err instanceof WeatherApiError && err.code === 'NO_KEY') { }
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn')) if (err.code === 'NO_KEY') {
return void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
return
}
if (err.code === 'UNAUTHORIZED') {
void showAlert(t('settings.weather_unauthorized'), t('logs.live_weather_owm_btn'))
return
}
if (err.code === 'NOT_FOUND') {
void showAlert(t('settings.weather_not_found'), t('logs.live_weather_owm_btn'))
return
}
if (err.code === 'BAD_REQUEST') {
void showAlert(t('settings.weather_bad_request'), t('logs.live_weather_owm_btn'))
return
}
} }
console.error('Live log OWM weather failed:', err) console.error('Live log OWM weather failed:', err)
void showAlert(t('settings.weather_error'), t('logs.live_weather_owm_btn')) void showAlert(t('settings.weather_error'), t('logs.live_weather_owm_btn'))
@@ -672,13 +835,50 @@ export default function LiveLogView({
void (async () => { void (async () => {
try { try {
const audioDataUrl = await blobToAudioDataUrl(blob) const audioDataUrl = await blobToAudioDataUrl(blob)
const authorized = getAiAuthorized()
let transcriptionText = ''
let transcribed = true
let transcriptionError = false
if (authorized) {
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 4000)
const res = await fetch('/api/ai/transcribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ audioDataUrl }),
signal: controller.signal
})
clearTimeout(timeoutId)
if (!res.ok) throw new Error(`Status ${res.status}`)
const data = await res.json()
transcriptionText = (data.text || '').trim()
} catch (err) {
console.warn('[LiveLogView] Automatic transcription failed or timed out:', err)
transcriptionError = true
transcribed = false
}
} else {
transcribed = false
}
let finalCaption = caption
if (transcriptionText) {
finalCaption = caption
? `${caption}\n(Transkript: ${transcriptionText})`
: transcriptionText
}
const voiceId = await saveEntryVoiceMemo({ const voiceId = await saveEntryVoiceMemo({
logbookId, logbookId,
entryId, entryId,
audioDataUrl, audioDataUrl,
mimeType, mimeType,
durationSec, durationSec,
caption, caption: finalCaption,
transcribed,
analyticsContext: 'live_log' analyticsContext: 'live_log'
}) })
await appendQuickEvent(logbookId, entryId, { await appendQuickEvent(logbookId, entryId, {
@@ -690,6 +890,23 @@ export default function LiveLogView({
setVoiceCaption('') setVoiceCaption('')
showUndo('voice') showUndo('voice')
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'voice' }) trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'voice' })
if (transcriptionError) {
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
status: 'failed',
mode: 'auto'
})
void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn'))
} else if (authorized) {
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
status: 'success',
mode: 'auto'
})
} else {
void showAlert(
t('profile.ai_unauthorized_alert_desc'),
t('profile.ai_unauthorized_alert_title')
)
}
} catch (err: unknown) { } catch (err: unknown) {
console.error('Live log voice save failed:', err) console.error('Live log voice save failed:', err)
const msg = err instanceof Error && err.message === 'VOICE_MEMO_TOO_LARGE' const msg = err instanceof Error && err.message === 'VOICE_MEMO_TOO_LARGE'
@@ -843,45 +1060,45 @@ export default function LiveLogView({
break break
} }
case 'fuel': { case 'fuel': {
const liters = parseFloat(primary) const liters = parseAppDecimal(primary)
if (!Number.isFinite(liters) || liters <= 0) return if (liters == null || liters <= 0) return
setModal('none') setModal('none')
void runQuickAction(async () => { void runQuickAction(async () => {
await appendTankRefill(logbookId, entryId, 'fuel', liters, { await appendTankRefill(logbookId, entryId, 'fuel', liters, {
remarks: liveFuelRemark(String(liters)) remarks: liveFuelRemark(formatTankLiters(liters))
}) })
}, 'fuel') }, 'fuel')
break break
} }
case 'water': { case 'water': {
const liters = parseFloat(primary) const liters = parseAppDecimal(primary)
if (!Number.isFinite(liters) || liters <= 0) return if (liters == null || liters <= 0) return
setModal('none') setModal('none')
void runQuickAction(async () => { void runQuickAction(async () => {
await appendTankRefill(logbookId, entryId, 'freshwater', liters, { await appendTankRefill(logbookId, entryId, 'freshwater', liters, {
remarks: liveWaterRemark(String(liters)) remarks: liveWaterRemark(formatTankLiters(liters))
}) })
}, 'water') }, 'water')
break break
} }
case 'sog': { case 'sog': {
const speedKn = parseFloat(primary.replace(',', '.')) const speedKn = parseAppDecimal(primary)
if (!Number.isFinite(speedKn) || speedKn < 0) return if (speedKn == null || speedKn < 0) return
setModal('none') setModal('none')
void runQuickAction(async () => { void runQuickAction(async () => {
await appendQuickEvent(logbookId, entryId, { await appendQuickEvent(logbookId, entryId, {
remarks: liveSogRemark(String(speedKn)) remarks: liveSogRemark(formatSpeedKn(speedKn))
}) })
}, 'sog') }, 'sog')
break break
} }
case 'stw': { case 'stw': {
const speedKn = parseFloat(primary.replace(',', '.')) const speedKn = parseAppDecimal(primary)
if (!Number.isFinite(speedKn) || speedKn < 0) return if (speedKn == null || speedKn < 0) return
setModal('none') setModal('none')
void runQuickAction(async () => { void runQuickAction(async () => {
await appendQuickEvent(logbookId, entryId, { await appendQuickEvent(logbookId, entryId, {
remarks: liveStwRemark(String(speedKn)) remarks: liveStwRemark(formatSpeedKn(speedKn))
}) })
}, 'stw') }, 'stw')
break break
@@ -945,7 +1162,7 @@ export default function LiveLogView({
{error && <div className="auth-error mb-4">{error}</div>} {error && <div className="auth-error mb-4">{error}</div>}
{!hasPositionFix && ( {!hasLoggedPosition && (
<p className="live-log-gps-hint" role="status"> <p className="live-log-gps-hint" role="status">
<MapPin size={16} aria-hidden /> <MapPin size={16} aria-hidden />
{t('logs.live_gps_start_hint')} {t('logs.live_gps_start_hint')}
@@ -1036,9 +1253,9 @@ export default function LiveLogView({
)} )}
</div> </div>
<button type="button" className="live-log-action-btn" onClick={() => void openFixModal()} disabled={busy}> <button type="button" className="live-log-action-btn" onClick={() => void openPositionModal()} disabled={busy}>
<MapPin size={18} /> <MapPin size={18} />
{t('logs.live_fix')} {t('logs.live_position')}
</button> </button>
<button type="button" className="live-log-action-btn" onClick={() => { setCommentText(''); setModal('comment') }} disabled={busy}> <button type="button" className="live-log-action-btn" onClick={() => { setCommentText(''); setModal('comment') }} disabled={busy}>
<MessageSquare size={18} /> <MessageSquare size={18} />
@@ -1061,25 +1278,21 @@ export default function LiveLogView({
) : ( ) : (
<ol className="live-log-stream"> <ol className="live-log-stream">
{events.map((event, index) => { {events.map((event, index) => {
const voiceId = parseLiveVoiceRemark(event.remarks.trim())
const voicePreloaded = voiceId ? voiceMemoLookup.get(voiceId) : undefined
let summary = formatEventSummary(event, t)
if (voiceId && voicePreloaded?.caption) {
summary = t('logs.live_voice_entry', { caption: voicePreloaded.caption })
}
return ( return (
<li key={`${event.time}-${index}`} className="live-log-entry"> <li key={`${event.time}-${index}`} className="live-log-entry">
<time className="live-log-time">{event.time}</time> <time className="live-log-time">{event.time}</time>
<CreatorAvatar
creatorId={event.creatorId}
crewSnapshotsById={crewSnapshotsById}
size={24}
/>
<div className="live-log-summary-block"> <div className="live-log-summary-block">
<span className="live-log-summary">{summary}</span> <EventRemarksCell
{voiceId && ( event={event}
<VoiceMemoPlayer logbookId={logbookId}
audioId={voiceId} voiceMemoLookup={voiceMemoLookup}
logbookId={logbookId} readOnly={false}
preloaded={voicePreloaded} />
compact
/>
)}
</div> </div>
</li> </li>
) )
@@ -1145,7 +1358,7 @@ export default function LiveLogView({
</p> </p>
)} )}
<div className="live-log-modal-actions"> <div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={closeModal}>{t('logs.confirm_no')}</button> <button type="button" className="btn secondary" onClick={closeModal}>{t('logs.live_cancel')}</button>
<button <button
type="button" type="button"
className="btn primary" className="btn primary"
@@ -1161,68 +1374,79 @@ export default function LiveLogView({
</div> </div>
)} )}
{modal === 'fix' && ( {modal === 'position' && (
<div <div
className="live-log-modal-backdrop" className="live-log-modal-backdrop"
onClick={(e) => { if (e.target === e.currentTarget) closeModal() }} onClick={(e) => { if (e.target === e.currentTarget) closeModal() }}
> >
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}> <div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
<h3>{t('logs.live_fix')}</h3> <h3>{t('logs.live_position')}</h3>
{fixGpsUnavailable && ( {positionGpsUnavailable && (
<> <>
<p className="live-log-modal-hint live-log-gps-hint-modal">{t('logs.live_gps_start_hint')}</p> {positionGpsErrorReason && (
<p className="live-log-modal-hint">{t('logs.live_fix_manual_hint')}</p> <p className="live-log-modal-hint live-log-gps-error-modal" role="alert">
{t(geolocationErrorI18nKey(positionGpsErrorReason))}
</p>
)}
<p className="live-log-modal-hint">{t('logs.live_position_manual_hint')}</p>
</> </>
)} )}
<fieldset className="live-log-fix-coords" disabled={busy}> <fieldset className="live-log-position-coords" disabled={busy}>
<legend className="live-log-fix-label">{t('logs.event_gps')}</legend> <legend className="live-log-position-label">{t('logs.event_gps')}</legend>
<div className="live-log-fix-coords-row"> <div className="live-log-position-coords-row">
<label className="live-log-fix-field"> <label className="live-log-position-field">
<span className="live-log-fix-field-label">{t('logs.live_fix_lat_placeholder')}</span> <span className="live-log-position-field-label">{t('logs.live_position_lat_placeholder')}</span>
<input <input
type="text" type="text"
inputMode="decimal" inputMode="decimal"
className="input-text" className="input-text"
placeholder="54.123456" placeholder="54.123456"
value={fixLat} value={positionLat}
onChange={(e) => setFixLat(e.target.value)} onChange={(e) => { setPositionGpsSignal(null); setPositionLat(e.target.value) }}
autoFocus autoFocus
/> />
</label> </label>
<label className="live-log-fix-field"> <label className="live-log-position-field">
<span className="live-log-fix-field-label">{t('logs.live_fix_lng_placeholder')}</span> <span className="live-log-position-field-label">{t('logs.live_position_lng_placeholder')}</span>
<input <input
type="text" type="text"
inputMode="decimal" inputMode="decimal"
className="input-text" className="input-text"
placeholder="10.654321" placeholder="10.654321"
value={fixLng} value={positionLng}
onChange={(e) => setFixLng(e.target.value)} onChange={(e) => { setPositionGpsSignal(null); setPositionLng(e.target.value) }}
onKeyDown={(e) => { if (e.key === 'Enter') confirmFix() }} onKeyDown={(e) => { if (e.key === 'Enter') confirmPosition() }}
/> />
</label> </label>
</div> </div>
<div className="live-log-fix-gps-row"> {positionGpsSignal && (
<GpsSignalHint
quality={positionGpsSignal.quality}
accuracyM={positionGpsSignal.accuracyM}
className="gps-signal-hint-modal"
/>
)}
<div className="live-log-position-gps-row">
<button <button
type="button" type="button"
className="btn secondary live-log-fix-gps-btn" className="btn secondary live-log-position-gps-btn"
onClick={() => void retryFixGps()} onClick={() => void retryPositionGps()}
title={t('logs.gps_btn')} title={t('logs.gps_btn')}
disabled={fixGpsLoading} disabled={positionGpsLoading}
aria-label={t('logs.gps_btn')} aria-label={t('logs.gps_btn')}
> >
<MapPin size={16} /> <MapPin size={16} />
<span>{fixGpsLoading ? t('logs.live_fix_gps_loading') : t('logs.gps_btn')}</span> <span>{positionGpsLoading ? t('logs.live_position_gps_loading') : t('logs.gps_btn')}</span>
</button> </button>
</div> </div>
</fieldset> </fieldset>
<div className="live-log-modal-actions"> <div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={closeModal}>{t('logs.confirm_no')}</button> <button type="button" className="btn secondary" onClick={closeModal}>{t('logs.live_cancel')}</button>
<button <button
type="button" type="button"
className="btn primary" className="btn primary"
onClick={confirmFix} onClick={confirmPosition}
disabled={busy || !normalizeGpsCoordinates(fixLat, fixLng)} disabled={busy || !normalizeGpsCoordinates(positionLat, positionLng)}
> >
{t('logs.live_sails_confirm')} {t('logs.live_sails_confirm')}
</button> </button>
@@ -1237,7 +1461,7 @@ export default function LiveLogView({
<h3>{t('logs.live_comment_btn')}</h3> <h3>{t('logs.live_comment_btn')}</h3>
<input type="text" className="input-text" value={commentText} onChange={(e) => setCommentText(e.target.value)} placeholder={t('logs.live_comment_placeholder')} autoFocus onKeyDown={(e) => { if (e.key === 'Enter') confirmComment() }} /> <input type="text" className="input-text" value={commentText} onChange={(e) => setCommentText(e.target.value)} placeholder={t('logs.live_comment_placeholder')} autoFocus onKeyDown={(e) => { if (e.key === 'Enter') confirmComment() }} />
<div className="live-log-modal-actions"> <div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button> <button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.live_cancel')}</button>
<button type="button" className="btn primary" onClick={confirmComment} disabled={!commentText.trim()}>{t('logs.live_comment_confirm')}</button> <button type="button" className="btn primary" onClick={confirmComment} disabled={!commentText.trim()}>{t('logs.live_comment_confirm')}</button>
</div> </div>
</div> </div>
@@ -1271,7 +1495,7 @@ export default function LiveLogView({
/> />
</div> </div>
<div className="live-log-modal-actions"> <div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button> <button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.live_cancel')}</button>
<button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button> <button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button>
</div> </div>
</div> </div>
@@ -1293,7 +1517,7 @@ export default function LiveLogView({
/> />
</div> </div>
<div className="live-log-modal-actions"> <div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button> <button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.live_cancel')}</button>
<button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button> <button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button>
</div> </div>
</div> </div>
@@ -1338,7 +1562,7 @@ export default function LiveLogView({
onKeyDown={(e) => { if (e.key === 'Enter') confirmValueModal() }} onKeyDown={(e) => { if (e.key === 'Enter') confirmValueModal() }}
/> />
<div className="live-log-modal-actions"> <div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button> <button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.live_cancel')}</button>
<button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button> <button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button>
</div> </div>
</div> </div>
+118 -19
View File
@@ -43,6 +43,53 @@ export default function LiveVoiceCapture({
const [previewMime, setPreviewMime] = useState('audio/webm') const [previewMime, setPreviewMime] = useState('audio/webm')
const [previewDurationSec, setPreviewDurationSec] = useState(0) const [previewDurationSec, setPreviewDurationSec] = useState(0)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const log = useCallback((msg: string) => {
console.log(`[VoiceDebug] ${msg}`)
}, [])
const previewAudioRef = useRef<HTMLAudioElement | null>(null)
useEffect(() => {
const el = previewAudioRef.current
if (!el) {
log('previewAudioRef is null')
return
}
log('Preview audio player loaded. readyState=' + el.readyState + ', duration=' + el.duration + ', src=' + el.src)
const handleLoadedMetadata = () => {
log('loadedmetadata event fired. readyState=' + el.readyState + ', duration=' + el.duration)
if (el.duration === Infinity || isNaN(el.duration) || el.duration === 0) {
log('Duration correction hack triggered (duration=' + el.duration + '). Seeking to 1e10...')
el.currentTime = 1e10
const onTimeUpdate = () => {
log('timeupdate event. currentTime=' + el.currentTime + ', duration=' + el.duration)
el.currentTime = 0
el.removeEventListener('timeupdate', onTimeUpdate)
log('currentTime reset to 0. Final duration=' + el.duration)
}
el.addEventListener('timeupdate', onTimeUpdate)
} else {
log('Duration correction skipped (duration is valid)')
}
}
if (el.readyState >= 1) {
log('readyState >= 1. Executing hack immediately...')
handleLoadedMetadata()
} else {
log('readyState = 0. Adding loadedmetadata event listener...')
el.addEventListener('loadedmetadata', handleLoadedMetadata)
}
log('Calling el.load() to force loading of the media resource...')
el.load()
return () => {
el.removeEventListener('loadedmetadata', handleLoadedMetadata)
}
}, [previewUrl, log])
const stopStream = useCallback(() => { const stopStream = useCallback(() => {
for (const track of streamRef.current?.getTracks() ?? []) { for (const track of streamRef.current?.getTracks() ?? []) {
@@ -110,24 +157,51 @@ export default function LiveVoiceCapture({
if (!recorder || recorder.state !== 'recording') return if (!recorder || recorder.state !== 'recording') return
recorder.stop() recorder.stop()
clearTimer() clearTimer()
stopStream() }, [clearTimer])
}, [clearTimer, stopStream])
const startRecording = async () => { const startRecording = async () => {
setMicError(null) setMicError(null)
chunksRef.current = [] chunksRef.current = []
log('startRecording flow triggered')
if (!navigator.mediaDevices?.getUserMedia) {
log('navigator.mediaDevices.getUserMedia is unavailable')
setMicError(t('logs.live_voice_mic_denied'))
return
}
try { try {
log('Requesting getUserMedia audio stream...')
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
streamRef.current = stream streamRef.current = stream
log('Stream obtained successfully. active=' + stream.active)
stream.getTracks().forEach((track, i) => {
log(`Track ${i}: label="${track.label}" enabled=${track.enabled} readyState=${track.readyState} muted=${track.muted}`)
})
const mimeType = pickMediaRecorderMimeType() const mimeType = pickMediaRecorderMimeType()
log('MIME type candidates support check:')
const MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus'
]
MIME_CANDIDATES.forEach(mime => {
log(` - ${mime}: ${MediaRecorder.isTypeSupported(mime) ? 'SUPPORTED' : 'UNSUPPORTED'}`)
})
log('Selected MIME from picker: ' + mimeType)
const recorder = mimeType const recorder = mimeType
? new MediaRecorder(stream, { mimeType }) ? new MediaRecorder(stream, { mimeType })
: new MediaRecorder(stream) : new MediaRecorder(stream)
mediaRecorderRef.current = recorder mediaRecorderRef.current = recorder
const resolvedMime = recorder.mimeType || mimeType || 'audio/webm' const resolvedMime = recorder.mimeType || mimeType || 'audio/webm'
log('MediaRecorder created. Resolved mime=' + resolvedMime)
recorder.ondataavailable = (ev) => { recorder.ondataavailable = (ev) => {
if (ev.data.size > 0) chunksRef.current.push(ev.data) log(`ondataavailable event: data size=${ev.data?.size} bytes`)
if (ev.data && ev.data.size > 0) {
chunksRef.current.push(ev.data)
}
} }
recorder.onstop = () => { recorder.onstop = () => {
@@ -135,44 +209,67 @@ export default function LiveVoiceCapture({
VOICE_MEMO_MAX_DURATION_SEC, VOICE_MEMO_MAX_DURATION_SEC,
Math.max(1, Math.round((Date.now() - startedAtRef.current) / 1000)) Math.max(1, Math.round((Date.now() - startedAtRef.current) / 1000))
) )
const blob = new Blob(chunksRef.current, { type: resolvedMime }) log(`onstop triggered. durationSec=${durationSec}. Wrapping in 50ms timeout...`)
chunksRef.current = [] setTimeout(() => {
try { log(`Creating Blob from ${chunksRef.current.length} chunks. Resolved mime=${resolvedMime}`)
assertVoiceMemoBlobSize(blob) const totalChunksSize = chunksRef.current.reduce((acc, chunk) => acc + chunk.size, 0)
finishRecording(blob, resolvedMime, durationSec) log(`Total raw chunks size: ${totalChunksSize} bytes`)
} catch { const blob = new Blob(chunksRef.current, { type: resolvedMime })
setMicError(t('logs.live_voice_too_large')) chunksRef.current = []
setPhase('idle') stopStream()
} log(`Blob finalized: size=${blob.size} bytes, type=${blob.type}`)
try {
assertVoiceMemoBlobSize(blob)
log('Blob size assertion passed. Calling finishRecording...')
finishRecording(blob, resolvedMime, durationSec)
} catch (err) {
log('Blob size assertion failed (too large)')
setMicError(t('logs.live_voice_too_large'))
setPhase('idle')
}
}, 50)
} }
recorder.onerror = () => { recorder.onerror = (ev) => {
log('MediaRecorder onerror triggered: ' + JSON.stringify(ev))
setMicError(t('logs.live_voice_record_failed')) setMicError(t('logs.live_voice_record_failed'))
resetAll() resetAll()
} }
startedAtRef.current = Date.now() startedAtRef.current = Date.now()
recorder.start(200) log('Calling recorder.start()...')
recorder.start()
log('recorder.start() called. State=' + recorder.state)
setPhase('recording') setPhase('recording')
setElapsedSec(0) setElapsedSec(0)
timerRef.current = window.setInterval(() => { timerRef.current = window.setInterval(() => {
const sec = Math.floor((Date.now() - startedAtRef.current) / 1000) const sec = Math.floor((Date.now() - startedAtRef.current) / 1000)
setElapsedSec(sec) setElapsedSec(sec)
if (sec >= VOICE_MEMO_MAX_DURATION_SEC) { if (sec >= VOICE_MEMO_MAX_DURATION_SEC) {
log('Max duration reached. Stopping recording...')
stopRecording() stopRecording()
} }
}, 250) }, 250)
} catch { } catch (err: any) {
log('Error in startRecording try-catch block: ' + (err instanceof Error ? err.stack || err.message : String(err)))
setMicError(t('logs.live_voice_mic_denied')) setMicError(t('logs.live_voice_mic_denied'))
stopStream() stopStream()
} }
} }
const handleSave = async () => { const handleSave = async () => {
if (!previewBlob || saving || busy) return if (!previewBlob || saving || busy) {
log('handleSave ignored. previewBlob=' + (previewBlob ? 'PRESENT' : 'NULL') + ' saving=' + saving + ' busy=' + busy)
return
}
log('handleSave triggered. Saving blob size=' + previewBlob.size + ' mime=' + previewMime + ' duration=' + previewDurationSec)
setSaving(true) setSaving(true)
try { try {
onSave(previewBlob, previewMime, previewDurationSec) log('Invoking onSave callback...')
await onSave(previewBlob, previewMime, previewDurationSec)
log('onSave callback successfully finished!')
} catch (err: any) {
log('Error during onSave execution: ' + (err instanceof Error ? err.stack || err.message : String(err)))
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -195,7 +292,7 @@ export default function LiveVoiceCapture({
className="btn-icon" className="btn-icon"
onClick={onClose} onClick={onClose}
disabled={busy || saving || phase === 'recording'} disabled={busy || saving || phase === 'recording'}
aria-label={t('logs.confirm_no')} aria-label={t('logs.live_cancel')}
> >
<X size={18} /> <X size={18} />
</button> </button>
@@ -237,7 +334,7 @@ export default function LiveVoiceCapture({
{phase === 'preview' && previewUrl && ( {phase === 'preview' && previewUrl && (
<> <>
<audio className="voice-memo-player" controls src={previewUrl} preload="auto" /> <audio ref={previewAudioRef} className="voice-memo-player" controls src={previewUrl} preload="auto" />
{onCaptionChange && ( {onCaptionChange && (
<label className="live-voice-caption-field"> <label className="live-voice-caption-field">
<span>{t('logs.live_voice_caption_label')}</span> <span>{t('logs.live_voice_caption_label')}</span>
@@ -274,6 +371,8 @@ export default function LiveVoiceCapture({
</div> </div>
</> </>
)} )}
</div> </div>
</div> </div>
) )
+8 -2
View File
@@ -9,7 +9,8 @@ import { downloadCsv, shareCsv } from '../services/csvExport.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js' import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js' import { getErrorMessage } from '../utils/errors.js'
import { findTodayEntryId, tryDecryptEntryPayload } from '../services/quickEventLog.js' import { findTodayEntryId, pruneEmptyTodayDuplicates, tryDecryptEntryPayload } from '../services/quickEventLog.js'
import { localDateString } from '../utils/logEntryPayload.js'
import LogEntryEditor from './LogEntryEditor.tsx' import LogEntryEditor from './LogEntryEditor.tsx'
import LiveLogView from './LiveLogView.tsx' import LiveLogView from './LiveLogView.tsx'
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx' import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
@@ -123,6 +124,11 @@ export default function LogEntriesList({
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.') if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const todayEntryId = await findTodayEntryId(logbookId)
if (todayEntryId) {
await pruneEmptyTodayDuplicates(logbookId, todayEntryId)
}
const local = await db.entries.where({ logbookId }).toArray() const local = await db.entries.where({ logbookId }).toArray()
const list: DecryptedEntryItem[] = [] const list: DecryptedEntryItem[] = []
@@ -300,7 +306,7 @@ export default function LogEntriesList({
const localId = window.crypto.randomUUID() const localId = window.crypto.randomUUID()
const nowStr = new Date().toISOString() const nowStr = new Date().toISOString()
const todayStr = nowStr.substring(0, 10) const todayStr = localDateString()
const { loadDefaultEntryCrewForNewDay } = await import('./EntryCrewSection.js') const { loadDefaultEntryCrewForNewDay } = await import('./EntryCrewSection.js')
const entryCrew = await loadDefaultEntryCrewForNewDay( const entryCrew = await loadDefaultEntryCrewForNewDay(
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -15,11 +15,13 @@ import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, Wifi, WifiO
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx' import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx' import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
import ProfileHeaderButton from './ProfileHeaderButton.tsx' import ProfileHeaderButton from './ProfileHeaderButton.tsx'
import AdminHeaderButton from './AdminHeaderButton.tsx'
interface LogbookDashboardProps { interface LogbookDashboardProps {
onSelectLogbook: (id: string, title: string) => void onSelectLogbook: (id: string, title: string) => void
onLogout: () => void onLogout: () => void
onOpenProfile: () => void onOpenProfile: () => void
onOpenAdmin?: () => void
} }
type LogbookSortKey = 'name' | 'date' type LogbookSortKey = 'name' | 'date'
@@ -42,7 +44,7 @@ function sortLogbooks(
return sorted return sorted
} }
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile }: LogbookDashboardProps) { export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile, onOpenAdmin }: LogbookDashboardProps) {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const { showConfirm } = useDialog() const { showConfirm } = useDialog()
const [logbooks, setLogbooks] = useState<DecryptedLogbook[]>([]) const [logbooks, setLogbooks] = useState<DecryptedLogbook[]>([])
@@ -388,6 +390,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
<ProfileHeaderButton onClick={onOpenProfile} /> <ProfileHeaderButton onClick={onOpenProfile} />
{onOpenAdmin && <AdminHeaderButton onClick={onOpenAdmin} />}
{/* Lang toggle */} {/* Lang toggle */}
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language"> <button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
<Languages size={18} /> <Languages size={18} />
+1 -1
View File
@@ -147,7 +147,7 @@ export default function PersonPoolForm() {
</button> </button>
<button <button
type="button" type="button"
className="btn-icon logout" className="btn-icon danger"
onClick={() => void handleDelete(person.payloadId)} onClick={() => void handleDelete(person.payloadId)}
title="Delete" title="Delete"
> >
+207 -66
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js' import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js' import { getActiveMasterKey } from '../services/auth.js'
@@ -8,7 +9,8 @@ import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.j
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js' import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
import { useLiveQuery } from 'dexie-react-hooks' import { useLiveQuery } from 'dexie-react-hooks'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import { Camera, Trash2 } from 'lucide-react' import { Camera, Image, Trash2, X, ChevronDown, ChevronUp } from 'lucide-react'
import { probeCameraAvailability } from '../utils/cameraAvailability.js'
interface PhotoCaptureProps { interface PhotoCaptureProps {
entryId: string entryId: string
@@ -27,12 +29,43 @@ interface DecryptedPhoto {
export default function PhotoCapture({ entryId, logbookId, readOnly = false, preloadedPhotos }: PhotoCaptureProps) { export default function PhotoCapture({ entryId, logbookId, readOnly = false, preloadedPhotos }: PhotoCaptureProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { showConfirm } = useDialog() const { showConfirm } = useDialog()
const [collapsed, setCollapsed] = useState(true)
const [caption, setCaption] = useState('') const [caption, setCaption] = useState('')
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [decryptedPhotos, setDecryptedPhotos] = useState<DecryptedPhoto[]>([]) const [decryptedPhotos, setDecryptedPhotos] = useState<DecryptedPhoto[]>([])
const [hasCamera, setHasCamera] = useState(false)
const [maximizedPhoto, setMaximizedPhoto] = useState<DecryptedPhoto | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const cameraInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!maximizedPhoto) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setMaximizedPhoto(null)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [maximizedPhoto])
useEffect(() => {
let cancelled = false
probeCameraAvailability().then((avail) => {
if (!cancelled) {
setHasCamera(avail === 'available')
}
})
return () => {
cancelled = true
}
}, [])
// Reactively query local photos database // Reactively query local photos database
const localPhotos = useLiveQuery( const localPhotos = useLiveQuery(
@@ -119,93 +152,201 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
} }
} }
const triggerSelect = () => { const triggerGallerySelect = () => {
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.click() fileInputRef.current.click()
} }
} }
const triggerCameraSelect = () => {
if (cameraInputRef.current) {
cameraInputRef.current.click()
}
}
return ( return (
<div className="form-card mt-6"> <div className="form-card mt-6">
<div className="form-header mb-4"> <div
<Camera size={20} className="form-icon" /> className="form-header accordion-header"
<h3>{t('logs.photos_title')}</h3> onClick={() => setCollapsed(!collapsed)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setCollapsed(!collapsed)
}
}}
role="button"
aria-expanded={!collapsed}
tabIndex={0}
>
<div className="accordion-header-title">
<Camera size={20} className="form-icon" />
<h3>{t('logs.photos_title')}</h3>
</div>
{collapsed ? (
<ChevronDown size={20} className="accordion-chevron" />
) : (
<ChevronUp size={20} className="accordion-chevron" />
)}
</div> </div>
{error && <div className="auth-error mb-4">{error}</div>} {!collapsed && (
<div style={{ marginTop: '16px' }}>
{error && <div className="auth-error mb-4">{error}</div>}
{/* Upload area */} {/* Upload area */}
{/* Upload Form */} {/* Upload Form */}
{!readOnly && ( {!readOnly && (
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}> <div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}> <div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}>
<label>{t('logs.photo_caption_label')}</label> <label>{t('logs.photo_caption_label')}</label>
<input <input
type="text" type="text"
placeholder={t('logs.photo_caption_placeholder')} placeholder={t('logs.photo_caption_placeholder')}
className="input-text" className="input-text"
value={caption} value={caption}
onChange={(e) => setCaption(e.target.value)} onChange={(e) => setCaption(e.target.value)}
disabled={uploading} disabled={uploading}
/> />
</div> </div>
<input <input
type="file" type="file"
accept="image/*" accept="image/*"
ref={fileInputRef} ref={fileInputRef}
onChange={handleFileChange} onChange={handleFileChange}
style={{ display: 'none' }} style={{ display: 'none' }}
/> />
<button <input
type="button" type="file"
className="btn primary" accept="image/*"
onClick={triggerSelect} capture="environment"
disabled={uploading} ref={cameraInputRef}
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }} onChange={handleFileChange}
> style={{ display: 'none' }}
{uploading ? ( />
<span className="spin"></span>
) : (
<Camera size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
</button>
</div>
</div>
)}
{/* Photo Grid */} {hasCamera ? (
{decryptedPhotos.length === 0 ? ( <>
<div className="dashboard-status-msg">{t('logs.no_photos')}</div> <button
) : ( type="button"
<div className="photo-attachments-grid"> className="btn primary"
{decryptedPhotos.map((photo) => ( onClick={triggerCameraSelect}
<div key={photo.payloadId} className="photo-card glass"> disabled={uploading}
<div className="photo-container"> style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" /> >
{!readOnly && ( {uploading ? (
<span className="spin"></span>
) : (
<Camera size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_camera_btn')}
</button>
<button
type="button"
className="btn secondary"
onClick={triggerGallerySelect}
disabled={uploading}
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
>
{uploading ? (
<span className="spin"></span>
) : (
<Image size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_gallery_btn')}
</button>
</>
) : (
<button <button
type="button" type="button"
className="photo-btn-delete" className="btn primary"
onClick={() => handleDelete(photo.payloadId)} onClick={triggerGallerySelect}
title="Remove photo" disabled={uploading}
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
> >
<Trash2 size={16} /> {uploading ? (
<span className="spin"></span>
) : (
<Camera size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
</button> </button>
)} )}
</div> </div>
{photo.caption && (
<div className="photo-caption-bar">
<span>{photo.caption}</span>
</div>
)}
</div> </div>
))} )}
{/* Photo Grid */}
{decryptedPhotos.length === 0 ? (
<div className="dashboard-status-msg">{t('logs.no_photos')}</div>
) : (
<div className="photo-attachments-grid">
{decryptedPhotos.map((photo) => (
<div
key={photo.payloadId}
className="photo-card glass"
onClick={() => setMaximizedPhoto(photo)}
style={{ cursor: 'pointer' }}
>
<div className="photo-container">
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
{!readOnly && (
<button
type="button"
className="photo-btn-delete"
onClick={(e) => {
e.stopPropagation()
handleDelete(photo.payloadId)
}}
title="Remove photo"
>
<Trash2 size={16} />
</button>
)}
</div>
{photo.caption && (
<div className="photo-caption-bar">
<span>{photo.caption}</span>
</div>
)}
</div>
))}
</div>
)}
</div> </div>
)} )}
{maximizedPhoto && createPortal(
<div
className="photo-maximized-overlay"
onClick={() => setMaximizedPhoto(null)}
>
<div className="photo-maximized-container" onClick={(e) => e.stopPropagation()}>
<button
type="button"
className="photo-maximized-close"
onClick={() => setMaximizedPhoto(null)}
aria-label={t('common.close') || 'Close'}
>
<X size={24} />
</button>
<img
src={maximizedPhoto.image}
alt={maximizedPhoto.caption || 'Maximized Attachment'}
className="photo-maximized-img"
/>
{maximizedPhoto.caption && (
<div className="photo-maximized-caption">
{maximizedPhoto.caption}
</div>
)}
</div>
</div>,
document.body
)}
</div> </div>
) )
} }
+1 -1
View File
@@ -429,7 +429,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
<td> <td>
<button <button
type="button" type="button"
className="btn-icon logout" className="btn-icon danger"
onClick={() => handleRevoke(c.id, c.username)} onClick={() => handleRevoke(c.id, c.username)}
title="Revoke access" title="Revoke access"
> >
+3 -2
View File
@@ -14,6 +14,7 @@ import {
} from '../services/statsAggregation.js' } from '../services/statsAggregation.js'
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js' import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
import { formatFuelPerMotorHour } from '../utils/fuelStats.js' import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
import { formatAppDecimal } from '../utils/numberFormat.js'
import { import {
loadLogbookEventSeries, loadLogbookEventSeries,
type EventSeriesPoint, type EventSeriesPoint,
@@ -211,8 +212,8 @@ function PropulsionBreakdown({ totals }: { totals: StatsTotals }) {
)} )}
</div> </div>
<div className="stats-propulsion-labels"> <div className="stats-propulsion-labels">
<span>{t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({sailPct.toFixed(0)}%)</span> <span>{t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({formatAppDecimal(sailPct, { maximumFractionDigits: 0 })}%)</span>
<span>{t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({motorPct.toFixed(0)}%)</span> <span>{t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({formatAppDecimal(motorPct, { maximumFractionDigits: 0 })}%)</span>
{totals.unknownPropulsionNm > 0 && ( {totals.unknownPropulsionNm > 0 && (
<span>{t('stats.unknown_propulsion')}: {formatNm(totals.unknownPropulsionNm)} {t('stats.unit_nm')}</span> <span>{t('stats.unknown_propulsion')}: {formatNm(totals.unknownPropulsionNm)} {t('stats.unit_nm')}</span>
)} )}
+4 -6
View File
@@ -1,6 +1,7 @@
import React, { useCallback } from 'react' import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { clampTankLiters } from '../utils/tankCapacity.js' import { clampTankLiters } from '../utils/tankCapacity.js'
import { formatTankLiters, parseAppDecimalOrZero } from '../utils/numberFormat.js'
interface TankLiterInputProps { interface TankLiterInputProps {
id?: string id?: string
@@ -13,10 +14,8 @@ interface TankLiterInputProps {
} }
function parseInputLiters(value: string): number { function parseInputLiters(value: string): number {
const trimmed = value.trim().replace(',', '.') if (!value.trim()) return 0
if (!trimmed) return 0 return parseAppDecimalOrZero(value)
const parsed = Number(trimmed)
return Number.isFinite(parsed) ? parsed : 0
} }
export default function TankLiterInput({ export default function TankLiterInput({
@@ -34,8 +33,7 @@ export default function TankLiterInput({
const emitValue = useCallback( const emitValue = useCallback(
(liters: number) => { (liters: number) => {
const clamped = clampTankLiters(liters, useSlider ? maxLiters : undefined) const clamped = clampTankLiters(liters, useSlider ? maxLiters : undefined)
const str = const str = formatTankLiters(clamped)
Number.isInteger(clamped) ? String(clamped) : String(Number(clamped.toFixed(1)))
onChange(str) onChange(str)
}, },
[onChange, maxLiters, useSlider] [onChange, maxLiters, useSlider]
@@ -1,6 +1,6 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Compass, Palette, Save, Check, Cloud } from 'lucide-react' import { Compass, Palette, Save, Check, Cloud, Brain } from 'lucide-react'
import ThemedSelect from './ThemedSelect.tsx' import ThemedSelect from './ThemedSelect.tsx'
import PushNotificationSettings from './PushNotificationSettings.tsx' import PushNotificationSettings from './PushNotificationSettings.tsx'
import PwaInstallPrompt from './PwaInstallPrompt.tsx' import PwaInstallPrompt from './PwaInstallPrompt.tsx'
@@ -13,7 +13,9 @@ import {
getThemePreference, getThemePreference,
setColorSchemePreference, setColorSchemePreference,
setOwmApiKey, setOwmApiKey,
setThemePreference setThemePreference,
getAiAuthorized,
setAiAuthorized
} from '../services/userPreferences.js' } from '../services/userPreferences.js'
interface UserProfilePreferencesProps { interface UserProfilePreferencesProps {
@@ -28,12 +30,25 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
const [colorScheme, setColorScheme] = useState(() => getColorSchemePreference(userId)) const [colorScheme, setColorScheme] = useState(() => getColorSchemePreference(userId))
const [savingOwm, setSavingOwm] = useState(false) const [savingOwm, setSavingOwm] = useState(false)
const [owmSaved, setOwmSaved] = useState(false) const [owmSaved, setOwmSaved] = useState(false)
const [aiAuthorized, setAiAuthorizedState] = useState(() => getAiAuthorized(userId))
useEffect(() => {
const handleChanged = () => {
setTheme(getThemePreference(userId))
setColorScheme(getColorSchemePreference(userId))
setAiAuthorizedState(getAiAuthorized(userId))
}
window.addEventListener('appearance-changed', handleChanged)
return () => {
window.removeEventListener('appearance-changed', handleChanged)
}
}, [userId])
const persistAppearance = (nextTheme: string, nextColorScheme: string) => { const persistAppearance = (nextTheme: string, nextColorScheme: string) => {
setThemePreference(userId, nextTheme) setThemePreference(userId, nextTheme)
setColorSchemePreference(userId, nextColorScheme) setColorSchemePreference(userId, nextColorScheme)
notifyAppearanceChanged() notifyAppearanceChanged()
void saveAppearancePrefsToServer(nextTheme, nextColorScheme).catch((err) => { void saveAppearancePrefsToServer(nextTheme, nextColorScheme, aiAuthorized, userId).catch((err) => {
console.warn('Failed to save appearance prefs to server:', err) console.warn('Failed to save appearance prefs to server:', err)
}) })
} }
@@ -58,6 +73,15 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
window.setTimeout(() => setOwmSaved(false), 3000) window.setTimeout(() => setOwmSaved(false), 3000)
} }
const handleAiToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
const nextVal = e.target.checked
setAiAuthorizedState(nextVal)
setAiAuthorized(userId, nextVal)
void saveAppearancePrefsToServer(theme, colorScheme, nextVal, userId).catch((err) => {
console.warn('Failed to save ai preference to server:', err)
})
}
return ( return (
<> <>
<section className="member-editor-card glass"> <section className="member-editor-card glass">
@@ -152,6 +176,42 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
</form> </form>
</section> </section>
<section className="member-editor-card glass">
<div className="profile-section-header">
<Brain size={20} style={{ color: 'var(--app-accent-light)' }} />
<h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}>
{t('profile.ai_title')}
</h3>
</div>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 12px 0' }}>
{t('profile.ai_desc')}
</p>
<p className="text-muted" style={{ fontSize: '13px', lineHeight: '145%', margin: '0 0 16px 0', whiteSpace: 'pre-line' }}>
{t('profile.ai_help')}
</p>
<label
className="switch-label"
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
cursor: 'pointer',
fontSize: '14px',
color: '#f1f5f9'
}}
>
<input
id="profile-ai-authorize"
type="checkbox"
checked={aiAuthorized}
onChange={handleAiToggle}
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
/>
<span>{t('profile.ai_enable_label')}</span>
</label>
</section>
<PushNotificationSettings /> <PushNotificationSettings />
<PwaInstallPrompt variant="inline" /> <PwaInstallPrompt variant="inline" />
</> </>
+1 -1
View File
@@ -193,7 +193,7 @@ export default function VesselPoolForm() {
</button> </button>
<button <button
type="button" type="button"
className="btn-icon logout" className="btn-icon danger"
onClick={() => void handleDelete(v.payloadId)} onClick={() => void handleDelete(v.payloadId)}
> >
<Trash2 size={14} /> <Trash2 size={14} />
+41 -7
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useState, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js' import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js' import { getActiveMasterKey } from '../services/auth.js'
@@ -11,6 +11,7 @@ export interface PreloadedVoiceMemo {
mimeType?: string mimeType?: string
durationSec?: number durationSec?: number
caption?: string caption?: string
transcribed?: boolean
} }
interface VoiceMemoPlayerProps { interface VoiceMemoPlayerProps {
@@ -30,6 +31,38 @@ export default function VoiceMemoPlayer({
const [src, setSrc] = useState<string | null>(preloaded?.audio ?? null) const [src, setSrc] = useState<string | null>(preloaded?.audio ?? null)
const [error, setError] = useState(false) const [error, setError] = useState(false)
const audioRef = useRef<HTMLAudioElement | null>(null)
useEffect(() => {
const el = audioRef.current
if (!el) return
const handleLoadedMetadata = () => {
if (el.duration === Infinity || isNaN(el.duration) || el.duration === 0) {
el.currentTime = 1e10
const onTimeUpdate = () => {
el.currentTime = 0
el.removeEventListener('timeupdate', onTimeUpdate)
}
el.addEventListener('timeupdate', onTimeUpdate)
}
}
if (el.readyState >= 1) {
handleLoadedMetadata()
} else {
el.addEventListener('loadedmetadata', handleLoadedMetadata)
}
if (src) {
el.load()
}
return () => {
el.removeEventListener('loadedmetadata', handleLoadedMetadata)
}
}, [src])
useEffect(() => { useEffect(() => {
if (preloaded?.audio) { if (preloaded?.audio) {
setSrc(preloaded.audio) setSrc(preloaded.audio)
@@ -69,12 +102,13 @@ export default function VoiceMemoPlayer({
) )
} }
const playerClass = compact
? 'voice-memo-player voice-memo-player--compact'
: 'voice-memo-player'
return ( return (
<audio <div className="voice-memo-player-shell">
className={compact ? 'voice-memo-player voice-memo-player--compact' : 'voice-memo-player'} <audio ref={audioRef} className={playerClass} controls preload="metadata" src={src} />
controls </div>
preload="none"
src={src}
/>
) )
} }
+2 -1
View File
@@ -48,7 +48,8 @@ export function useEntryVoiceMemos(
audio: String(decrypted.audio), audio: String(decrypted.audio),
mimeType: decrypted.mimeType ? String(decrypted.mimeType) : undefined, mimeType: decrypted.mimeType ? String(decrypted.mimeType) : undefined,
durationSec: typeof decrypted.durationSec === 'number' ? decrypted.durationSec : undefined, durationSec: typeof decrypted.durationSec === 'number' ? decrypted.durationSec : undefined,
caption: decrypted.caption ? String(decrypted.caption) : '' caption: decrypted.caption ? String(decrypted.caption) : '',
transcribed: decrypted.transcribed !== false
}) })
} catch { } catch {
// skip corrupt memo // skip corrupt memo
+68 -17
View File
@@ -6,6 +6,10 @@
"beta": "Beta", "beta": "Beta",
"beta_hint": "Betaversion - funktioner kan stadig ændres" "beta_hint": "Betaversion - funktioner kan stadig ændres"
}, },
"footer": {
"kofi_label": "Ko-fi",
"kofi_title": "Støt projektet, videreudvikling og driftsomkostninger på Ko-fi"
},
"languages": { "languages": {
"de": "Deutsch", "de": "Deutsch",
"en": "English", "en": "English",
@@ -39,7 +43,8 @@
"deviation": "Tabel over distraktioner", "deviation": "Tabel over distraktioner",
"logs": "Indlæg i logbogen", "logs": "Indlæg i logbogen",
"stats": "Statistik", "stats": "Statistik",
"settings": "Indstillinger" "settings": "Indstillinger",
"admin": "Admin"
}, },
"auth": { "auth": {
"welcome": "Velkommen til Kapteins Daagbok.", "welcome": "Velkommen til Kapteins Daagbok.",
@@ -86,7 +91,15 @@
"use_localhost_link": "Skift til localhost", "use_localhost_link": "Skift til localhost",
"error_passkey_cancelled": "Passkey-login blev annulleret eller udløb. Prøv igen.", "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_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." "error_session_incomplete": "Login ufuldstændig. Log ind med passkey igen.",
"restore_checking": "Tjekker session…",
"restore_title": "Gendan session",
"restore_subtitle": "Du er stadig logget ind. Lås din logbog op med passkey eller PIN.",
"restore_unlocking": "Låser op…",
"restore_with_passkey": "Lås op med passkey ({{name}})",
"restore_with_pin": "Lås op med PIN",
"restore_pin_warning": "Indtast din lokale PIN for at låse logbogen op efter genindlæsning.",
"restore_other_account": "Log ind med en anden konto"
}, },
"pwa": { "pwa": {
"title": "Installer app", "title": "Installer app",
@@ -172,7 +185,10 @@
"travel_day_number": "Rejsedag {{number}}", "travel_day_number": "Rejsedag {{number}}",
"departure": "Starthavn (rejse fra)", "departure": "Starthavn (rejse fra)",
"destination": "Destinationsport (til)", "destination": "Destinationsport (til)",
"route": "Rejse fra/til", "route": "Reje fra/til",
"tanks": "Tanke",
"customize_columns": "Tilpas kolonner",
"column_selector_title": "Kolonner at vise",
"freshwater": "Ferskvand (liter)", "freshwater": "Ferskvand (liter)",
"fuel": "Treibstoff / Brændstof (liter)", "fuel": "Treibstoff / Brændstof (liter)",
"greywater": "Gråt vand (liter)", "greywater": "Gråt vand (liter)",
@@ -245,13 +261,13 @@
"live_sails_confirm": "Indtast", "live_sails_confirm": "Indtast",
"live_sails_confirm_count": "Indtast ({{count}})", "live_sails_confirm_count": "Indtast ({{count}})",
"live_sails": "Sejl: {{sails}}", "live_sails": "Sejl: {{sails}}",
"live_fix": "Fix", "live_position": "Position",
"live_fix_coords": "Fix {{lat}}, {{lng}}", "live_position_coords": "Position {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS ikke tilgængelig. Indtast bredde- og længdegrad manuelt, eller prøv igen med GPS-knappen.", "live_position_manual_hint": "GPS ikke tilgængelig. Indtast bredde- og længdegrad manuelt, eller prøv igen med GPS-knappen.",
"live_fix_gps_loading": "Henter GPS-position…", "live_position_gps_loading": "Henter GPS-position…",
"live_fix_invalid": "Indtast gyldige koordinater (bredde 90…90, længde 180…180).", "live_position_invalid": "Indtast gyldige koordinater (bredde 90…90, længde 180…180).",
"live_fix_lat_placeholder": "Bredde (Lat)", "live_position_lat_placeholder": "Bredde (Lat)",
"live_fix_lng_placeholder": "Længde (Lng)", "live_position_lng_placeholder": "Længde (Lng)",
"live_photo_btn": "Foto (kamera)", "live_photo_btn": "Foto (kamera)",
"live_photo_capture_btn": "Tag billede", "live_photo_capture_btn": "Tag billede",
"live_photo_save_btn": "Gem", "live_photo_save_btn": "Gem",
@@ -262,6 +278,7 @@
"live_photo_camera_starting": "Starter kamera…", "live_photo_camera_starting": "Starter kamera…",
"live_photo_camera_denied": "Kameraadgang nægtet eller utilgængelig.", "live_photo_camera_denied": "Kameraadgang nægtet eller utilgængelig.",
"live_photo_camera_unavailable": "Kamera understøttes ikke i denne browser.", "live_photo_camera_unavailable": "Kamera understøttes ikke i denne browser.",
"live_photo_no_camera": "Der er intet kamera tilgængeligt på denne enhed.",
"live_photo_error": "Foto kunne ikke gemmes.", "live_photo_error": "Foto kunne ikke gemmes.",
"live_photo_entry": "Foto: {{caption}}", "live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto taget", "live_photo_entry_plain": "Foto taget",
@@ -283,6 +300,9 @@
"live_voice_entry_plain": "Stemmenotat", "live_voice_entry_plain": "Stemmenotat",
"live_voice_caption_label": "Billedtekst (valgfrit)", "live_voice_caption_label": "Billedtekst (valgfrit)",
"live_voice_caption_placeholder": "f.eks. radiokontakt med havnemester", "live_voice_caption_placeholder": "f.eks. radiokontakt med havnemester",
"live_voice_transcribe_action": "Transkribere",
"live_voice_transcribing": "Transkriberer…",
"live_voice_transcribe_failed": "Stemmebesked gemt, men transkribering mislykkedes.",
"live_undo_voice_hint": "Stemmenotat gemt", "live_undo_voice_hint": "Stemmenotat gemt",
"live_comment_btn": "Kommentar", "live_comment_btn": "Kommentar",
"live_comment_placeholder": "Indtast tekst…", "live_comment_placeholder": "Indtast tekst…",
@@ -293,8 +313,8 @@
"live_weather_btn": "Vejr", "live_weather_btn": "Vejr",
"live_weather_owm_btn": "Hent OpenWeatherMap-vejr", "live_weather_owm_btn": "Hent OpenWeatherMap-vejr",
"live_weather_owm_loading": "Henter vejr…", "live_weather_owm_loading": "Henter vejr…",
"live_weather_fix_required": "Log først en GPS-fix (Fix-knap) for at hente OpenWeatherMap-vejr. Positionen må højst være 6 timer gammel.", "live_weather_position_required": "Log først en position (Position-knap) for at hente OpenWeatherMap-vejr. Positionen må højst være 6 timer gammel.",
"live_weather_fix_stale": "Den seneste GPS-fix er ældre end 6 timer. Log en ny fix, før du henter vejr.", "live_weather_position_stale": "Den seneste position er ældre end 6 timer. Log en ny position, før du henter vejr.",
"live_wind_btn": "Vind", "live_wind_btn": "Vind",
"live_temp_btn": "T °C", "live_temp_btn": "T °C",
"live_pressure_btn": "Lufttryk", "live_pressure_btn": "Lufttryk",
@@ -302,8 +322,8 @@
"live_sea_state_btn": "Søgang", "live_sea_state_btn": "Søgang",
"live_visibility_btn": "Sigtbarhed", "live_visibility_btn": "Sigtbarhed",
"live_course_btn": "Kurs", "live_course_btn": "Kurs",
"live_fuel_btn": "Diesel", "live_fuel_btn": "+ Diesel",
"live_water_btn": "Vand", "live_water_btn": "+ Vand",
"live_wind_entry": "Vind {{value}}", "live_wind_entry": "Vind {{value}}",
"live_temp_entry": "Temperatur {{temp}} °C", "live_temp_entry": "Temperatur {{temp}} °C",
"live_pressure_entry": "Lufttryk {{value}} hPa", "live_pressure_entry": "Lufttryk {{value}} hPa",
@@ -316,6 +336,7 @@
"live_auto_position": "Auto-position", "live_auto_position": "Auto-position",
"live_undo_hint": "Indtastning gemt", "live_undo_hint": "Indtastning gemt",
"live_undo_btn": "Fortryd", "live_undo_btn": "Fortryd",
"live_cancel": "Annuller",
"live_pressure_placeholder": "f.eks. 1013", "live_pressure_placeholder": "f.eks. 1013",
"live_temp_placeholder": "f.eks. 18", "live_temp_placeholder": "f.eks. 18",
"live_precip_placeholder": "f.eks. let regn", "live_precip_placeholder": "f.eks. let regn",
@@ -338,6 +359,7 @@
"carry_over_tanks_yes": "Tag over", "carry_over_tanks_yes": "Tag over",
"carry_over_tanks_no": "Start med 0", "carry_over_tanks_no": "Start med 0",
"event_title": "Kronologisk hændelseslog", "event_title": "Kronologisk hændelseslog",
"event_creator": "Indtastet af",
"no_events": "Der er endnu ikke indtastet nogen begivenheder for denne rejsedag.", "no_events": "Der er endnu ikke indtastet nogen begivenheder for denne rejsedag.",
"event_time": "Tidspunkt på dagen", "event_time": "Tidspunkt på dagen",
"event_mgk": "MgK-kursus", "event_mgk": "MgK-kursus",
@@ -375,6 +397,24 @@
"event_location_placeholder": "z. f.eks. Kiel", "event_location_placeholder": "z. f.eks. Kiel",
"event_remarks": "Bemærkninger / hændelser", "event_remarks": "Bemærkninger / hændelser",
"gps_btn": "Hent GPS-koordinater", "gps_btn": "Hent GPS-koordinater",
"gps_permission_denied": "Adgang til placering blev nægtet. Tillad det i browser- eller enhedsindstillinger og prøv igen.",
"gps_timeout": "GPS fik timeout. Prøv igen udendørs med frit udsyn til himlen.",
"gps_position_unavailable": "Intet GPS-signal tilgængeligt. Vent og prøv igen, eller indtast koordinater manuelt.",
"gps_unavailable": "GPS understøttes ikke af denne browser eller enhed.",
"gps_failed": "GPS-position kunne ikke bestemmes.",
"gps_fallback_no_location": "GPS mislykkedes. Angiv et sted under placering/havn, afgang eller destination, eller indtast koordinater manuelt.",
"gps_fallback_success": "Koordinater for \"{{location}}\" fundet via stedsnavn (ikke GPS).",
"gps_fallback_failed": "GPS og stedsnavnssøgning mislykkedes. Indtast koordinater manuelt.",
"gps_quality_excellent": "Stærk GPS-modtagelse (±{{accuracy}} m)",
"gps_quality_good": "God GPS-modtagelse (±{{accuracy}} m)",
"gps_quality_fair": "Middel GPS-modtagelse (±{{accuracy}} m) gå udendørs for bedre signal.",
"gps_quality_poor": "Svag GPS-modtagelse (±{{accuracy}} m) sandsynligvis få satellitter. Prøv udendørs igen eller kontroller positionen.",
"gps_quality_unknown": "GPS-position overtaget (nøjagtighed ikke rapporteret af enheden).",
"gps_live_intro_title": "Placering til live-log",
"gps_live_intro_body": "Appen har brug for din placering til automatiske positionsindlæg og GPS-knappen.\n\nTryk på „Tillad placering“ og bekræft i den næste dialog. Du kan altid indtaste position manuelt via „Position“.",
"gps_live_intro_allow": "Tillad placering",
"gps_live_intro_later": "Senere",
"gps_enable_in_settings_hint": "Adgang til placering er blokeret. Du kan tillade det senere i browser- eller enhedsindstillinger (websted / app → Placering).",
"weather_btn": "OpenWeatherMap Kald vejret op", "weather_btn": "OpenWeatherMap Kald vejret op",
"weather_offline": "OpenWeatherMap kræver internetforbindelse. Du er offline lige nu.", "weather_offline": "OpenWeatherMap kræver internetforbindelse. Du er offline lige nu.",
"event_wind_pressure": "Lufttryk (hPa)", "event_wind_pressure": "Lufttryk (hPa)",
@@ -402,10 +442,12 @@
"ai_summary_error_rate_limited": "Maksimalt antal genereringer nået for denne rejsedag.", "ai_summary_error_rate_limited": "Maksimalt antal genereringer nået for denne rejsedag.",
"ai_summary_error_forbidden": "Kun skipperen må generere AI-resuméer.", "ai_summary_error_forbidden": "Kun skipperen må generere AI-resuméer.",
"ai_summary_offline": "AI-resumé kræver internetforbindelse. Du er offline lige nu.", "ai_summary_offline": "AI-resumé kræver internetforbindelse. Du er offline lige nu.",
"photos_title": "Vedhæftede billeder (E2E-krypteret)", "photos_title": "Vedhæftede billeder",
"photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)", "photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)",
"photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen", "photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen",
"photo_btn": "Tag foto / upload", "photo_btn": "Tag foto / upload",
"photo_camera_btn": "Tag foto",
"photo_gallery_btn": "Vælg fra galleri",
"photo_processing": "Er ved at blive behandlet...", "photo_processing": "Er ved at blive behandlet...",
"no_photos": "Der er endnu ingen billeder knyttet til denne rejsedag.", "no_photos": "Der er endnu ingen billeder knyttet til denne rejsedag.",
"photo_delete_confirm": "Er du sikker på, at du vil slette dette foto permanent?", "photo_delete_confirm": "Er du sikker på, at du vil slette dette foto permanent?",
@@ -480,8 +522,8 @@
"nmea_change_engine_stop": "Engine off", "nmea_change_engine_stop": "Engine off",
"nmea_change_autopilot_on": "Autopilot on", "nmea_change_autopilot_on": "Autopilot on",
"nmea_change_autopilot_off": "Autopilot off", "nmea_change_autopilot_off": "Autopilot off",
"nmea_change_gps_lost": "GPS fix lost", "nmea_change_gps_lost": "GPS-position mistet",
"nmea_change_gps_regained": "GPS fix restored", "nmea_change_gps_regained": "GPS-position gendannet",
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C", "nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway", "nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop", "nmea_change_anchor": "Anchored / stop",
@@ -635,6 +677,12 @@
"integrations_title": "Integrationer", "integrations_title": "Integrationer",
"owm_key": "OpenWeatherMap API-nøgle", "owm_key": "OpenWeatherMap API-nøgle",
"owm_help": "Valgfrit: egen OpenWeatherMap API-nøgle. Hvis der ikke er nogen indtastning, bruges nøglen på serversiden fra operatørkonfigurationen.", "owm_help": "Valgfrit: egen OpenWeatherMap API-nøgle. Hvis der ikke er nogen indtastning, bruges nøglen på serversiden fra operatørkonfigurationen.",
"ai_title": "AI-funktioner og privatliv",
"ai_desc": "Autoriser integrationer af kunstig intelligens for dine logbøger.",
"ai_help": "Aktivering af AI-funktioner giver appen mulighed for at opsummere dine rejsedage og transkribere optagede stemmememoer. For at behandle disse anmodninger sendes rå stemmedata og rejselogfiler sikkert løbende til OpenRouter. Der gemmes ingen data permanent af AI-modellen.\n\nDisse cloud-ressourcer koster penge at køre. Hvis du kan lide at bruge dem, bedes du overveje at støtte projektet frivilligt med en donation via Ko-fi-linket i footeren for at holde dem gratis og bæredygtige for alle.",
"ai_enable_label": "Aktiver transkribering og resuméer af rejsedage",
"ai_unauthorized_alert_title": "AI-funktioner er ikke autoriseret",
"ai_unauthorized_alert_desc": "For at bruge transkribering eller rejsedagsresuméer skal du autorisere dataoverførslen til OpenRouter i din brugerprofil under 'AI-funktioner og privatliv'.",
"prefs_save": "Gemme", "prefs_save": "Gemme",
"prefs_saving": "Vil blive reddet...", "prefs_saving": "Vil blive reddet...",
"prefs_saved": "Gemt", "prefs_saved": "Gemt",
@@ -756,6 +804,9 @@
"no_key": "Ingen OpenWeatherMap API-nøgle tilgængelig. Gem din egen nøgle i brugerprofilen, eller kontakt operatøren.", "no_key": "Ingen OpenWeatherMap API-nøgle tilgængelig. Gem din egen nøgle i brugerprofilen, eller kontakt operatøren.",
"weather_success": "Vejrdata hentet med succes!", "weather_success": "Vejrdata hentet med succes!",
"weather_error": "Hentning af vejrdata mislykkedes. Tjek API-nøglen og forbindelsen.", "weather_error": "Hentning af vejrdata mislykkedes. Tjek API-nøglen og forbindelsen.",
"weather_unauthorized": "Hentning af vejrdata mislykkedes. API-nøglen er ugyldig eller ikke autoriseret.",
"weather_not_found": "Hentning af vejrdata mislykkedes. Den angivne placering eller koordinater blev ikke fundet.",
"weather_bad_request": "Hentning af vejrdata mislykkedes. Ingen placering eller GPS-position blev angivet.",
"weather_date_mismatch": "Vejrdata kan kun hentes for i dag ({{today}}). Dette logbogsindlæg er dateret {{date}}.", "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.", "gps_error": "Indtast en placering, eller find GPS-koordinaterne.",
"share_title": "Del logbog (skrivebeskyttet)", "share_title": "Del logbog (skrivebeskyttet)",
+68 -17
View File
@@ -6,6 +6,10 @@
"beta": "Beta", "beta": "Beta",
"beta_hint": "Beta-Version — Funktionen können sich noch ändern" "beta_hint": "Beta-Version — Funktionen können sich noch ändern"
}, },
"footer": {
"kofi_label": "Ko-fi",
"kofi_title": "Projekt, Weiterentwicklung und Betriebskosten auf Ko-fi unterstützen"
},
"languages": { "languages": {
"de": "Deutsch", "de": "Deutsch",
"en": "English", "en": "English",
@@ -39,7 +43,8 @@
"deviation": "Ablenkungstabelle", "deviation": "Ablenkungstabelle",
"logs": "Logbucheinträge", "logs": "Logbucheinträge",
"stats": "Statistik", "stats": "Statistik",
"settings": "Einstellungen" "settings": "Einstellungen",
"admin": "Admin"
}, },
"auth": { "auth": {
"welcome": "Willkommen bei Kapteins Daagbok", "welcome": "Willkommen bei Kapteins Daagbok",
@@ -86,7 +91,15 @@
"use_localhost_link": "Zu localhost wechseln", "use_localhost_link": "Zu localhost wechseln",
"error_passkey_cancelled": "Passkey-Anmeldung abgebrochen oder abgelaufen. Bitte erneut versuchen.", "error_passkey_cancelled": "Passkey-Anmeldung abgebrochen oder abgelaufen. Bitte erneut versuchen.",
"error_invalid_rp_id": "Passkey-Domain passt nicht (RP ID). Lokal nur http://localhost:5173 mit RP_ID=localhost in .env verwenden.", "error_invalid_rp_id": "Passkey-Domain passt nicht (RP ID). Lokal nur http://localhost:5173 mit RP_ID=localhost in .env verwenden.",
"error_session_incomplete": "Anmeldung unvollständig. Bitte erneut mit Passkey anmelden." "error_session_incomplete": "Anmeldung unvollständig. Bitte erneut mit Passkey anmelden.",
"restore_checking": "Session wird geprüft…",
"restore_title": "Session wiederherstellen",
"restore_subtitle": "Deine Anmeldung ist noch aktiv. Entsperre dein Logbuch mit Passkey oder PIN.",
"restore_unlocking": "Wird entsperrt…",
"restore_with_passkey": "Mit Passkey entsperren ({{name}})",
"restore_with_pin": "Mit PIN entsperren",
"restore_pin_warning": "Gib deine lokale PIN ein, um dein Logbuch nach dem Neuladen zu entsperren.",
"restore_other_account": "Anderer Account anmelden"
}, },
"pwa": { "pwa": {
"title": "App installieren", "title": "App installieren",
@@ -173,6 +186,9 @@
"departure": "Start-Hafen (Reise von)", "departure": "Start-Hafen (Reise von)",
"destination": "Ziel-Hafen (nach)", "destination": "Ziel-Hafen (nach)",
"route": "Reise von/nach", "route": "Reise von/nach",
"tanks": "Tanks",
"customize_columns": "Spalten anpassen",
"column_selector_title": "Anzuzeigende Spalten",
"freshwater": "Frischwasser (Liter)", "freshwater": "Frischwasser (Liter)",
"fuel": "Treibstoff / Fuel (Liter)", "fuel": "Treibstoff / Fuel (Liter)",
"greywater": "Grauwasser (Liter)", "greywater": "Grauwasser (Liter)",
@@ -245,13 +261,13 @@
"live_sails_confirm": "Eintragen", "live_sails_confirm": "Eintragen",
"live_sails_confirm_count": "Eintragen ({{count}})", "live_sails_confirm_count": "Eintragen ({{count}})",
"live_sails": "Segel: {{sails}}", "live_sails": "Segel: {{sails}}",
"live_fix": "Fix", "live_position": "Position",
"live_fix_coords": "Fix {{lat}}, {{lng}}", "live_position_coords": "Position {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS nicht verfügbar. Breiten- und Längengrad manuell eingeben oder erneut per GPS-Knopf versuchen.", "live_position_manual_hint": "GPS nicht verfügbar. Breiten- und Längengrad manuell eingeben oder erneut per GPS-Knopf versuchen.",
"live_fix_gps_loading": "GPS-Position wird ermittelt…", "live_position_gps_loading": "GPS-Position wird ermittelt…",
"live_fix_invalid": "Bitte gültige Koordinaten eingeben (Breite 90…90, Länge 180…180).", "live_position_invalid": "Bitte gültige Koordinaten eingeben (Breite 90…90, Länge 180…180).",
"live_fix_lat_placeholder": "Breite (Lat)", "live_position_lat_placeholder": "Breite (Lat)",
"live_fix_lng_placeholder": "Länge (Lng)", "live_position_lng_placeholder": "Länge (Lng)",
"live_photo_btn": "Foto (Kamera)", "live_photo_btn": "Foto (Kamera)",
"live_photo_capture_btn": "Aufnehmen", "live_photo_capture_btn": "Aufnehmen",
"live_photo_save_btn": "Speichern", "live_photo_save_btn": "Speichern",
@@ -262,6 +278,7 @@
"live_photo_camera_starting": "Kamera wird gestartet…", "live_photo_camera_starting": "Kamera wird gestartet…",
"live_photo_camera_denied": "Kamerazugriff verweigert oder nicht verfügbar.", "live_photo_camera_denied": "Kamerazugriff verweigert oder nicht verfügbar.",
"live_photo_camera_unavailable": "Kamera wird von diesem Browser nicht unterstützt.", "live_photo_camera_unavailable": "Kamera wird von diesem Browser nicht unterstützt.",
"live_photo_no_camera": "Auf diesem Gerät ist keine Kamera verfügbar.",
"live_photo_error": "Foto konnte nicht gespeichert werden.", "live_photo_error": "Foto konnte nicht gespeichert werden.",
"live_photo_entry": "Foto: {{caption}}", "live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto aufgenommen", "live_photo_entry_plain": "Foto aufgenommen",
@@ -283,18 +300,21 @@
"live_voice_entry_plain": "Sprachnotiz", "live_voice_entry_plain": "Sprachnotiz",
"live_voice_caption_label": "Beschriftung (optional)", "live_voice_caption_label": "Beschriftung (optional)",
"live_voice_caption_placeholder": "z. B. Funkverkehr mit Hafenmeister", "live_voice_caption_placeholder": "z. B. Funkverkehr mit Hafenmeister",
"live_voice_transcribe_action": "Transkribieren",
"live_voice_transcribing": "Transkribiere...",
"live_voice_transcribe_failed": "Sprachmemo gespeichert, aber Transkription fehlgeschlagen.",
"live_undo_voice_hint": "Sprachnotiz gespeichert", "live_undo_voice_hint": "Sprachnotiz gespeichert",
"live_comment_btn": "Kommentar", "live_comment_btn": "Kommentar",
"live_comment_placeholder": "Freitext eingeben…", "live_comment_placeholder": "Freitext eingeben…",
"live_comment_confirm": "Eintragen", "live_comment_confirm": "Eintragen",
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.", "live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
"live_gps_start_hint": "Beginne deine Tagesreise immer mit einem Standort.", "live_gps_start_hint": "Beginne deine Tagesreise immer mit einer Position.",
"live_event_generic": "Ereignis", "live_event_generic": "Ereignis",
"live_weather_btn": "Wetter", "live_weather_btn": "Wetter",
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen", "live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
"live_weather_owm_loading": "Wetter wird geladen…", "live_weather_owm_loading": "Wetter wird geladen…",
"live_weather_fix_required": "Für Wetter von OpenWeatherMap zuerst einen GPS-Fix eintragen (Schaltfläche „Fix“). Die Position darf höchstens 6 Stunden alt sein.", "live_weather_position_required": "Für Wetter von OpenWeatherMap zuerst eine Position eintragen (Schaltfläche „Position“). Die Position darf höchstens 6 Stunden alt sein.",
"live_weather_fix_stale": "Der letzte GPS-Fix ist älter als 6 Stunden. Bitte erneut einen Fix loggen, bevor du Wetter abrufst.", "live_weather_position_stale": "Die letzte Position ist älter als 6 Stunden. Bitte erneut eine Position loggen, bevor du Wetter abrufst.",
"live_wind_btn": "Wind", "live_wind_btn": "Wind",
"live_temp_btn": "T °C", "live_temp_btn": "T °C",
"live_pressure_btn": "Luftdruck", "live_pressure_btn": "Luftdruck",
@@ -302,8 +322,8 @@
"live_sea_state_btn": "Seegang", "live_sea_state_btn": "Seegang",
"live_visibility_btn": "Sichtweite", "live_visibility_btn": "Sichtweite",
"live_course_btn": "Kurs", "live_course_btn": "Kurs",
"live_fuel_btn": "Diesel", "live_fuel_btn": "+ Diesel",
"live_water_btn": "Wasser", "live_water_btn": "+ Wasser",
"live_wind_entry": "Wind {{value}}", "live_wind_entry": "Wind {{value}}",
"live_temp_entry": "Temperatur {{temp}} °C", "live_temp_entry": "Temperatur {{temp}} °C",
"live_pressure_entry": "Luftdruck {{value}} hPa", "live_pressure_entry": "Luftdruck {{value}} hPa",
@@ -316,6 +336,7 @@
"live_auto_position": "Auto-Position", "live_auto_position": "Auto-Position",
"live_undo_hint": "Eintrag gespeichert", "live_undo_hint": "Eintrag gespeichert",
"live_undo_btn": "Rückgängig", "live_undo_btn": "Rückgängig",
"live_cancel": "Abbruch",
"live_pressure_placeholder": "z. B. 1013", "live_pressure_placeholder": "z. B. 1013",
"live_temp_placeholder": "z. B. 18", "live_temp_placeholder": "z. B. 18",
"live_precip_placeholder": "z. B. leichter Regen", "live_precip_placeholder": "z. B. leichter Regen",
@@ -338,6 +359,7 @@
"carry_over_tanks_yes": "Übernehmen", "carry_over_tanks_yes": "Übernehmen",
"carry_over_tanks_no": "Mit 0 starten", "carry_over_tanks_no": "Mit 0 starten",
"event_title": "Chronologisches Ereignisprotokoll", "event_title": "Chronologisches Ereignisprotokoll",
"event_creator": "Eingetragen von",
"no_events": "Noch keine Ereignisse für diesen Reisetag eingetragen.", "no_events": "Noch keine Ereignisse für diesen Reisetag eingetragen.",
"event_time": "Uhrzeit", "event_time": "Uhrzeit",
"event_mgk": "MgK Kurs", "event_mgk": "MgK Kurs",
@@ -375,6 +397,24 @@
"event_location_placeholder": "z. B. Kiel", "event_location_placeholder": "z. B. Kiel",
"event_remarks": "Bemerkungen / Vorkommnisse", "event_remarks": "Bemerkungen / Vorkommnisse",
"gps_btn": "GPS-Koordinaten abrufen", "gps_btn": "GPS-Koordinaten abrufen",
"gps_permission_denied": "Standortzugriff wurde verweigert. Bitte in den Browser- oder Geräteeinstellungen erlauben und erneut versuchen.",
"gps_timeout": "GPS-Zeitüberschreitung. Bitte erneut versuchen am besten im Freien mit gutem Empfang.",
"gps_position_unavailable": "Kein GPS-Signal verfügbar. Bitte warten oder Koordinaten manuell eingeben.",
"gps_unavailable": "GPS wird von diesem Browser oder Gerät nicht unterstützt.",
"gps_failed": "GPS-Position konnte nicht ermittelt werden.",
"gps_fallback_no_location": "GPS fehlgeschlagen. Bitte einen Ort unter „Ort / Hafen“, Start- oder Zielhafen eintragen, oder Koordinaten manuell eingeben.",
"gps_fallback_success": "Koordinaten für „{{location}}“ über den Ortsnamen ermittelt (nicht per GPS).",
"gps_fallback_failed": "GPS und Ortsnamen-Suche sind fehlgeschlagen. Bitte Koordinaten manuell eingeben.",
"gps_quality_excellent": "Starker GPS-Empfang (±{{accuracy}} m)",
"gps_quality_good": "Guter GPS-Empfang (±{{accuracy}} m)",
"gps_quality_fair": "Mäßiger GPS-Empfang (±{{accuracy}} m) für besseren Empfang ins Freie gehen.",
"gps_quality_poor": "Schwacher GPS-Empfang (±{{accuracy}} m) vermutlich wenig Satelliten. Im Freien erneut versuchen oder Position prüfen.",
"gps_quality_unknown": "GPS-Position übernommen (Genauigkeit vom Gerät nicht gemeldet).",
"gps_live_intro_title": "Standort für Live-Log",
"gps_live_intro_body": "Für automatische Positions-Einträge und den GPS-Knopf braucht die App Zugriff auf deinen Standort.\n\nTippe auf „Standort erlauben“ im nächsten Dialog die Freigabe bestätigen. Du kannst jederzeit manuell unter „Position“ eintragen.",
"gps_live_intro_allow": "Standort erlauben",
"gps_live_intro_later": "Später",
"gps_enable_in_settings_hint": "Standortzugriff ist blockiert. In den Browser- oder Geräteeinstellungen (Website / App → Standort) kannst du die Freigabe nachträglich erlauben.",
"weather_btn": "OpenWeatherMap Wetter abrufen", "weather_btn": "OpenWeatherMap Wetter abrufen",
"weather_offline": "OpenWeatherMap erfordert eine Internetverbindung. Du bist derzeit offline.", "weather_offline": "OpenWeatherMap erfordert eine Internetverbindung. Du bist derzeit offline.",
"event_wind_pressure": "Luftdruck (hPa)", "event_wind_pressure": "Luftdruck (hPa)",
@@ -402,10 +442,12 @@
"ai_summary_error_rate_limited": "Maximale Anzahl an Generierungen für diesen Reisetag erreicht.", "ai_summary_error_rate_limited": "Maximale Anzahl an Generierungen für diesen Reisetag erreicht.",
"ai_summary_error_forbidden": "Nur der Skipper darf KI-Zusammenfassungen generieren.", "ai_summary_error_forbidden": "Nur der Skipper darf KI-Zusammenfassungen generieren.",
"ai_summary_offline": "Die KI-Zusammenfassung erfordert eine Internetverbindung. Du bist derzeit offline.", "ai_summary_offline": "Die KI-Zusammenfassung erfordert eine Internetverbindung. Du bist derzeit offline.",
"photos_title": "Foto-Anhänge (E2E-verschlüsselt)", "photos_title": "Foto-Anhänge",
"photo_caption_label": "Foto-Beschreibung / Label (Optional)", "photo_caption_label": "Foto-Beschreibung / Label (Optional)",
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt", "photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
"photo_btn": "Foto aufnehmen / Hochladen", "photo_btn": "Foto aufnehmen / Hochladen",
"photo_camera_btn": "Foto aufnehmen",
"photo_gallery_btn": "Aus Galerie wählen",
"photo_processing": "Wird verarbeitet...", "photo_processing": "Wird verarbeitet...",
"no_photos": "Noch keine Fotos an diesen Reisetag angehängt.", "no_photos": "Noch keine Fotos an diesen Reisetag angehängt.",
"photo_delete_confirm": "Bist du sicher, dass du dieses Foto unwiderruflich löschen möchtest?", "photo_delete_confirm": "Bist du sicher, dass du dieses Foto unwiderruflich löschen möchtest?",
@@ -470,8 +512,8 @@
"nmea_change_engine_stop": "Motor aus", "nmea_change_engine_stop": "Motor aus",
"nmea_change_autopilot_on": "Autopilot ein", "nmea_change_autopilot_on": "Autopilot ein",
"nmea_change_autopilot_off": "Autopilot aus", "nmea_change_autopilot_off": "Autopilot aus",
"nmea_change_gps_lost": "GPS-Fix verloren", "nmea_change_gps_lost": "GPS-Position verloren",
"nmea_change_gps_regained": "GPS-Fix wiederhergestellt", "nmea_change_gps_regained": "GPS-Position wiederhergestellt",
"nmea_change_water_temp": "Wassertemp. {{from}} → {{to}} °C", "nmea_change_water_temp": "Wassertemp. {{from}} → {{to}} °C",
"nmea_change_departure": "Abfahrt / Fahrtbeginn", "nmea_change_departure": "Abfahrt / Fahrtbeginn",
"nmea_change_anchor": "Ankern / Stop", "nmea_change_anchor": "Ankern / Stop",
@@ -635,6 +677,12 @@
"integrations_title": "Integrationen", "integrations_title": "Integrationen",
"owm_key": "OpenWeatherMap API-Schlüssel", "owm_key": "OpenWeatherMap API-Schlüssel",
"owm_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.", "owm_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.",
"ai_title": "KI-Funktionen & Datenschutz",
"ai_desc": "Autorisiere die Nutzung von künstlicher Intelligenz (lokale/Cloud-Integrationen) für deine Logbücher.",
"ai_help": "Die Aktivierung ermöglicht es, Reiseberichte automatisch zusammenzufassen und Sprachnotizen zu transkribieren. Zur Verarbeitung werden Sprachaufnahmen und Logbucheinträge verschlüsselt an OpenRouter übertragen. Die Daten werden dort nicht dauerhaft gespeichert.\n\nDa der Betrieb dieser Cloud-Ressourcen Kosten verursacht, freuen wir uns über eine freiwillige Unterstützung über den Ko-fi-Spenden-Link im Footer, um diese Funktionen dauerhaft für alle kostenlos anbieten zu können.",
"ai_enable_label": "Transkribierung und Tageszusammenfassungen aktivieren",
"ai_unauthorized_alert_title": "KI-Funktionen nicht autorisiert",
"ai_unauthorized_alert_desc": "Um Sprachnotizen zu transkribieren oder Reiseberichte zusammenzufassen, musst du der Datenübermittlung an OpenRouter in deinem Benutzerprofil unter 'KI-Funktionen & Datenschutz' zustimmen.",
"prefs_save": "Speichern", "prefs_save": "Speichern",
"prefs_saving": "Wird gespeichert…", "prefs_saving": "Wird gespeichert…",
"prefs_saved": "Gespeichert", "prefs_saved": "Gespeichert",
@@ -756,6 +804,9 @@
"no_key": "Kein OpenWeatherMap-API-Schlüssel verfügbar. Hinterlege einen eigenen Schlüssel im Benutzerprofil oder kontaktiere den Betreiber.", "no_key": "Kein OpenWeatherMap-API-Schlüssel verfügbar. Hinterlege einen eigenen Schlüssel im Benutzerprofil oder kontaktiere den Betreiber.",
"weather_success": "Wetterdaten erfolgreich abgerufen!", "weather_success": "Wetterdaten erfolgreich abgerufen!",
"weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfe den API-Schlüssel und die Verbindung.", "weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfe den API-Schlüssel und die Verbindung.",
"weather_unauthorized": "Wetterdatenabruf fehlgeschlagen. Der API-Schlüssel ist ungültig oder nicht autorisiert.",
"weather_not_found": "Wetterdatenabruf fehlgeschlagen. Der angegebene Ort oder die Koordinaten wurden nicht gefunden.",
"weather_bad_request": "Wetterdatenabruf fehlgeschlagen. Es wurde kein Ort und keine GPS-Position angegeben.",
"weather_date_mismatch": "Wetterdaten können nur für den heutigen Tag ({{today}}) abgerufen werden. Dieser Logbucheintrag ist auf den {{date}} datiert.", "weather_date_mismatch": "Wetterdaten können nur für den heutigen Tag ({{today}}) abgerufen werden. Dieser Logbucheintrag ist auf den {{date}} datiert.",
"gps_error": "Bitte gib einen Ort an oder ermittle die GPS-Koordinaten.", "gps_error": "Bitte gib einen Ort an oder ermittle die GPS-Koordinaten.",
"share_title": "Logbuch teilen (Schreibgeschützt)", "share_title": "Logbuch teilen (Schreibgeschützt)",
+68 -17
View File
@@ -6,6 +6,10 @@
"beta": "Beta", "beta": "Beta",
"beta_hint": "Beta release — features may still change" "beta_hint": "Beta release — features may still change"
}, },
"footer": {
"kofi_label": "Ko-fi",
"kofi_title": "Support the project, development, and running costs on Ko-fi"
},
"languages": { "languages": {
"de": "Deutsch", "de": "Deutsch",
"en": "English", "en": "English",
@@ -39,7 +43,8 @@
"deviation": "Deviation Table", "deviation": "Deviation Table",
"logs": "Logbook Entries", "logs": "Logbook Entries",
"stats": "Statistics", "stats": "Statistics",
"settings": "Settings" "settings": "Settings",
"admin": "Admin"
}, },
"auth": { "auth": {
"welcome": "Welcome to Kapteins Daagbok", "welcome": "Welcome to Kapteins Daagbok",
@@ -86,7 +91,15 @@
"use_localhost_link": "Switch to localhost", "use_localhost_link": "Switch to localhost",
"error_passkey_cancelled": "Passkey sign-in was cancelled or timed out. Please try again.", "error_passkey_cancelled": "Passkey sign-in was cancelled or timed out. Please try again.",
"error_invalid_rp_id": "Passkey domain mismatch (RP ID). For local dev use http://localhost:5173 with RP_ID=localhost in .env.", "error_invalid_rp_id": "Passkey domain mismatch (RP ID). For local dev use http://localhost:5173 with RP_ID=localhost in .env.",
"error_session_incomplete": "Sign-in incomplete. Please sign in with your passkey again." "error_session_incomplete": "Sign-in incomplete. Please sign in with your passkey again.",
"restore_checking": "Checking session…",
"restore_title": "Restore session",
"restore_subtitle": "You are still signed in. Unlock your logbook with passkey or PIN.",
"restore_unlocking": "Unlocking…",
"restore_with_passkey": "Unlock with passkey ({{name}})",
"restore_with_pin": "Unlock with PIN",
"restore_pin_warning": "Enter your local PIN to unlock your logbook after reload.",
"restore_other_account": "Sign in with another account"
}, },
"pwa": { "pwa": {
"title": "Install app", "title": "Install app",
@@ -173,6 +186,9 @@
"departure": "Departure Port (von)", "departure": "Departure Port (von)",
"destination": "Destination Port (nach)", "destination": "Destination Port (nach)",
"route": "Route / Journey", "route": "Route / Journey",
"tanks": "Tanks",
"customize_columns": "Customize columns",
"column_selector_title": "Columns to Show",
"freshwater": "Freshwater (Liters)", "freshwater": "Freshwater (Liters)",
"fuel": "Fuel (Liters)", "fuel": "Fuel (Liters)",
"greywater": "Greywater (Liters)", "greywater": "Greywater (Liters)",
@@ -245,13 +261,13 @@
"live_sails_confirm": "Log entry", "live_sails_confirm": "Log entry",
"live_sails_confirm_count": "Log entry ({{count}})", "live_sails_confirm_count": "Log entry ({{count}})",
"live_sails": "Sails: {{sails}}", "live_sails": "Sails: {{sails}}",
"live_fix": "Fix", "live_position": "Position",
"live_fix_coords": "Fix {{lat}}, {{lng}}", "live_position_coords": "Position {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS unavailable. Enter latitude and longitude manually, or try again with the GPS button.", "live_position_manual_hint": "GPS unavailable. Enter latitude and longitude manually, or try again with the GPS button.",
"live_fix_gps_loading": "Getting GPS position…", "live_position_gps_loading": "Getting GPS position…",
"live_fix_invalid": "Please enter valid coordinates (latitude 90…90, longitude 180…180).", "live_position_invalid": "Please enter valid coordinates (latitude 90…90, longitude 180…180).",
"live_fix_lat_placeholder": "Latitude (Lat)", "live_position_lat_placeholder": "Latitude (Lat)",
"live_fix_lng_placeholder": "Longitude (Lng)", "live_position_lng_placeholder": "Longitude (Lng)",
"live_photo_btn": "Photo (camera)", "live_photo_btn": "Photo (camera)",
"live_photo_capture_btn": "Capture", "live_photo_capture_btn": "Capture",
"live_photo_save_btn": "Save", "live_photo_save_btn": "Save",
@@ -262,6 +278,7 @@
"live_photo_camera_starting": "Starting camera…", "live_photo_camera_starting": "Starting camera…",
"live_photo_camera_denied": "Camera access denied or unavailable.", "live_photo_camera_denied": "Camera access denied or unavailable.",
"live_photo_camera_unavailable": "Camera is not supported in this browser.", "live_photo_camera_unavailable": "Camera is not supported in this browser.",
"live_photo_no_camera": "No camera is available on this device.",
"live_photo_error": "Could not save photo.", "live_photo_error": "Could not save photo.",
"live_photo_entry": "Photo: {{caption}}", "live_photo_entry": "Photo: {{caption}}",
"live_photo_entry_plain": "Photo captured", "live_photo_entry_plain": "Photo captured",
@@ -283,18 +300,21 @@
"live_voice_entry_plain": "Voice memo", "live_voice_entry_plain": "Voice memo",
"live_voice_caption_label": "Caption (optional)", "live_voice_caption_label": "Caption (optional)",
"live_voice_caption_placeholder": "e.g. radio call with harbour master", "live_voice_caption_placeholder": "e.g. radio call with harbour master",
"live_voice_transcribe_action": "Transcribe",
"live_voice_transcribing": "Transcribing…",
"live_voice_transcribe_failed": "Voice memo saved, but transcription failed.",
"live_undo_voice_hint": "Voice memo saved", "live_undo_voice_hint": "Voice memo saved",
"live_comment_btn": "Comment", "live_comment_btn": "Comment",
"live_comment_placeholder": "Enter text…", "live_comment_placeholder": "Enter text…",
"live_comment_confirm": "Log entry", "live_comment_confirm": "Log entry",
"live_gps_error": "Could not determine GPS position.", "live_gps_error": "Could not determine GPS position.",
"live_gps_start_hint": "Always start your day's voyage with a position fix.", "live_gps_start_hint": "Always start your day's voyage with a position.",
"live_event_generic": "Event", "live_event_generic": "Event",
"live_weather_btn": "Weather", "live_weather_btn": "Weather",
"live_weather_owm_btn": "Fetch OpenWeatherMap weather", "live_weather_owm_btn": "Fetch OpenWeatherMap weather",
"live_weather_owm_loading": "Loading weather…", "live_weather_owm_loading": "Loading weather…",
"live_weather_fix_required": "Log a GPS fix first (Fix button) to fetch OpenWeatherMap weather. The position must be at most 6 hours old.", "live_weather_position_required": "Log a position first (Position button) to fetch OpenWeatherMap weather. The position must be at most 6 hours old.",
"live_weather_fix_stale": "The last GPS fix is older than 6 hours. Log a new fix before fetching weather.", "live_weather_position_stale": "The last position is older than 6 hours. Log a new position before fetching weather.",
"live_wind_btn": "Wind", "live_wind_btn": "Wind",
"live_temp_btn": "Temp °C", "live_temp_btn": "Temp °C",
"live_pressure_btn": "Pressure", "live_pressure_btn": "Pressure",
@@ -302,8 +322,8 @@
"live_sea_state_btn": "Sea state", "live_sea_state_btn": "Sea state",
"live_visibility_btn": "Visibility", "live_visibility_btn": "Visibility",
"live_course_btn": "Course", "live_course_btn": "Course",
"live_fuel_btn": "Fuel", "live_fuel_btn": "+ Fuel",
"live_water_btn": "Water", "live_water_btn": "+ Water",
"live_wind_entry": "Wind {{value}}", "live_wind_entry": "Wind {{value}}",
"live_temp_entry": "Temperature {{temp}} °C", "live_temp_entry": "Temperature {{temp}} °C",
"live_pressure_entry": "Pressure {{value}} hPa", "live_pressure_entry": "Pressure {{value}} hPa",
@@ -316,6 +336,7 @@
"live_auto_position": "Auto position", "live_auto_position": "Auto position",
"live_undo_hint": "Entry saved", "live_undo_hint": "Entry saved",
"live_undo_btn": "Undo", "live_undo_btn": "Undo",
"live_cancel": "Cancel",
"live_pressure_placeholder": "e.g. 1013", "live_pressure_placeholder": "e.g. 1013",
"live_temp_placeholder": "e.g. 18", "live_temp_placeholder": "e.g. 18",
"live_precip_placeholder": "e.g. light rain", "live_precip_placeholder": "e.g. light rain",
@@ -338,6 +359,7 @@
"carry_over_tanks_yes": "Carry over", "carry_over_tanks_yes": "Carry over",
"carry_over_tanks_no": "Start at 0", "carry_over_tanks_no": "Start at 0",
"event_title": "Chronological Event Logbook", "event_title": "Chronological Event Logbook",
"event_creator": "Entered by",
"no_events": "No events logged for this travel day yet.", "no_events": "No events logged for this travel day yet.",
"event_time": "Time", "event_time": "Time",
"event_mgk": "MgK Course", "event_mgk": "MgK Course",
@@ -375,6 +397,24 @@
"event_location_placeholder": "e.g. Kiel", "event_location_placeholder": "e.g. Kiel",
"event_remarks": "Remarks / Events", "event_remarks": "Remarks / Events",
"gps_btn": "Get GPS Location", "gps_btn": "Get GPS Location",
"gps_permission_denied": "Location access was denied. Allow it in your browser or device settings and try again.",
"gps_timeout": "GPS timed out. Try again outdoors with a clear view of the sky.",
"gps_position_unavailable": "No GPS signal available. Wait and retry, or enter coordinates manually.",
"gps_unavailable": "GPS is not supported by this browser or device.",
"gps_failed": "Could not determine GPS position.",
"gps_fallback_no_location": "GPS failed. Enter a place under Location / harbour, departure, or destination, or type coordinates manually.",
"gps_fallback_success": "Coordinates for \"{{location}}\" resolved from place name (not GPS).",
"gps_fallback_failed": "GPS and place-name lookup both failed. Please enter coordinates manually.",
"gps_quality_excellent": "Strong GPS reception (±{{accuracy}} m)",
"gps_quality_good": "Good GPS reception (±{{accuracy}} m)",
"gps_quality_fair": "Fair GPS reception (±{{accuracy}} m) — move outdoors for a better fix.",
"gps_quality_poor": "Weak GPS reception (±{{accuracy}} m) — likely few satellites. Retry outdoors or verify the position.",
"gps_quality_unknown": "GPS position applied (accuracy not reported by device).",
"gps_live_intro_title": "Location for Live Log",
"gps_live_intro_body": "The app needs your location for automatic position entries and the GPS button.\n\nTap “Allow location” and confirm in the next dialog. You can always enter a position manually via “Position”.",
"gps_live_intro_allow": "Allow location",
"gps_live_intro_later": "Later",
"gps_enable_in_settings_hint": "Location access is blocked. You can allow it later in your browser or device settings (site / app → Location).",
"weather_btn": "Fetch OpenWeatherMap Weather", "weather_btn": "Fetch OpenWeatherMap Weather",
"weather_offline": "OpenWeatherMap requires an internet connection. You are currently offline.", "weather_offline": "OpenWeatherMap requires an internet connection. You are currently offline.",
"event_wind_pressure": "Barometer (hPa)", "event_wind_pressure": "Barometer (hPa)",
@@ -402,10 +442,12 @@
"ai_summary_error_rate_limited": "Maximum number of generations reached for this travel day.", "ai_summary_error_rate_limited": "Maximum number of generations reached for this travel day.",
"ai_summary_error_forbidden": "Only the skipper may generate AI summaries.", "ai_summary_error_forbidden": "Only the skipper may generate AI summaries.",
"ai_summary_offline": "AI summary generation requires an internet connection. You are currently offline.", "ai_summary_offline": "AI summary generation requires an internet connection. You are currently offline.",
"photos_title": "Photo Attachments (E2E Encrypted)", "photos_title": "Photo Attachments",
"photo_caption_label": "Photo Caption / Label (Optional)", "photo_caption_label": "Photo Caption / Label (Optional)",
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance", "photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
"photo_btn": "Take Photo / Upload", "photo_btn": "Take Photo / Upload",
"photo_camera_btn": "Take Photo",
"photo_gallery_btn": "Choose from Gallery",
"photo_processing": "Processing...", "photo_processing": "Processing...",
"no_photos": "No photos attached to this journal entry yet.", "no_photos": "No photos attached to this journal entry yet.",
"photo_delete_confirm": "Are you sure you want to permanently delete this photo?", "photo_delete_confirm": "Are you sure you want to permanently delete this photo?",
@@ -470,8 +512,8 @@
"nmea_change_engine_stop": "Engine off", "nmea_change_engine_stop": "Engine off",
"nmea_change_autopilot_on": "Autopilot on", "nmea_change_autopilot_on": "Autopilot on",
"nmea_change_autopilot_off": "Autopilot off", "nmea_change_autopilot_off": "Autopilot off",
"nmea_change_gps_lost": "GPS fix lost", "nmea_change_gps_lost": "GPS position lost",
"nmea_change_gps_regained": "GPS fix restored", "nmea_change_gps_regained": "GPS position restored",
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C", "nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway", "nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop", "nmea_change_anchor": "Anchored / stop",
@@ -635,6 +677,12 @@
"integrations_title": "Integrations", "integrations_title": "Integrations",
"owm_key": "OpenWeatherMap API key", "owm_key": "OpenWeatherMap API key",
"owm_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.", "owm_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.",
"ai_title": "AI Features & Privacy",
"ai_desc": "Authorize artificial intelligence integrations for your logbooks.",
"ai_help": "Enabling AI features allows the app to summarize travel days and transcribe recorded voice memos. To process these requests, raw voice data and travel logs are sent securely on-the-fly to OpenRouter. No data is stored permanently by the AI model.\n\nThese cloud resources cost money to run; if you enjoy using them, please consider supporting the project voluntarily with a donation via the Ko-fi link in the footer to keep them free and sustainable for everyone.",
"ai_enable_label": "Enable transcription and travel day summaries",
"ai_unauthorized_alert_title": "AI Features Not Authorized",
"ai_unauthorized_alert_desc": "To use transcription or travel day summaries, please authorize the data transmission to OpenRouter in your User Profile under 'AI Features & Privacy'.",
"prefs_save": "Save", "prefs_save": "Save",
"prefs_saving": "Saving…", "prefs_saving": "Saving…",
"prefs_saved": "Saved", "prefs_saved": "Saved",
@@ -756,6 +804,9 @@
"no_key": "No OpenWeatherMap API key available. Add your own key in your user profile or contact the operator.", "no_key": "No OpenWeatherMap API key available. Add your own key in your user profile or contact the operator.",
"weather_success": "Weather details fetched successfully!", "weather_success": "Weather details fetched successfully!",
"weather_error": "Failed to fetch weather. Check your API key and connection.", "weather_error": "Failed to fetch weather. Check your API key and connection.",
"weather_unauthorized": "Failed to fetch weather. The API key is invalid or unauthorized.",
"weather_not_found": "Failed to fetch weather. The specified location or coordinates were not found.",
"weather_bad_request": "Failed to fetch weather. No location or GPS position was specified.",
"weather_date_mismatch": "Weather data can only be fetched for today ({{today}}). This logbook entry is dated {{date}}.", "weather_date_mismatch": "Weather data can only be fetched for today ({{today}}). This logbook entry is dated {{date}}.",
"gps_error": "Please enter a location or fetch GPS coordinates first.", "gps_error": "Please enter a location or fetch GPS coordinates first.",
"share_title": "Share Logbook (Read-Only)", "share_title": "Share Logbook (Read-Only)",
+67 -16
View File
@@ -6,6 +6,10 @@
"beta": "Beta", "beta": "Beta",
"beta_hint": "Betaversjon - funksjoner kan fortsatt endres" "beta_hint": "Betaversjon - funksjoner kan fortsatt endres"
}, },
"footer": {
"kofi_label": "Ko-fi",
"kofi_title": "Støtt prosjektet, videreutvikling og driftskostnader på Ko-fi"
},
"languages": { "languages": {
"de": "Deutsch", "de": "Deutsch",
"en": "English", "en": "English",
@@ -39,7 +43,8 @@
"deviation": "Tabell over distraksjoner", "deviation": "Tabell over distraksjoner",
"logs": "Loggbokoppføringer", "logs": "Loggbokoppføringer",
"stats": "Statistikk", "stats": "Statistikk",
"settings": "Innstillinger" "settings": "Innstillinger",
"admin": "Admin"
}, },
"auth": { "auth": {
"welcome": "Velkommen til Kapteins Daagbok", "welcome": "Velkommen til Kapteins Daagbok",
@@ -86,7 +91,15 @@
"use_localhost_link": "Bytt til localhost", "use_localhost_link": "Bytt til localhost",
"error_passkey_cancelled": "Passkey-innlogging ble avbrutt eller utløp. Prøv igjen.", "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_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." "error_session_incomplete": "Innlogging ufullstendig. Logg inn med passkey igjen.",
"restore_checking": "Sjekker økt…",
"restore_title": "Gjenopprett økt",
"restore_subtitle": "Du er fortsatt innlogget. Lås opp loggboken med passkey eller PIN.",
"restore_unlocking": "Låser opp…",
"restore_with_passkey": "Lås opp med passkey ({{name}})",
"restore_with_pin": "Lås opp med PIN",
"restore_pin_warning": "Skriv inn din lokale PIN for å låse opp loggboken etter omlasting.",
"restore_other_account": "Logg inn med en annen konto"
}, },
"pwa": { "pwa": {
"title": "Installer app", "title": "Installer app",
@@ -173,6 +186,9 @@
"departure": "Starthavn (reise fra)", "departure": "Starthavn (reise fra)",
"destination": "Destinasjonsport (til)", "destination": "Destinasjonsport (til)",
"route": "Reise fra/til", "route": "Reise fra/til",
"tanks": "Tanker",
"customize_columns": "Tilpass kolonner",
"column_selector_title": "Kolonner å vise",
"freshwater": "Ferskvann (liter)", "freshwater": "Ferskvann (liter)",
"fuel": "Drivstoff / Drivstoff (liter)", "fuel": "Drivstoff / Drivstoff (liter)",
"greywater": "Gråvann (liter)", "greywater": "Gråvann (liter)",
@@ -245,13 +261,13 @@
"live_sails_confirm": "Loggfør", "live_sails_confirm": "Loggfør",
"live_sails_confirm_count": "Loggfør ({{count}})", "live_sails_confirm_count": "Loggfør ({{count}})",
"live_sails": "Seil: {{sails}}", "live_sails": "Seil: {{sails}}",
"live_fix": "Fix", "live_position": "Posisjon",
"live_fix_coords": "Fix {{lat}}, {{lng}}", "live_position_coords": "Posisjon {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS ikke tilgjengelig. Skriv inn bredde- og lengdegrad manuelt, eller prøv igjen med GPS-knappen.", "live_position_manual_hint": "GPS ikke tilgjengelig. Skriv inn bredde- og lengdegrad manuelt, eller prøv igjen med GPS-knappen.",
"live_fix_gps_loading": "Henter GPS-posisjon…", "live_position_gps_loading": "Henter GPS-posisjon…",
"live_fix_invalid": "Skriv inn gyldige koordinater (bredde 90…90, lengde 180…180).", "live_position_invalid": "Skriv inn gyldige koordinater (bredde 90…90, lengde 180…180).",
"live_fix_lat_placeholder": "Bredde (Lat)", "live_position_lat_placeholder": "Bredde (Lat)",
"live_fix_lng_placeholder": "Lengde (Lng)", "live_position_lng_placeholder": "Lengde (Lng)",
"live_photo_btn": "Foto (kamera)", "live_photo_btn": "Foto (kamera)",
"live_photo_capture_btn": "Ta bilde", "live_photo_capture_btn": "Ta bilde",
"live_photo_save_btn": "Lagre", "live_photo_save_btn": "Lagre",
@@ -262,6 +278,7 @@
"live_photo_camera_starting": "Starter kamera…", "live_photo_camera_starting": "Starter kamera…",
"live_photo_camera_denied": "Kameratilgang nektet eller utilgjengelig.", "live_photo_camera_denied": "Kameratilgang nektet eller utilgjengelig.",
"live_photo_camera_unavailable": "Kamera støttes ikke i denne nettleseren.", "live_photo_camera_unavailable": "Kamera støttes ikke i denne nettleseren.",
"live_photo_no_camera": "Ingen kamera er tilgjengelig på denne enheten.",
"live_photo_error": "Kunne ikke lagre foto.", "live_photo_error": "Kunne ikke lagre foto.",
"live_photo_entry": "Foto: {{caption}}", "live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto tatt", "live_photo_entry_plain": "Foto tatt",
@@ -283,6 +300,9 @@
"live_voice_entry_plain": "Talemelding", "live_voice_entry_plain": "Talemelding",
"live_voice_caption_label": "Bildetekst (valgfritt)", "live_voice_caption_label": "Bildetekst (valgfritt)",
"live_voice_caption_placeholder": "f.eks. radiokontakt med havnesjef", "live_voice_caption_placeholder": "f.eks. radiokontakt med havnesjef",
"live_voice_transcribe_action": "Transkribere",
"live_voice_transcribing": "Transkriberer…",
"live_voice_transcribe_failed": "Taleopptak lagret, men transkribering mislyktes.",
"live_undo_voice_hint": "Talemelding lagret", "live_undo_voice_hint": "Talemelding lagret",
"live_comment_btn": "Kommentar", "live_comment_btn": "Kommentar",
"live_comment_placeholder": "Skriv inn tekst…", "live_comment_placeholder": "Skriv inn tekst…",
@@ -293,8 +313,8 @@
"live_weather_btn": "Vær", "live_weather_btn": "Vær",
"live_weather_owm_btn": "Hent OpenWeatherMap-vær", "live_weather_owm_btn": "Hent OpenWeatherMap-vær",
"live_weather_owm_loading": "Henter vær…", "live_weather_owm_loading": "Henter vær…",
"live_weather_fix_required": "Logg først en GPS-fix (Fix-knapp) for å hente OpenWeatherMap-vær. Posisjonen må være maks 6 timer gammel.", "live_weather_position_required": "Logg først en posisjon (Posisjon-knapp) for å hente OpenWeatherMap-vær. Posisjonen må være maks 6 timer gammel.",
"live_weather_fix_stale": "Siste GPS-fix er eldre enn 6 timer. Logg en ny fix før du henter vær.", "live_weather_position_stale": "Siste posisjon er eldre enn 6 timer. Logg en ny posisjon før du henter vær.",
"live_wind_btn": "Vind", "live_wind_btn": "Vind",
"live_temp_btn": "T °C", "live_temp_btn": "T °C",
"live_pressure_btn": "Lufttrykk", "live_pressure_btn": "Lufttrykk",
@@ -302,8 +322,8 @@
"live_sea_state_btn": "Sjøgang", "live_sea_state_btn": "Sjøgang",
"live_visibility_btn": "Sikt", "live_visibility_btn": "Sikt",
"live_course_btn": "Kurs", "live_course_btn": "Kurs",
"live_fuel_btn": "Diesel", "live_fuel_btn": "+ Diesel",
"live_water_btn": "Vann", "live_water_btn": "+ Vann",
"live_wind_entry": "Vind {{value}}", "live_wind_entry": "Vind {{value}}",
"live_temp_entry": "Temperatur {{temp}} °C", "live_temp_entry": "Temperatur {{temp}} °C",
"live_pressure_entry": "Lufttrykk {{value}} hPa", "live_pressure_entry": "Lufttrykk {{value}} hPa",
@@ -316,6 +336,7 @@
"live_auto_position": "Auto-posisjon", "live_auto_position": "Auto-posisjon",
"live_undo_hint": "Oppføring lagret", "live_undo_hint": "Oppføring lagret",
"live_undo_btn": "Angre", "live_undo_btn": "Angre",
"live_cancel": "Avbryt",
"live_pressure_placeholder": "f.eks. 1013", "live_pressure_placeholder": "f.eks. 1013",
"live_temp_placeholder": "f.eks. 18", "live_temp_placeholder": "f.eks. 18",
"live_precip_placeholder": "f.eks. lett regn", "live_precip_placeholder": "f.eks. lett regn",
@@ -338,6 +359,7 @@
"carry_over_tanks_yes": "Ta over", "carry_over_tanks_yes": "Ta over",
"carry_over_tanks_no": "Begynn med 0", "carry_over_tanks_no": "Begynn med 0",
"event_title": "Kronologisk hendelseslogg", "event_title": "Kronologisk hendelseslogg",
"event_creator": "Registrert av",
"no_events": "Ingen arrangementer lagt inn for denne reisedagen ennå.", "no_events": "Ingen arrangementer lagt inn for denne reisedagen ennå.",
"event_time": "Tid på døgnet", "event_time": "Tid på døgnet",
"event_mgk": "MgK-kurs", "event_mgk": "MgK-kurs",
@@ -375,6 +397,24 @@
"event_location_placeholder": "z. f.eks. Kiel", "event_location_placeholder": "z. f.eks. Kiel",
"event_remarks": "Merknader / hendelser", "event_remarks": "Merknader / hendelser",
"gps_btn": "Hent GPS-koordinater", "gps_btn": "Hent GPS-koordinater",
"gps_permission_denied": "Tilgang til posisjon ble nektet. Tillat det i nettleser- eller enhetsinnstillinger og prøv igjen.",
"gps_timeout": "GPS fikk tidsavbrudd. Prøv igjen utendørs med fri sikt mot himmelen.",
"gps_position_unavailable": "Ingen GPS-signal tilgjengelig. Vent og prøv igjen, eller skriv inn koordinater manuelt.",
"gps_unavailable": "GPS støttes ikke av denne nettleseren eller enheten.",
"gps_failed": "GPS-posisjon kunne ikke bestemmes.",
"gps_fallback_no_location": "GPS mislyktes. Skriv inn et sted under sted/havn, avreise eller destinasjon, eller koordinater manuelt.",
"gps_fallback_success": "Koordinater for «{{location}}» funnet via stedsnavn (ikke GPS).",
"gps_fallback_failed": "GPS og stedsnavnssøk mislyktes. Skriv inn koordinater manuelt.",
"gps_quality_excellent": "Sterk GPS-mottak (±{{accuracy}} m)",
"gps_quality_good": "God GPS-mottak (±{{accuracy}} m)",
"gps_quality_fair": "Middels GPS-mottak (±{{accuracy}} m) gå utendørs for bedre signal.",
"gps_quality_poor": "Svakt GPS-mottak (±{{accuracy}} m) sannsynligvis få satellitter. Prøv utendørs igjen eller kontroller posisjonen.",
"gps_quality_unknown": "GPS-posisjon tatt i bruk (nøyaktighet ikke rapportert av enheten).",
"gps_live_intro_title": "Posisjon for live-logg",
"gps_live_intro_body": "Appen trenger posisjonen din for automatiske posisjonsregistreringer og GPS-knappen.\n\nTrykk «Tillat posisjon» og bekreft i neste dialog. Du kan alltid legge inn posisjon manuelt via «Posisjon».",
"gps_live_intro_allow": "Tillat posisjon",
"gps_live_intro_later": "Senere",
"gps_enable_in_settings_hint": "Posisjonstilgang er blokkert. Du kan tillate det senere i nettleser- eller enhetsinnstillinger (nettsted / app → Posisjon).",
"weather_btn": "OpenWeatherMap Ring opp været", "weather_btn": "OpenWeatherMap Ring opp været",
"weather_offline": "OpenWeatherMap krever internettforbindelse. Du er frakoblet.", "weather_offline": "OpenWeatherMap krever internettforbindelse. Du er frakoblet.",
"event_wind_pressure": "Lufttrykk (hPa)", "event_wind_pressure": "Lufttrykk (hPa)",
@@ -402,10 +442,12 @@
"ai_summary_error_rate_limited": "Maksimalt antall genereringer nådd for denne reisedagen.", "ai_summary_error_rate_limited": "Maksimalt antall genereringer nådd for denne reisedagen.",
"ai_summary_error_forbidden": "Kun skipperen kan generere AI-sammendrag.", "ai_summary_error_forbidden": "Kun skipperen kan generere AI-sammendrag.",
"ai_summary_offline": "AI-sammendrag krever internettforbindelse. Du er frakoblet.", "ai_summary_offline": "AI-sammendrag krever internettforbindelse. Du er frakoblet.",
"photos_title": "Bildevedlegg (E2E-kryptert)", "photos_title": "Bildevedlegg",
"photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)", "photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)",
"photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen", "photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen",
"photo_btn": "Ta bilde / last opp", "photo_btn": "Ta bilde / last opp",
"photo_camera_btn": "Ta bilde",
"photo_gallery_btn": "Velg fra galleri",
"photo_processing": "...blir behandlet...", "photo_processing": "...blir behandlet...",
"no_photos": "Ingen bilder knyttet til denne reisedagen ennå.", "no_photos": "Ingen bilder knyttet til denne reisedagen ennå.",
"photo_delete_confirm": "Er du sikker på at du vil slette dette bildet permanent?", "photo_delete_confirm": "Er du sikker på at du vil slette dette bildet permanent?",
@@ -480,8 +522,8 @@
"nmea_change_engine_stop": "Engine off", "nmea_change_engine_stop": "Engine off",
"nmea_change_autopilot_on": "Autopilot on", "nmea_change_autopilot_on": "Autopilot on",
"nmea_change_autopilot_off": "Autopilot off", "nmea_change_autopilot_off": "Autopilot off",
"nmea_change_gps_lost": "GPS fix lost", "nmea_change_gps_lost": "GPS-posisjon tapt",
"nmea_change_gps_regained": "GPS fix restored", "nmea_change_gps_regained": "GPS-posisjon gjenopprettet",
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C", "nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway", "nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop", "nmea_change_anchor": "Anchored / stop",
@@ -635,6 +677,12 @@
"integrations_title": "Integrasjoner", "integrations_title": "Integrasjoner",
"owm_key": "OpenWeatherMap API-nøkkel", "owm_key": "OpenWeatherMap API-nøkkel",
"owm_help": "Valgfritt: egen OpenWeatherMap API-nøkkel. Hvis ingen oppføring er gjort, brukes serverside-nøkkelen fra operatørkonfigurasjonen.", "owm_help": "Valgfritt: egen OpenWeatherMap API-nøkkel. Hvis ingen oppføring er gjort, brukes serverside-nøkkelen fra operatørkonfigurasjonen.",
"ai_title": "KI-funksjoner og personvern",
"ai_desc": "Autoriser integrasjoner av kunstig intelligens for loggbøkene dine.",
"ai_help": "Aktivering av KI-funksjoner gjør det mulig for appen å oppsummere reisedagene dine og transkribere innspilte talememoer. For å behandle disse forespørslene sendes rå stemmedata og reiselogger sikkert løpende til OpenRouter. Ingen data lagres permanent av KI-modellen.\n\nDisse nettskyressursene koster penger å drifte. Hvis du har glede av å bruke dem, kan du vurdere å støtte prosjektet frivillig med en donasjon via Ko-fi-lenken i bunnteksten for å holde dem gratis og bærekraftige for alle.",
"ai_enable_label": "Aktiver transkribering og oppsummeringer av reisedager",
"ai_unauthorized_alert_title": "KI-funktionen er ikke autorisert",
"ai_unauthorized_alert_desc": "For å bruke transkribering eller reisedagsoppsummeringer, må du autorisere dataoverføringen til OpenRouter i brukerprofilen din under 'KI-funksjoner og personvern'.",
"prefs_save": "Spar", "prefs_save": "Spar",
"prefs_saving": "...vil bli reddet...", "prefs_saving": "...vil bli reddet...",
"prefs_saved": "Reddet", "prefs_saved": "Reddet",
@@ -756,6 +804,9 @@
"no_key": "Ingen OpenWeatherMap API-nøkkel tilgjengelig. Lagre din egen nøkkel i brukerprofilen, eller kontakt operatøren.", "no_key": "Ingen OpenWeatherMap API-nøkkel tilgjengelig. Lagre din egen nøkkel i brukerprofilen, eller kontakt operatøren.",
"weather_success": "Værdata vellykket hentet!", "weather_success": "Værdata vellykket hentet!",
"weather_error": "Henting av værdata mislyktes. Kontroller API-nøkkelen og tilkoblingen.", "weather_error": "Henting av værdata mislyktes. Kontroller API-nøkkelen og tilkoblingen.",
"weather_unauthorized": "Henting av værdata mislyktes. API-nøkkelen er ugyldig eller ikke autorisert.",
"weather_not_found": "Henting av værdata mislyktes. Den angitte posisjonen eller koordinatene ble ikke funnet.",
"weather_bad_request": "Henting av værdata mislyktes. Ingen posisjon eller GPS-koordinater ble angitt.",
"weather_date_mismatch": "Værdata kan bare hentes ut for i dag ({{today}}). Denne loggbokoppføringen er datert {{date}}.", "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.", "gps_error": "Vennligst skriv inn en posisjon eller finn GPS-koordinatene.",
"share_title": "Del loggbok (skrivebeskyttet)", "share_title": "Del loggbok (skrivebeskyttet)",
+67 -16
View File
@@ -6,6 +6,10 @@
"beta": "Beta", "beta": "Beta",
"beta_hint": "Betaversion - funktioner kan fortfarande ändras" "beta_hint": "Betaversion - funktioner kan fortfarande ändras"
}, },
"footer": {
"kofi_label": "Ko-fi",
"kofi_title": "Stöd projektet, vidareutveckling och driftskostnader på Ko-fi"
},
"languages": { "languages": {
"de": "Deutsch", "de": "Deutsch",
"en": "English", "en": "English",
@@ -39,7 +43,8 @@
"deviation": "Distraktionsbord", "deviation": "Distraktionsbord",
"logs": "Loggboksanteckningar", "logs": "Loggboksanteckningar",
"stats": "Statistik", "stats": "Statistik",
"settings": "Inställningar" "settings": "Inställningar",
"admin": "Admin"
}, },
"auth": { "auth": {
"welcome": "Välkommen till Kapteins Daagbok", "welcome": "Välkommen till Kapteins Daagbok",
@@ -86,7 +91,15 @@
"use_localhost_link": "Byt till localhost", "use_localhost_link": "Byt till localhost",
"error_passkey_cancelled": "Passkey-inloggning avbröts eller gick ut. Försök igen.", "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_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." "error_session_incomplete": "Inloggning ofullständig. Logga in med passkey igen.",
"restore_checking": "Kontrollerar session…",
"restore_title": "Återställ session",
"restore_subtitle": "Du är fortfarande inloggad. Lås upp din loggbok med passkey eller PIN.",
"restore_unlocking": "Låser upp…",
"restore_with_passkey": "Lås upp med passkey ({{name}})",
"restore_with_pin": "Lås upp med PIN",
"restore_pin_warning": "Ange din lokala PIN för att låsa upp loggboken efter omladdning.",
"restore_other_account": "Logga in med ett annat konto"
}, },
"pwa": { "pwa": {
"title": "Installera app", "title": "Installera app",
@@ -173,6 +186,9 @@
"departure": "Starthamn (resa från)", "departure": "Starthamn (resa från)",
"destination": "Destinationsport (till)", "destination": "Destinationsport (till)",
"route": "Resa från/till", "route": "Resa från/till",
"tanks": "Tankar",
"customize_columns": "Anpassa kolumner",
"column_selector_title": "Kolumner att visa",
"freshwater": "Färskvatten (liter)", "freshwater": "Färskvatten (liter)",
"fuel": "Treibstoff / Bränsle (liter)", "fuel": "Treibstoff / Bränsle (liter)",
"greywater": "Gråvatten (liter)", "greywater": "Gråvatten (liter)",
@@ -245,13 +261,13 @@
"live_sails_confirm": "Logga", "live_sails_confirm": "Logga",
"live_sails_confirm_count": "Logga ({{count}})", "live_sails_confirm_count": "Logga ({{count}})",
"live_sails": "Segel: {{sails}}", "live_sails": "Segel: {{sails}}",
"live_fix": "Fix", "live_position": "Position",
"live_fix_coords": "Fix {{lat}}, {{lng}}", "live_position_coords": "Position {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS ej tillgänglig. Ange latitud och longitud manuellt, eller försök igen med GPS-knappen.", "live_position_manual_hint": "GPS ej tillgänglig. Ange latitud och longitud manuellt, eller försök igen med GPS-knappen.",
"live_fix_gps_loading": "Hämtar GPS-position…", "live_position_gps_loading": "Hämtar GPS-position…",
"live_fix_invalid": "Ange giltiga koordinater (latitud 90…90, longitud 180…180).", "live_position_invalid": "Ange giltiga koordinater (latitud 90…90, longitud 180…180).",
"live_fix_lat_placeholder": "Latitud (Lat)", "live_position_lat_placeholder": "Latitud (Lat)",
"live_fix_lng_placeholder": "Longitud (Lng)", "live_position_lng_placeholder": "Longitud (Lng)",
"live_photo_btn": "Foto (kamera)", "live_photo_btn": "Foto (kamera)",
"live_photo_capture_btn": "Ta foto", "live_photo_capture_btn": "Ta foto",
"live_photo_save_btn": "Spara", "live_photo_save_btn": "Spara",
@@ -262,6 +278,7 @@
"live_photo_camera_starting": "Startar kamera…", "live_photo_camera_starting": "Startar kamera…",
"live_photo_camera_denied": "Kameraåtkomst nekad eller ej tillgänglig.", "live_photo_camera_denied": "Kameraåtkomst nekad eller ej tillgänglig.",
"live_photo_camera_unavailable": "Kameran stöds inte i den här webbläsaren.", "live_photo_camera_unavailable": "Kameran stöds inte i den här webbläsaren.",
"live_photo_no_camera": "Ingen kamera finns på den här enheten.",
"live_photo_error": "Foto kunde inte sparas.", "live_photo_error": "Foto kunde inte sparas.",
"live_photo_entry": "Foto: {{caption}}", "live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto taget", "live_photo_entry_plain": "Foto taget",
@@ -283,6 +300,9 @@
"live_voice_entry_plain": "Röstanteckning", "live_voice_entry_plain": "Röstanteckning",
"live_voice_caption_label": "Bildtext (valfritt)", "live_voice_caption_label": "Bildtext (valfritt)",
"live_voice_caption_placeholder": "t.ex. radiokontakt med hamnmästare", "live_voice_caption_placeholder": "t.ex. radiokontakt med hamnmästare",
"live_voice_transcribe_action": "Transkribera",
"live_voice_transcribing": "Transkriberar…",
"live_voice_transcribe_failed": "Röstanteckning sparad, men transkribering misslyckades.",
"live_undo_voice_hint": "Röstanteckning sparad", "live_undo_voice_hint": "Röstanteckning sparad",
"live_comment_btn": "Kommentar", "live_comment_btn": "Kommentar",
"live_comment_placeholder": "Ange text…", "live_comment_placeholder": "Ange text…",
@@ -293,8 +313,8 @@
"live_weather_btn": "Väder", "live_weather_btn": "Väder",
"live_weather_owm_btn": "Hämta OpenWeatherMap-väder", "live_weather_owm_btn": "Hämta OpenWeatherMap-väder",
"live_weather_owm_loading": "Hämtar väder…", "live_weather_owm_loading": "Hämtar väder…",
"live_weather_fix_required": "Logga först en GPS-fix (Fix-knappen) för att hämta OpenWeatherMap-väder. Positionen får högst vara 6 timmar gammal.", "live_weather_position_required": "Logga först en position (Position-knappen) för att hämta OpenWeatherMap-väder. Positionen får högst vara 6 timmar gammal.",
"live_weather_fix_stale": "Senaste GPS-fixen är äldre än 6 timmar. Logga en ny fix innan du hämtar väder.", "live_weather_position_stale": "Senaste positionen är äldre än 6 timmar. Logga en ny position innan du hämtar väder.",
"live_wind_btn": "Vind", "live_wind_btn": "Vind",
"live_temp_btn": "T °C", "live_temp_btn": "T °C",
"live_pressure_btn": "Lufttryck", "live_pressure_btn": "Lufttryck",
@@ -302,8 +322,8 @@
"live_sea_state_btn": "Sjögang", "live_sea_state_btn": "Sjögang",
"live_visibility_btn": "Sikt", "live_visibility_btn": "Sikt",
"live_course_btn": "Kurs", "live_course_btn": "Kurs",
"live_fuel_btn": "Diesel", "live_fuel_btn": "+ Diesel",
"live_water_btn": "Vatten", "live_water_btn": "+ Vatten",
"live_wind_entry": "Vind {{value}}", "live_wind_entry": "Vind {{value}}",
"live_temp_entry": "Temperatur {{temp}} °C", "live_temp_entry": "Temperatur {{temp}} °C",
"live_pressure_entry": "Lufttryck {{value}} hPa", "live_pressure_entry": "Lufttryck {{value}} hPa",
@@ -316,6 +336,7 @@
"live_auto_position": "Auto-position", "live_auto_position": "Auto-position",
"live_undo_hint": "Post sparad", "live_undo_hint": "Post sparad",
"live_undo_btn": "Ångra", "live_undo_btn": "Ångra",
"live_cancel": "Avbryt",
"live_pressure_placeholder": "t.ex. 1013", "live_pressure_placeholder": "t.ex. 1013",
"live_temp_placeholder": "t.ex. 18", "live_temp_placeholder": "t.ex. 18",
"live_precip_placeholder": "t.ex. lätt regn", "live_precip_placeholder": "t.ex. lätt regn",
@@ -338,6 +359,7 @@
"carry_over_tanks_yes": "Ta över", "carry_over_tanks_yes": "Ta över",
"carry_over_tanks_no": "Börja med 0", "carry_over_tanks_no": "Börja med 0",
"event_title": "Kronologisk händelselogg", "event_title": "Kronologisk händelselogg",
"event_creator": "Registrerad av",
"no_events": "Inga händelser inlagda för denna resdag ännu.", "no_events": "Inga händelser inlagda för denna resdag ännu.",
"event_time": "Tid på dygnet", "event_time": "Tid på dygnet",
"event_mgk": "MgK-kurs", "event_mgk": "MgK-kurs",
@@ -375,6 +397,24 @@
"event_location_placeholder": "z. t.ex. Kiel", "event_location_placeholder": "z. t.ex. Kiel",
"event_remarks": "Anmärkningar / incidenter", "event_remarks": "Anmärkningar / incidenter",
"gps_btn": "Hämta GPS-koordinater", "gps_btn": "Hämta GPS-koordinater",
"gps_permission_denied": "Platstillgång nekades. Tillåt det i webbläsar- eller enhetsinställningar och försök igen.",
"gps_timeout": "GPS fick tidsgräns. Försök igen utomhus med fri sikt mot himlen.",
"gps_position_unavailable": "Ingen GPS-signal tillgänglig. Vänta och försök igen, eller ange koordinater manuellt.",
"gps_unavailable": "GPS stöds inte av denna webbläsare eller enhet.",
"gps_failed": "GPS-position kunde inte bestämmas.",
"gps_fallback_no_location": "GPS misslyckades. Ange en plats under ort/hamn, avresa eller destination, eller skriv koordinater manuellt.",
"gps_fallback_success": "Koordinater för \"{{location}}\" hittades via ortsnamn (inte GPS).",
"gps_fallback_failed": "GPS och ortnamnssökning misslyckades. Ange koordinater manuellt.",
"gps_quality_excellent": "Stark GPS-mottagning (±{{accuracy}} m)",
"gps_quality_good": "Bra GPS-mottagning (±{{accuracy}} m)",
"gps_quality_fair": "Måttlig GPS-mottagning (±{{accuracy}} m) gå utomhus för bättre signal.",
"gps_quality_poor": "Svag GPS-mottagning (±{{accuracy}} m) troligen få satelliter. Försök utomhus igen eller kontrollera positionen.",
"gps_quality_unknown": "GPS-position övertagen (noggrannhet ej rapporterad av enheten).",
"gps_live_intro_title": "Plats för live-logg",
"gps_live_intro_body": "Appen behöver din plats för automatiska positionsregistreringar och GPS-knappen.\n\nTryck på „Tillåt plats“ och bekräfta i nästa dialog. Du kan alltid ange position manuellt via „Position“.",
"gps_live_intro_allow": "Tillåt plats",
"gps_live_intro_later": "Senare",
"gps_enable_in_settings_hint": "Platstillgång är blockerad. Du kan tillåta det senare i webbläsar- eller enhetsinställningar (webbplats / app → Plats).",
"weather_btn": "OpenWeatherMap Ring upp väder", "weather_btn": "OpenWeatherMap Ring upp väder",
"weather_offline": "OpenWeatherMap kräver internetanslutning. Du är offline.", "weather_offline": "OpenWeatherMap kräver internetanslutning. Du är offline.",
"event_wind_pressure": "Lufttryck (hPa)", "event_wind_pressure": "Lufttryck (hPa)",
@@ -402,10 +442,12 @@
"ai_summary_error_rate_limited": "Maximalt antal genereringar nått för denna resedag.", "ai_summary_error_rate_limited": "Maximalt antal genereringar nått för denna resedag.",
"ai_summary_error_forbidden": "Endast skepparen får generera AI-sammanfattningar.", "ai_summary_error_forbidden": "Endast skepparen får generera AI-sammanfattningar.",
"ai_summary_offline": "AI-sammanfattning kräver internetanslutning. Du är offline.", "ai_summary_offline": "AI-sammanfattning kräver internetanslutning. Du är offline.",
"photos_title": "Fotobilagor (E2E-krypterade)", "photos_title": "Fotobilagor",
"photo_caption_label": "Fotobeskrivning/etikett (valfritt)", "photo_caption_label": "Fotobeskrivning/etikett (valfritt)",
"photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet", "photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet",
"photo_btn": "Ta foto / ladda upp", "photo_btn": "Ta foto / ladda upp",
"photo_camera_btn": "Ta foto",
"photo_gallery_btn": "Välj från galleri",
"photo_processing": "Håller på att bearbetas...", "photo_processing": "Håller på att bearbetas...",
"no_photos": "Inga foton kopplade till denna resdag ännu.", "no_photos": "Inga foton kopplade till denna resdag ännu.",
"photo_delete_confirm": "Är du säker på att du vill radera det här fotot permanent?", "photo_delete_confirm": "Är du säker på att du vill radera det här fotot permanent?",
@@ -480,8 +522,8 @@
"nmea_change_engine_stop": "Engine off", "nmea_change_engine_stop": "Engine off",
"nmea_change_autopilot_on": "Autopilot on", "nmea_change_autopilot_on": "Autopilot on",
"nmea_change_autopilot_off": "Autopilot off", "nmea_change_autopilot_off": "Autopilot off",
"nmea_change_gps_lost": "GPS fix lost", "nmea_change_gps_lost": "GPS-position förlorad",
"nmea_change_gps_regained": "GPS fix restored", "nmea_change_gps_regained": "GPS-position återställd",
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C", "nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway", "nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop", "nmea_change_anchor": "Anchored / stop",
@@ -635,6 +677,12 @@
"integrations_title": "Integrationer", "integrations_title": "Integrationer",
"owm_key": "OpenWeatherMap API-nyckel", "owm_key": "OpenWeatherMap API-nyckel",
"owm_help": "Valfritt: egen OpenWeatherMap API-nyckel. Om inget anges används nyckeln på serversidan från operatörskonfigurationen.", "owm_help": "Valfritt: egen OpenWeatherMap API-nyckel. Om inget anges används nyckeln på serversidan från operatörskonfigurationen.",
"ai_title": "AI-funktioner och integritet",
"ai_desc": "Auktorisera integrationer av artificiell intelligens för dina loggböcker.",
"ai_help": "Genom at aktivera AI-funktioner kan appen sammanfatta dina rejsdagar och transkribera röstmemon. För att bearbeta dessa förfrågningar skickas röstdata och rejsloggar säkert och tillfälligt till OpenRouter. Inga data sparas permanent av AI-modellen.\n\nDessa molnresurser kostar pengar att driva. Om du gillar att använda dem, överväg att frivilligt stödja projektet med en donation via Ko-fi-länken i sidfoten för att hålla dem gratis och hållbara för alla.",
"ai_enable_label": "Aktivera transkribering och sammanfattningar av rejsdagar",
"ai_unauthorized_alert_title": "AI-funktioner är inte auktoriserade",
"ai_unauthorized_alert_desc": "För att använda transkribering eller rejsdagsöversikter måste du auktorisera dataöverföringen till OpenRouter i din användarprofil under 'AI-funktioner och integritet'.",
"prefs_save": "Spara", "prefs_save": "Spara",
"prefs_saving": "Kommer att sparas...", "prefs_saving": "Kommer att sparas...",
"prefs_saved": "Sparade", "prefs_saved": "Sparade",
@@ -756,6 +804,9 @@
"no_key": "Ingen OpenWeatherMap API-nyckel tillgänglig. Spara din egen nyckel i användarprofilen eller kontakta operatören.", "no_key": "Ingen OpenWeatherMap API-nyckel tillgänglig. Spara din egen nyckel i användarprofilen eller kontakta operatören.",
"weather_success": "Väderdata har hämtats framgångsrikt!", "weather_success": "Väderdata har hämtats framgångsrikt!",
"weather_error": "Hämtning av väderdata misslyckades. Kontrollera API-nyckeln och anslutningen.", "weather_error": "Hämtning av väderdata misslyckades. Kontrollera API-nyckeln och anslutningen.",
"weather_unauthorized": "Hämtning av väderdata misslyckades. API-nyckeln är ogiltig eller inte auktoriserad.",
"weather_not_found": "Hämtning av väderdata misslyckades. Den angivna platsen eller koordinaterna hittades inte.",
"weather_bad_request": "Hämtning av väderdata misslyckades. Ingen plats eller GPS-position angavs.",
"weather_date_mismatch": "Väderdata kan endast hämtas för idag ({{today}}). Denna loggbokspost är daterad {{date}}.", "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.", "gps_error": "Ange en plats eller bestäm GPS-koordinaterna.",
"share_title": "Aktieloggbok (skrivskyddad)", "share_title": "Aktieloggbok (skrivskyddad)",
+75
View File
@@ -0,0 +1,75 @@
import { ApiError, apiJson } from './api.js'
const ADMIN_BASE = '/api/admin'
export interface AdminMe {
isAdmin: boolean
userId: string
}
export interface AdminSummary {
totalUsers: number
totalLogbooks: number
totalPhotos: number
totalVoiceMemos: number
totalGpsTracks: number
totalCollaborations: number
totalInvitations: number
aiSummaryEntries: number
dbSize: number
}
export type AdminTimeBucket = 'day' | 'week' | 'month'
export interface AdminTimeSeriesPoint {
date: string
count: number
}
export interface AdminTimeSeriesMetric {
metric: string
points: AdminTimeSeriesPoint[]
}
export interface AdminTimeSeriesResponse {
bucket: AdminTimeBucket
windowDays: number
series: AdminTimeSeriesMetric[]
}
export async function fetchAdminMe(): Promise<AdminMe> {
return await apiJson<AdminMe>(`${ADMIN_BASE}/me`)
}
/** Returns true only for users listed in server ADMIN_USER_IDS. */
export async function checkAdminAccess(): Promise<boolean> {
try {
await fetchAdminMe()
return true
} catch (err) {
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
return false
}
return false
}
}
export async function fetchAdminSummary(): Promise<AdminSummary> {
return await apiJson<AdminSummary>(`${ADMIN_BASE}/summary`)
}
export async function fetchAdminTimeSeries(
params: { bucket?: AdminTimeBucket; windowDays?: number } = {}
): Promise<AdminTimeSeriesResponse> {
const search = new URLSearchParams()
if (params.bucket) {
search.set('bucket', params.bucket)
}
if (params.windowDays && Number.isFinite(params.windowDays)) {
search.set('window', String(params.windowDays))
}
const query = search.toString()
const url = query ? `${ADMIN_BASE}/timeseries?${query}` : `${ADMIN_BASE}/timeseries`
return await apiJson<AdminTimeSeriesResponse>(url)
}
+2 -2
View File
@@ -26,6 +26,7 @@ export const PlausibleEvents = {
PUSH_ENABLED: 'Push Enabled', PUSH_ENABLED: 'Push Enabled',
PUSH_DISABLED: 'Push Disabled', PUSH_DISABLED: 'Push Disabled',
FOOTER_LINK_CLICKED: 'Footer Link Clicked', FOOTER_LINK_CLICKED: 'Footer Link Clicked',
KOFI_LINK_CLICKED: 'Ko-fi Link Clicked',
PROFILE_OPENED: 'Profile Opened', PROFILE_OPENED: 'Profile Opened',
PASSKEY_ADDED: 'Passkey Added', PASSKEY_ADDED: 'Passkey Added',
PASSKEY_REMOVED: 'Passkey Removed', PASSKEY_REMOVED: 'Passkey Removed',
@@ -40,9 +41,8 @@ export const PlausibleEvents = {
NMEA_UPLOADED: 'NMEA Uploaded', NMEA_UPLOADED: 'NMEA Uploaded',
LIVE_LOG_OPENED: 'Live Log Opened', LIVE_LOG_OPENED: 'Live Log Opened',
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged', LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
LIVE_LOG_PHOTO_UPLOADED: 'Live Log Photo Uploaded',
VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded', VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded',
LIVE_LOG_VOICE_UPLOADED: 'Live Log Voice Uploaded', VOICE_MEMO_TRANSCRIBED: 'Voice Memo Transcribed',
OWM_WEATHER_FETCHED: 'OWM Weather Fetched', OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
AI_SUMMARY_GENERATED: 'AI Summary Generated', AI_SUMMARY_GENERATED: 'AI Summary Generated',
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft', PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
+8 -4
View File
@@ -26,6 +26,7 @@ describe('appearancePrefs', () => {
await expect(fetchAppearancePrefs()).resolves.toEqual({ await expect(fetchAppearancePrefs()).resolves.toEqual({
theme: 'auto', theme: 'auto',
colorScheme: 'auto', colorScheme: 'auto',
aiAuthorized: false,
persisted: false persisted: false
}) })
expect(mockedApiJson).not.toHaveBeenCalled() expect(mockedApiJson).not.toHaveBeenCalled()
@@ -36,6 +37,7 @@ describe('appearancePrefs', () => {
mockedApiJson.mockResolvedValueOnce({ mockedApiJson.mockResolvedValueOnce({
theme: 'ocean', theme: 'ocean',
colorScheme: 'dark', colorScheme: 'dark',
aiAuthorized: true,
persisted: true persisted: true
}) })
@@ -46,6 +48,7 @@ describe('appearancePrefs', () => {
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean') expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean')
expect(localStorage.getItem(`user_pref_color_scheme_${USER_ID}`)).toBe('dark') expect(localStorage.getItem(`user_pref_color_scheme_${USER_ID}`)).toBe('dark')
expect(localStorage.getItem(`user_pref_ai_authorized_${USER_ID}`)).toBe('true')
expect(changed).toHaveBeenCalledTimes(1) expect(changed).toHaveBeenCalledTimes(1)
}) })
@@ -53,20 +56,20 @@ describe('appearancePrefs', () => {
localStorage.setItem('active_userid', USER_ID) localStorage.setItem('active_userid', USER_ID)
setThemePreference(USER_ID, 'material') setThemePreference(USER_ID, 'material')
mockedApiJson mockedApiJson
.mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', persisted: false }) .mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', aiAuthorized: false, persisted: false })
.mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', persisted: true }) .mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', aiAuthorized: false, persisted: true })
await syncAppearancePrefs(USER_ID) await syncAppearancePrefs(USER_ID)
expect(mockedApiJson).toHaveBeenCalledTimes(2) expect(mockedApiJson).toHaveBeenCalledTimes(2)
expect(mockedApiJson).toHaveBeenLastCalledWith('/api/auth/appearance-prefs', { expect(mockedApiJson).toHaveBeenLastCalledWith('/api/auth/appearance-prefs', {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ theme: 'material', colorScheme: 'auto' }) body: JSON.stringify({ theme: 'material', colorScheme: 'auto', aiAuthorized: false })
}) })
}) })
it('saveAppearancePrefsToServer skips when not authenticated', async () => { it('saveAppearancePrefsToServer skips when not authenticated', async () => {
await saveAppearancePrefsToServer('ocean', 'light') await saveAppearancePrefsToServer('ocean', 'light', true)
expect(mockedApiJson).not.toHaveBeenCalled() expect(mockedApiJson).not.toHaveBeenCalled()
}) })
@@ -76,6 +79,7 @@ describe('appearancePrefs', () => {
mockedApiJson.mockResolvedValue({ mockedApiJson.mockResolvedValue({
theme: 'material', theme: 'material',
colorScheme: 'dark', colorScheme: 'dark',
aiAuthorized: false,
persisted: true persisted: true
}) })
+16 -5
View File
@@ -5,7 +5,9 @@ import {
getColorSchemePreference, getColorSchemePreference,
getThemePreference, getThemePreference,
setColorSchemePreference, setColorSchemePreference,
setThemePreference setThemePreference,
getAiAuthorized,
setAiAuthorized
} from './userPreferences.js' } from './userPreferences.js'
const API_BASE = '/api/auth/appearance-prefs' const API_BASE = '/api/auth/appearance-prefs'
@@ -13,13 +15,15 @@ const API_BASE = '/api/auth/appearance-prefs'
export interface AppearancePrefs { export interface AppearancePrefs {
theme: string theme: string
colorScheme: string colorScheme: string
aiAuthorized: boolean
persisted: boolean persisted: boolean
} }
function hasLocalAppearancePrefs(userId: string): boolean { function hasLocalAppearancePrefs(userId: string): boolean {
return ( return (
localStorage.getItem(`user_pref_theme_${userId}`) != null || localStorage.getItem(`user_pref_theme_${userId}`) != null ||
localStorage.getItem(`user_pref_color_scheme_${userId}`) != null localStorage.getItem(`user_pref_color_scheme_${userId}`) != null ||
localStorage.getItem(`user_pref_ai_authorized_${userId}`) != null
) )
} }
@@ -35,7 +39,7 @@ function resolveSyncedUserId(userId?: string | null): string | null {
export async function fetchAppearancePrefs(userId?: string | null): Promise<AppearancePrefs> { export async function fetchAppearancePrefs(userId?: string | null): Promise<AppearancePrefs> {
if (!resolveSyncedUserId(userId)) { if (!resolveSyncedUserId(userId)) {
return { theme: 'auto', colorScheme: 'auto', persisted: false } return { theme: 'auto', colorScheme: 'auto', aiAuthorized: false, persisted: false }
} }
return apiJson<AppearancePrefs>(API_BASE) return apiJson<AppearancePrefs>(API_BASE)
@@ -44,13 +48,14 @@ export async function fetchAppearancePrefs(userId?: string | null): Promise<Appe
export async function saveAppearancePrefsToServer( export async function saveAppearancePrefsToServer(
theme: string, theme: string,
colorScheme: string, colorScheme: string,
aiAuthorized: boolean,
userId?: string | null userId?: string | null
): Promise<void> { ): Promise<void> {
if (!resolveSyncedUserId(userId)) return if (!resolveSyncedUserId(userId)) return
await apiJson<AppearancePrefs>(API_BASE, { await apiJson<AppearancePrefs>(API_BASE, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ theme, colorScheme }) body: JSON.stringify({ theme, colorScheme, aiAuthorized })
}) })
} }
@@ -65,8 +70,14 @@ export async function syncAppearancePrefs(userId?: string | null): Promise<void>
if (server.persisted) { if (server.persisted) {
setThemePreference(id, server.theme) setThemePreference(id, server.theme)
setColorSchemePreference(id, server.colorScheme) setColorSchemePreference(id, server.colorScheme)
setAiAuthorized(id, server.aiAuthorized)
} else if (hasLocalAppearancePrefs(id)) { } else if (hasLocalAppearancePrefs(id)) {
await saveAppearancePrefsToServer(getThemePreference(id), getColorSchemePreference(id), id) await saveAppearancePrefsToServer(
getThemePreference(id),
getColorSchemePreference(id),
getAiAuthorized(id),
id
)
} }
} catch (err) { } catch (err) {
console.warn('Failed to sync appearance preferences:', err) console.warn('Failed to sync appearance preferences:', err)
+9
View File
@@ -64,6 +64,15 @@ export function persistSessionUserId(userId: string | undefined): void {
} }
} }
/** Username to use when re-unlocking after reload (active account or sole remembered user). */
export function resolveRestoreUsername(): string | null {
const stored = localStorage.getItem('active_username')
if (stored) return stored
const known = getKnownUsernames()
if (known.length === 1) return known[0]
return null
}
export async function reauthWithPasskey(): Promise<boolean> { export async function reauthWithPasskey(): Promise<boolean> {
const options = await apiJson<any>(`${API_BASE}/reauth-options`, { const options = await apiJson<any>(`${API_BASE}/reauth-options`, {
method: 'POST' method: 'POST'
+23
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
import { import {
hasUnlockedLocalCrypto, hasUnlockedLocalCrypto,
hasUnlockedLocalSession, hasUnlockedLocalSession,
resolveRestoreUsername,
setActiveMasterKey setActiveMasterKey
} from './auth.js' } from './auth.js'
@@ -33,6 +34,28 @@ describe('local session unlock checks', () => {
}) })
}) })
describe('resolveRestoreUsername', () => {
beforeEach(() => {
localStorage.clear()
})
it('prefers active_username from storage', () => {
localStorage.setItem('active_username', 'captain')
localStorage.setItem('daagbox_known_users', JSON.stringify(['other']))
expect(resolveRestoreUsername()).toBe('captain')
})
it('falls back to a single remembered user', () => {
localStorage.setItem('daagbox_known_users', JSON.stringify(['solo']))
expect(resolveRestoreUsername()).toBe('solo')
})
it('returns null when multiple users and no active username', () => {
localStorage.setItem('daagbox_known_users', JSON.stringify(['alpha', 'beta']))
expect(resolveRestoreUsername()).toBeNull()
})
})
describe('persistSessionUserId', () => { describe('persistSessionUserId', () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear() localStorage.clear()
+14 -3
View File
@@ -77,7 +77,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'AI Summary', 'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'AI Summary',
'Skipper Signature', 'Crew Signature', 'Skipper Signature', 'Crew Signature',
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)', 'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
'Event Time', 'MgK Course', 'RwK Course', 'Event Time', 'Event Creator', 'MgK Course', 'RwK Course',
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Visibility', 'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Visibility',
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)', 'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
'Latitude', 'Longitude', 'Remarks', 'Latitude', 'Longitude', 'Remarks',
@@ -122,6 +122,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const greywaterLevel = entry.greywater?.level ?? ''; const greywaterLevel = entry.greywater?.level ?? '';
const aiSummary = entry.aiSummary ?? ''; const aiSummary = entry.aiSummary ?? '';
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {};
const eventsList = entry.events || []; const eventsList = entry.events || [];
if (eventsList.length === 0) { if (eventsList.length === 0) {
// Create one row even if there are no events for the day // Create one row even if there are no events for the day
@@ -129,7 +130,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
dateVal, travelDay, dep, dest, aiSummary, dateVal, travelDay, dep, dest, aiSummary,
signS, signC, signS, signC,
trackDist, trackMax, trackAvg, motorH, trackDist, trackMax, trackAvg, motorH,
'', '', '', '', '', '', '',
'', '', '', '', '', '', '', '', '', '',
'', '', '', '', '', '', '', '', '', '',
'', '', '', '', '', '',
@@ -142,11 +143,21 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
// Sort events chronologically by time // Sort events chronologically by time
const sortedEvents = sortLogEventsByTime(eventsList); const sortedEvents = sortLogEventsByTime(eventsList);
for (const ev of sortedEvents) { for (const ev of sortedEvents) {
const creatorSnap = ev.creatorId ? crewSnapshots[ev.creatorId] : null;
let creatorName = '';
if (creatorSnap) {
creatorName = creatorSnap.name || '';
} else if (ev.creatorId === 'skipper') {
creatorName = 'Skipper';
} else if (ev.creatorId) {
creatorName = ev.creatorId;
}
rows.push([ rows.push([
dateVal, travelDay, dep, dest, aiSummary, dateVal, travelDay, dep, dest, aiSummary,
signS, signC, signS, signC,
trackDist, trackMax, trackAvg, motorH, trackDist, trackMax, trackAvg, motorH,
ev.time || '', ev.mgk || '', ev.rwk || '', ev.time || '', creatorName, ev.mgk || '', ev.rwk || '',
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '', ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
ev.visibility || '', ev.visibility || '',
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '', ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
+4 -2
View File
@@ -1,3 +1,4 @@
import { formatAppDecimal } from '../utils/numberFormat.js'
import { db } from './db.js' import { db } from './db.js'
import { getActiveMasterKey } from './auth.js' import { getActiveMasterKey } from './auth.js'
import { import {
@@ -639,9 +640,10 @@ export function downloadBackupBlob(blob: Blob, filename: string): void {
/** Human-readable size for UI warnings. */ /** Human-readable size for UI warnings. */
export function formatBackupBytes(bytes: number): string { export function formatBackupBytes(bytes: number): string {
const fmt = (n: number) => formatAppDecimal(n, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
if (bytes < 1024) return `${bytes} B` if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` if (bytes < 1024 * 1024) return `${fmt(bytes / 1024)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB` return `${fmt(bytes / (1024 * 1024))} MB`
} }
export const BACKUP_SIZE_WARN_BYTES = 50_000_000 export const BACKUP_SIZE_WARN_BYTES = 50_000_000
@@ -1,7 +1,12 @@
import { formatAppDecimal } from '../../utils/numberFormat.js'
import type { NmeaChangeEvent, NmeaDetectionConfig, NmeaTimePoint } from './nmeaTypes.js' import type { NmeaChangeEvent, NmeaDetectionConfig, NmeaTimePoint } from './nmeaTypes.js'
import { DEFAULT_NMEA_DETECTION_CONFIG } from './nmeaTypes.js' import { DEFAULT_NMEA_DETECTION_CONFIG } from './nmeaTypes.js'
import { angularDelta } from './nmeaTimeSeries.js' import { angularDelta } from './nmeaTimeSeries.js'
function formatNmeaDecimal(value: number): string {
return formatAppDecimal(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
}
function pushUnique(events: NmeaChangeEvent[], event: NmeaChangeEvent, minGapMs: number) { function pushUnique(events: NmeaChangeEvent[], event: NmeaChangeEvent, minGapMs: number) {
const last = events[events.length - 1] const last = events[events.length - 1]
if (last && last.type === event.type && event.timestamp - last.timestamp < minGapMs) return if (last && last.type === event.type && event.timestamp - last.timestamp < minGapMs) return
@@ -64,7 +69,7 @@ export function detectNmeaChanges(
timestamp: p.timestamp, timestamp: p.timestamp,
confidence: 'medium', confidence: 'medium',
summaryKey: 'logs.nmea_change_wind_speed', summaryKey: 'logs.nmea_change_wind_speed',
summaryParams: { from: lastWindSpeed.toFixed(1), to: p.windSpeedKnots.toFixed(1) }, summaryParams: { from: formatNmeaDecimal(lastWindSpeed), to: formatNmeaDecimal(p.windSpeedKnots) },
data: p data: p
}, config.dedupeWindowMs) }, config.dedupeWindowMs)
} }
@@ -79,7 +84,7 @@ export function detectNmeaChanges(
timestamp: p.timestamp, timestamp: p.timestamp,
confidence: 'medium', confidence: 'medium',
summaryKey: 'logs.nmea_change_pressure', summaryKey: 'logs.nmea_change_pressure',
summaryParams: { from: lastPressure.toFixed(1), to: p.pressureHpa.toFixed(1) }, summaryParams: { from: formatNmeaDecimal(lastPressure), to: formatNmeaDecimal(p.pressureHpa) },
data: p data: p
}, config.dedupeWindowMs) }, config.dedupeWindowMs)
} }
@@ -95,7 +100,7 @@ export function detectNmeaChanges(
timestamp: p.timestamp, timestamp: p.timestamp,
confidence: 'high', confidence: 'high',
summaryKey: 'logs.nmea_change_depth', summaryKey: 'logs.nmea_change_depth',
summaryParams: { from: lastDepth.toFixed(1), to: p.depthM.toFixed(1) }, summaryParams: { from: formatNmeaDecimal(lastDepth), to: formatNmeaDecimal(p.depthM) },
data: p data: p
}, config.dedupeWindowMs) }, config.dedupeWindowMs)
} }
@@ -156,7 +161,7 @@ export function detectNmeaChanges(
timestamp: p.timestamp, timestamp: p.timestamp,
confidence: 'medium', confidence: 'medium',
summaryKey: 'logs.nmea_change_water_temp', summaryKey: 'logs.nmea_change_water_temp',
summaryParams: { from: lastWaterTemp.toFixed(1), to: p.waterTempC.toFixed(1) }, summaryParams: { from: formatNmeaDecimal(lastWaterTemp), to: formatNmeaDecimal(p.waterTempC) },
data: p data: p
}, config.dedupeWindowMs) }, config.dedupeWindowMs)
} }
@@ -200,7 +205,7 @@ export function detectNmeaChanges(
timestamp: p.timestamp, timestamp: p.timestamp,
confidence: 'low', confidence: 'low',
summaryKey: 'logs.nmea_change_speed', summaryKey: 'logs.nmea_change_speed',
summaryParams: { from: lastSog.toFixed(1), to: sog.toFixed(1) }, summaryParams: { from: formatNmeaDecimal(lastSog), to: formatNmeaDecimal(sog) },
data: p data: p
}, config.dedupeWindowMs) }, config.dedupeWindowMs)
} }
@@ -2,6 +2,7 @@ import type { TFunction } from 'i18next'
import type { LogEventPayload } from '../../utils/logEntryPayload.js' import type { LogEventPayload } from '../../utils/logEntryPayload.js'
import { normalizeLogEvent } from '../../utils/logEntryPayload.js' import { normalizeLogEvent } from '../../utils/logEntryPayload.js'
import { formatCourseAngle } from '../../utils/courseAngle.js' import { formatCourseAngle } from '../../utils/courseAngle.js'
import { formatAppDecimal, formatCanonicalCoordinate } from '../../utils/numberFormat.js'
import { degreesToCardinal } from '../../utils/courseAngle.js' import { degreesToCardinal } from '../../utils/courseAngle.js'
import type { import type {
NmeaChangeEvent, NmeaChangeEvent,
@@ -33,9 +34,12 @@ function pointToLogEvent(
windDirection: windDir, windDirection: windDir,
windStrength: point.windSpeedKnots != null ? String(point.windSpeedKnots) : '', windStrength: point.windSpeedKnots != null ? String(point.windSpeedKnots) : '',
windPressure: point.pressureHpa != null ? String(Math.round(point.pressureHpa)) : '', windPressure: point.pressureHpa != null ? String(Math.round(point.pressureHpa)) : '',
gpsLat: point.lat != null ? point.lat.toFixed(6) : '', gpsLat: point.lat != null ? formatCanonicalCoordinate(point.lat) : '',
gpsLng: point.lng != null ? point.lng.toFixed(6) : '', gpsLng: point.lng != null ? formatCanonicalCoordinate(point.lng) : '',
logReading: point.logDistanceNm != null ? point.logDistanceNm.toFixed(2) : '', logReading:
point.logDistanceNm != null
? formatAppDecimal(point.logDistanceNm, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: '',
sailsOrMotor, sailsOrMotor,
remarks remarks
}) })
@@ -51,7 +55,11 @@ function buildRemarks(change: NmeaChangeEvent, t: TFunction): string {
const parts: string[] = [] const parts: string[] = []
parts.push(t(change.summaryKey, change.summaryParams ?? {})) parts.push(t(change.summaryKey, change.summaryParams ?? {}))
if (change.data?.depthM != null) { if (change.data?.depthM != null) {
parts.push(t('logs.nmea_remark_depth', { depth: change.data.depthM.toFixed(1) })) parts.push(
t('logs.nmea_remark_depth', {
depth: formatAppDecimal(change.data.depthM, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
})
)
} }
if (change.confidence === 'low') { if (change.confidence === 'low') {
parts.push(t('logs.nmea_remark_uncertain')) parts.push(t('logs.nmea_remark_uncertain'))
+66 -12
View File
@@ -13,12 +13,13 @@ function formatPasskeySignDate(signedAt: string): string {
} }
export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> { export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> {
let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = ''; let yachtName = '', owner = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
let entry: any = null; let entry: any = null;
if (preloadedData) { if (preloadedData) {
const yacht = preloadedData.yacht || {}; const yacht = preloadedData.yacht || {};
yachtName = yacht.name || ''; yachtName = yacht.name || '';
owner = yacht.owner || '';
homePort = yacht.port || ''; homePort = yacht.port || '';
registration = yacht.registrationNumber || yacht.registration || ''; registration = yacht.registrationNumber || yacht.registration || '';
callsign = yacht.callSign || ''; callsign = yacht.callSign || '';
@@ -35,6 +36,7 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
const yacht = await resolveVesselForLogbook(logbookId) const yacht = await resolveVesselForLogbook(logbookId)
if (yacht) { if (yacht) {
yachtName = yacht.name || '' yachtName = yacht.name || ''
owner = yacht.owner || ''
homePort = yacht.homePort || '' homePort = yacht.homePort || ''
registration = yacht.registrationNumber || '' registration = yacht.registrationNumber || ''
callsign = yacht.callSign || '' callsign = yacht.callSign || ''
@@ -74,24 +76,56 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
doc.setFontSize(8.5); doc.setFontSize(8.5);
doc.setFont('Helvetica', 'normal'); doc.setFont('Helvetica', 'normal');
doc.text(`Yachtname: ${yachtName || '—'}`, 10, 21); doc.text(`Yachtname: ${yachtName || '—'}`, 10, 21);
doc.text(`Heimathafen: ${homePort || '—'}`, 60, 21); doc.text(`Eigner: ${owner || '—'}`, 55, 21);
doc.text(`Kennzeichen: ${registration || '—'}`, 110, 21); doc.text(`Heimathafen: ${homePort || '—'}`, 100, 21);
doc.text(`Rufzeichen: ${callsign || '—'}`, 160, 21); doc.text(`Kennzeichen: ${registration || '—'}`, 145, 21);
doc.text(`ATIS: ${atis || '—'}`, 210, 21); doc.text(`Rufzeichen: ${callsign || '—'}`, 190, 21);
doc.text(`MMSI: ${mmsi || '—'}`, 250, 21); doc.text(`ATIS: ${atis || '—'}`, 230, 21);
doc.text(`MMSI: ${mmsi || '—'}`, 260, 21);
doc.text(`Datum: ${entry.date || '—'}`, 10, 23); doc.text(`Datum: ${entry.date || '—'}`, 10, 24);
doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 23); doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 24);
doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 23); doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 24);
doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 23); doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 24);
// Format Crew names with initials
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {}
const crewList: string[] = []
if (entry.selectedSkipperId && crewSnapshots[entry.selectedSkipperId]) {
const name = crewSnapshots[entry.selectedSkipperId].name || 'Skipper'
const initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || 'S'
crewList.push(`${name} [${initial}] (Skipper)`)
} else if (crewSnapshots['skipper']) {
const name = crewSnapshots['skipper'].name || 'Skipper'
crewList.push(`${name} [S] (Skipper)`)
}
if (Array.isArray(entry.selectedCrewIds)) {
for (const crewId of entry.selectedCrewIds) {
const snap = crewSnapshots[crewId]
if (snap) {
const name = snap.name || ''
const initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '?'
crewList.push(`${name} [${initial}]`)
}
}
}
const crewText = crewList.length > 0 ? `Besatzung (Crew): ${crewList.join(', ')}` : ''
doc.setFont('Helvetica', 'normal');
if (entry.trackDistanceNm) { if (entry.trackDistanceNm) {
doc.setFont('Helvetica', 'normal');
doc.text( doc.text(
`GPS-Track: ${entry.trackDistanceNm} sm · max. ${entry.trackSpeedMaxKn ?? '—'} kn · Ø ${entry.trackSpeedAvgKn ?? '—'} kn`, `GPS-Track: ${entry.trackDistanceNm} sm · max. ${entry.trackSpeedMaxKn ?? '—'} kn · Ø ${entry.trackSpeedAvgKn ?? '—'} kn`,
10, 10,
27 27
); );
if (crewText) {
doc.text(crewText, 140, 27);
}
} else if (crewText) {
doc.text(crewText, 10, 27);
} }
// Divider line // Divider line
@@ -175,8 +209,28 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
doc.text(gps, writeX + 1, y + 4.2); doc.text(gps, writeX + 1, y + 4.2);
writeX += colWidths[11]; writeX += colWidths[11];
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {};
let initial = '';
if (ev.creatorId) {
const snap = crewSnapshots[ev.creatorId];
let name = '';
if (snap) {
name = snap.name || '';
} else if (ev.creatorId === 'skipper') {
name = 'Skipper';
} else {
name = ev.creatorId;
}
if (name) {
initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '';
}
}
// Clip remarks to fit within the 94mm bounds // Clip remarks to fit within the 94mm bounds
const remarks = ev.remarks || ''; let remarks = ev.remarks || '';
if (initial) {
remarks = `[${initial}] ${remarks}`;
}
const maxChars = 65; const maxChars = 65;
const clippedRemarks = remarks.length > maxChars ? remarks.substring(0, maxChars) + '...' : remarks; const clippedRemarks = remarks.length > maxChars ? remarks.substring(0, maxChars) + '...' : remarks;
doc.text(clippedRemarks, writeX + 1, y + 4.2); doc.text(clippedRemarks, writeX + 1, y + 4.2);
-3
View File
@@ -55,9 +55,6 @@ export async function saveEntryPhoto(options: {
}) })
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: analyticsContext }) trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: analyticsContext })
if (analyticsContext === 'live_log') {
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_PHOTO_UPLOADED)
}
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err)) syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
return photoId return photoId
} }
+109 -15
View File
@@ -9,6 +9,7 @@ import {
normalizeLogEvent, normalizeLogEvent,
sortLogEventsByTime, sortLogEventsByTime,
currentLocalTimeHHMM, currentLocalTimeHHMM,
localDateString,
type LogEventPayload type LogEventPayload
} from '../utils/logEntryPayload.js' } from '../utils/logEntryPayload.js'
import { import {
@@ -96,6 +97,14 @@ function buildEncryptedPayload(
consumption: fuel.consumption ?? 0 consumption: fuel.consumption ?? 0
} }
const entryCrew = data.selectedSkipperId
? {
selectedSkipperId: String(data.selectedSkipperId),
selectedCrewIds: Array.isArray(data.selectedCrewIds) ? data.selectedCrewIds.map(String) : [],
crewSnapshotsById: (data.crewSnapshotsById as Record<string, any>) || {}
}
: undefined
const payload = buildLogEntryPayload({ const payload = buildLogEntryPayload({
date: String(data.date || ''), date: String(data.date || ''),
dayOfTravel: String(data.dayOfTravel || ''), dayOfTravel: String(data.dayOfTravel || ''),
@@ -120,7 +129,8 @@ function buildEncryptedPayload(
motorHoursRaw != null && motorHoursRaw !== '' motorHoursRaw != null && motorHoursRaw !== ''
? parseFloat(String(motorHoursRaw)) ? parseFloat(String(motorHoursRaw))
: undefined, : undefined,
events: options.events events: options.events,
entryCrew
}) })
const clear = options.clearSignatures const clear = options.clearSignatures
@@ -151,18 +161,86 @@ export async function loadEntry(logbookId: string, entryId: string): Promise<Loa
return { payloadId: record.payloadId, updatedAt: record.updatedAt, data } return { payloadId: record.payloadId, updatedAt: record.updatedAt, data }
} }
function scoreTodayEntry(data: Record<string, unknown>): number {
const events = (data.events as unknown[] | undefined)?.length ?? 0
const signed = (data.signSkipper || data.signCrew) ? 1 : 0
const destination = String(data.destination || '').trim() ? 1 : 0
return events * 10 + signed + destination
}
export async function findTodayEntryId(logbookId: string): Promise<string | null> { export async function findTodayEntryId(logbookId: string): Promise<string | null> {
const todayStr = new Date().toISOString().substring(0, 10) const todayStr = localDateString()
const masterKey = await getMasterKey(logbookId) const masterKey = await getMasterKey(logbookId)
const local = sortEntriesNewestFirst(await db.entries.where({ logbookId }).toArray()) const local = sortEntriesNewestFirst(await db.entries.where({ logbookId }).toArray())
let bestId: string | null = null
let bestScore = -1
let bestUpdatedAt = ''
for (const entry of local) { for (const entry of local) {
const decrypted = await tryDecryptEntryPayload(entry, masterKey) const decrypted = await tryDecryptEntryPayload(entry, masterKey)
if (decrypted && String(decrypted.date) === todayStr) { if (!decrypted || String(decrypted.date) !== todayStr) continue
return entry.payloadId
const score = scoreTodayEntry(decrypted)
if (
score > bestScore
|| (score === bestScore && entry.updatedAt > bestUpdatedAt)
) {
bestId = entry.payloadId
bestScore = score
bestUpdatedAt = entry.updatedAt
} }
} }
return null
return bestId
}
async function entryHasAttachments(logbookId: string, entryId: string): Promise<boolean> {
const [photos, voices, track] = await Promise.all([
db.photos.where({ logbookId, entryId }).count(),
db.voiceMemos.where({ logbookId, entryId }).count(),
db.gpsTracks.get(entryId)
])
return photos > 0 || voices > 0 || track != null
}
async function isEmptyTodayEntry(
logbookId: string,
entryId: string,
data: Record<string, unknown>
): Promise<boolean> {
if (((data.events as unknown[] | undefined)?.length ?? 0) > 0) return false
if (data.signSkipper || data.signCrew) return false
if (String(data.destination || '').trim()) return false
return !(await entryHasAttachments(logbookId, entryId))
}
/** Remove duplicate empty travel days for today (e.g. after parallel Live-log init). */
export async function pruneEmptyTodayDuplicates(
logbookId: string,
keepEntryId: string
): Promise<void> {
const todayStr = localDateString()
const masterKey = await getMasterKey(logbookId)
const local = await db.entries.where({ logbookId }).toArray()
const now = new Date().toISOString()
for (const entry of local) {
if (entry.payloadId === keepEntryId) continue
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
if (!decrypted || String(decrypted.date) !== todayStr) continue
if (!(await isEmptyTodayEntry(logbookId, entry.payloadId, decrypted))) continue
await db.entries.delete(entry.payloadId)
await db.syncQueue.put({
action: 'delete',
type: 'entry',
payloadId: entry.payloadId,
logbookId,
data: '',
updatedAt: now
})
}
} }
export async function createTodayEntry(logbookId: string): Promise<string> { export async function createTodayEntry(logbookId: string): Promise<string> {
@@ -185,7 +263,7 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
const localId = window.crypto.randomUUID() const localId = window.crypto.randomUUID()
const nowStr = new Date().toISOString() const nowStr = new Date().toISOString()
const todayStr = nowStr.substring(0, 10) const todayStr = localDateString()
const initialPayload = { const initialPayload = {
date: todayStr, date: todayStr,
@@ -227,20 +305,36 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
return localId return localId
} }
const findOrCreateTodayEntryInflight = new Map<string, Promise<string>>()
async function findOrCreateTodayEntryOnce(logbookId: string): Promise<string> {
await ensureLogbookKey(logbookId)
let entryId = await findTodayEntryId(logbookId)
if (!entryId) {
entryId = await createTodayEntry(logbookId)
}
await pruneEmptyTodayDuplicates(logbookId, entryId)
return entryId
}
/** One travel day per local calendar date; concurrent callers share one in-flight create. */
export async function findOrCreateTodayEntry(logbookId: string): Promise<string> { export async function findOrCreateTodayEntry(logbookId: string): Promise<string> {
const id = logbookId.trim() const id = logbookId.trim()
if (!id) throw new Error('Logbook id required') if (!id) throw new Error('Logbook id required')
await ensureLogbookKey(id) let inflight = findOrCreateTodayEntryInflight.get(id)
if (!inflight) {
const entryCount = await db.entries.where({ logbookId: id }).count() inflight = findOrCreateTodayEntryOnce(id)
if (entryCount === 0) { findOrCreateTodayEntryInflight.set(id, inflight)
return createTodayEntry(id) void inflight.finally(() => {
if (findOrCreateTodayEntryInflight.get(id) === inflight) {
findOrCreateTodayEntryInflight.delete(id)
}
})
} }
return inflight
const existing = await findTodayEntryId(id)
if (existing) return existing
return createTodayEntry(id)
} }
export interface AppendQuickEventResult { export interface AppendQuickEventResult {
+1 -11
View File
@@ -258,14 +258,4 @@ export function getTrackColor(index: number): string {
return TRACK_COLORS[index % TRACK_COLORS.length] return TRACK_COLORS[index % TRACK_COLORS.length]
} }
export function formatNm(value: number): string { export { formatHours, formatLiters, formatNm } from '../utils/numberFormat.js'
return value.toFixed(2)
}
export function formatLiters(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1)
}
export function formatHours(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1)
}
+12 -1
View File
@@ -6,7 +6,9 @@ import {
getThemePreference, getThemePreference,
setColorSchemePreference, setColorSchemePreference,
setOwmApiKey, setOwmApiKey,
setThemePreference setThemePreference,
getAiAuthorized,
setAiAuthorized
} from './userPreferences.js' } from './userPreferences.js'
const USER_ID = 'test-user-123' const USER_ID = 'test-user-123'
@@ -58,4 +60,13 @@ describe('userPreferences', () => {
expect(getThemePreference(USER_ID)).toBe('ocean') expect(getThemePreference(USER_ID)).toBe('ocean')
expect(getColorSchemePreference(USER_ID)).toBe('light') expect(getColorSchemePreference(USER_ID)).toBe('light')
}) })
it('stores AI authorization preference per user', () => {
localStorage.setItem('active_userid', USER_ID)
expect(getAiAuthorized()).toBe(false)
setAiAuthorized(USER_ID, true)
expect(getAiAuthorized()).toBe(true)
expect(getAiAuthorized(USER_ID)).toBe(true)
expect(getAiAuthorized('other-user')).toBe(false)
})
}) })
+17
View File
@@ -89,3 +89,20 @@ export function setOwmApiKey(userId: string, value: string): void {
localStorage.removeItem(owmKey(userId)) localStorage.removeItem(owmKey(userId))
} }
} }
function aiAuthorizedKey(userId: string): string {
return `user_pref_ai_authorized_${userId}`
}
export function getAiAuthorized(userId?: string | null): boolean {
const id = resolveUserId(userId)
if (id) {
return localStorage.getItem(aiAuthorizedKey(id)) === 'true'
}
return false
}
export function setAiAuthorized(userId: string, value: boolean): void {
localStorage.setItem(aiAuthorizedKey(userId), String(value))
}
+57 -5
View File
@@ -1,7 +1,7 @@
import { db } from './db.js' import { db } from './db.js'
import { getActiveMasterKey } from './auth.js' import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js' import { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js' import { encryptJson, decryptJson } from './crypto.js'
import { syncLogbook } from './sync.js' import { syncLogbook } from './sync.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js' import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
@@ -18,6 +18,7 @@ export async function saveEntryVoiceMemo(options: {
mimeType: string mimeType: string
durationSec: number durationSec: number
caption?: string caption?: string
transcribed?: boolean
analyticsContext?: string analyticsContext?: string
}): Promise<string> { }): Promise<string> {
const { const {
@@ -27,6 +28,7 @@ export async function saveEntryVoiceMemo(options: {
mimeType, mimeType,
durationSec, durationSec,
caption = '', caption = '',
transcribed = true,
analyticsContext = 'logbook' analyticsContext = 'logbook'
} = options } = options
const masterKey = await getEncryptionKey(logbookId) const masterKey = await getEncryptionKey(logbookId)
@@ -35,7 +37,8 @@ export async function saveEntryVoiceMemo(options: {
audio: audioDataUrl, audio: audioDataUrl,
mimeType, mimeType,
durationSec, durationSec,
caption: caption.trim() caption: caption.trim(),
transcribed: !!transcribed
} }
const encrypted = await encryptJson(voicePayload, masterKey) const encrypted = await encryptJson(voicePayload, masterKey)
@@ -66,9 +69,6 @@ export async function saveEntryVoiceMemo(options: {
}) })
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_UPLOADED, { context: analyticsContext }) trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_UPLOADED, { context: analyticsContext })
if (analyticsContext === 'live_log') {
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_VOICE_UPLOADED)
}
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err)) syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
return voiceId return voiceId
} }
@@ -101,3 +101,55 @@ export async function removeLastVoiceMemoForEntry(
await deleteEntryVoiceMemo(logbookId, lastId) await deleteEntryVoiceMemo(logbookId, lastId)
return lastId return lastId
} }
/** Updates an existing voice memo payload with a new transcript and sets transcribed: true. */
export async function updateVoiceMemoTranscript(
logbookId: string,
voiceId: string,
transcript: string
): Promise<void> {
const masterKey = await getEncryptionKey(logbookId)
const record = await db.voiceMemos.get(voiceId)
if (!record) throw new Error('Voice memo not found')
const decrypted = await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)
if (!decrypted) throw new Error('Failed to decrypt voice memo')
const manualCaption = decrypted.caption ? String(decrypted.caption).trim() : ''
const finalCaption = manualCaption
? `${manualCaption}\n(Transkript: ${transcript.trim()})`
: transcript.trim()
const updatedPayload = {
...decrypted,
caption: finalCaption,
transcribed: true
}
const encrypted = await encryptJson(updatedPayload, masterKey)
const now = new Date().toISOString()
await db.voiceMemos.put({
...record,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'update',
type: 'voiceMemo',
payloadId: voiceId,
logbookId,
data: JSON.stringify({
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
entryId: record.entryId
}),
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
+47
View File
@@ -69,4 +69,51 @@ describe('fetchOpenWeatherCurrent', () => {
expect(trackPlausibleEvent).not.toHaveBeenCalled() expect(trackPlausibleEvent).not.toHaveBeenCalled()
}) })
it('throws UNAUTHORIZED when status is 401', async () => {
apiFetch.mockResolvedValue({
ok: false,
status: 401,
json: async () => ({ error: 'Unauthorized' })
})
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
expect(err).toBeInstanceOf(WeatherApiError)
expect((err as any).code).toBe('UNAUTHORIZED')
})
it('throws NOT_FOUND when status is 404', async () => {
apiFetch.mockResolvedValue({
ok: false,
status: 404,
json: async () => ({ error: 'Not Found' })
})
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
expect(err).toBeInstanceOf(WeatherApiError)
expect((err as any).code).toBe('NOT_FOUND')
})
it('throws BAD_REQUEST when status is 400', async () => {
apiFetch.mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ error: 'Bad Request' })
})
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
expect(err).toBeInstanceOf(WeatherApiError)
expect((err as any).code).toBe('BAD_REQUEST')
})
it('throws BAD_REQUEST when coordinates or query are missing', async () => {
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
const err = await fetchOpenWeatherCurrent({}).catch((e) => e)
expect(err).toBeInstanceOf(WeatherApiError)
expect((err as any).code).toBe('BAD_REQUEST')
expect(apiFetch).not.toHaveBeenCalled()
})
}) })
+15 -3
View File
@@ -7,9 +7,12 @@ import {
} from './analytics.js' } from './analytics.js'
export class WeatherApiError extends Error { export class WeatherApiError extends Error {
code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' | 'UNAUTHORIZED' | 'NOT_FOUND' | 'BAD_REQUEST'
constructor(message: string, code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' = 'REQUEST_FAILED') { constructor(
message: string,
code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' | 'UNAUTHORIZED' | 'NOT_FOUND' | 'BAD_REQUEST' = 'REQUEST_FAILED'
) {
super(message) super(message)
this.name = 'WeatherApiError' this.name = 'WeatherApiError'
this.code = code this.code = code
@@ -38,7 +41,7 @@ export async function fetchOpenWeatherCurrent(
} else if (params.q?.trim()) { } else if (params.q?.trim()) {
searchParams.set('q', params.q.trim()) searchParams.set('q', params.q.trim())
} else { } else {
throw new WeatherApiError('lat/lon or location query required') throw new WeatherApiError('lat/lon or location query required', 'BAD_REQUEST')
} }
const userKey = getOwmApiKeyForActiveUser().trim() const userKey = getOwmApiKeyForActiveUser().trim()
@@ -65,6 +68,15 @@ export async function fetchOpenWeatherCurrent(
if (res.status === 503) { if (res.status === 503) {
throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY') throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY')
} }
if (res.status === 401) {
throw new WeatherApiError('Invalid OpenWeatherMap API key', 'UNAUTHORIZED')
}
if (res.status === 404) {
throw new WeatherApiError('Location or coordinates not found', 'NOT_FOUND')
}
if (res.status === 400) {
throw new WeatherApiError('Invalid or missing location parameters', 'BAD_REQUEST')
}
const data = await res.json() const data = await res.json()
if (!res.ok) { if (!res.ok) {
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from 'vitest'
import {
cameraErrorKeyFromDomException,
isCameraApiSupported,
probeCameraAvailability
} from './cameraAvailability.js'
describe('cameraAvailability', () => {
it('detects missing camera API', () => {
const nav = { mediaDevices: undefined }
vi.stubGlobal('navigator', nav)
expect(isCameraApiSupported()).toBe(false)
vi.unstubAllGlobals()
})
it('returns none when no videoinput devices', async () => {
vi.stubGlobal('navigator', {
mediaDevices: {
getUserMedia: vi.fn(),
enumerateDevices: vi.fn().mockResolvedValue([
{ kind: 'audioinput', deviceId: 'a1', label: '', groupId: '' }
])
}
})
await expect(probeCameraAvailability()).resolves.toBe('none')
vi.unstubAllGlobals()
})
it('returns available when a videoinput exists', async () => {
vi.stubGlobal('navigator', {
mediaDevices: {
getUserMedia: vi.fn(),
enumerateDevices: vi.fn().mockResolvedValue([
{ kind: 'videoinput', deviceId: 'v1', label: '', groupId: '' }
])
}
})
await expect(probeCameraAvailability()).resolves.toBe('available')
vi.unstubAllGlobals()
})
it('maps NotFoundError to no-camera i18n key', () => {
expect(cameraErrorKeyFromDomException(new DOMException('', 'NotFoundError'))).toBe(
'logs.live_photo_no_camera'
)
})
})
+33
View File
@@ -0,0 +1,33 @@
export type CameraAvailability = 'available' | 'none' | 'unsupported'
/** Whether the browser exposes camera APIs at all. */
export function isCameraApiSupported(): boolean {
return typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia
}
/** Best-effort probe for at least one video input device (no permission prompt). */
export async function probeCameraAvailability(): Promise<CameraAvailability> {
if (!isCameraApiSupported()) return 'unsupported'
if (!navigator.mediaDevices?.enumerateDevices) {
// Cannot list devices; defer to getUserMedia attempt in the capture UI.
return 'available'
}
try {
const devices = await navigator.mediaDevices.enumerateDevices()
if (devices.some((d) => d.kind === 'videoinput')) return 'available'
return 'none'
} catch {
return 'none'
}
}
export function cameraErrorKeyFromDomException(err: unknown): string {
const name = err instanceof DOMException ? err.name : ''
if (name === 'NotFoundError' || name === 'OverconstrainedError') {
return 'logs.live_photo_no_camera'
}
if (name === 'NotAllowedError' || name === 'NotReadableError' || name === 'SecurityError') {
return 'logs.live_photo_camera_denied'
}
return 'logs.live_photo_camera_unavailable'
}
+5 -5
View File
@@ -21,8 +21,8 @@ const t = (key: string, opts?: Record<string, unknown>) => {
'logs.live_cast_off': 'Cast off', 'logs.live_cast_off': 'Cast off',
'logs.live_moor': 'Moor', 'logs.live_moor': 'Moor',
'logs.live_sails': `Sails: ${opts?.sails ?? ''}`, 'logs.live_sails': `Sails: ${opts?.sails ?? ''}`,
'logs.live_fix': 'Fix', 'logs.live_position': 'Position',
'logs.live_fix_coords': `Fix ${opts?.lat}, ${opts?.lng}`, 'logs.live_position_coords': `Position ${opts?.lat}, ${opts?.lng}`,
'logs.live_event_generic': 'Event', 'logs.live_event_generic': 'Event',
'logs.live_temp_entry': `Temperature ${opts?.temp} °C`, 'logs.live_temp_entry': `Temperature ${opts?.temp} °C`,
'logs.live_pressure_entry': `Pressure ${opts?.value} hPa`, 'logs.live_pressure_entry': `Pressure ${opts?.value} hPa`,
@@ -85,14 +85,14 @@ describe('formatEventSummary', () => {
expect(formatEventSummary(event, t)).toBe('Sails: Main + Genoa') expect(formatEventSummary(event, t)).toBe('Sails: Main + Genoa')
}) })
it('formats fix with coordinates', () => { it('formats position with coordinates', () => {
const event = normalizeLogEvent({ const event = normalizeLogEvent({
time: '09:00', time: '09:00',
remarks: LIVE_EVENT_CODES.FIX, remarks: LIVE_EVENT_CODES.POSITION,
gpsLat: '54.323000', gpsLat: '54.323000',
gpsLng: '10.145000' gpsLng: '10.145000'
}) })
expect(formatEventSummary(event, t)).toBe('Fix 54.323000, 10.145000') expect(formatEventSummary(event, t)).toBe('Position 54.323000, 10.145000')
}) })
it('formats pressure entry', () => { it('formats pressure entry', () => {
+4 -3
View File
@@ -1,6 +1,7 @@
import type { TFunction } from 'i18next' import type { TFunction } from 'i18next'
import type { LogEventPayload } from './logEntryPayload.js' import type { LogEventPayload } from './logEntryPayload.js'
import { import {
isManualPositionEventCode,
LIVE_EVENT_CODES, LIVE_EVENT_CODES,
parseLiveCommentRemark, parseLiveCommentRemark,
parseLiveFuelRemark, parseLiveFuelRemark,
@@ -58,16 +59,16 @@ export function formatEventSummary(event: LogEventPayload, t: TFunction): string
const stw = parseLiveStwRemark(code) const stw = parseLiveStwRemark(code)
if (stw) return t('logs.live_stw_entry', { speed: stw }) if (stw) return t('logs.live_stw_entry', { speed: stw })
if (code === LIVE_EVENT_CODES.FIX || code === LIVE_EVENT_CODES.AUTO_POSITION) { if (isManualPositionEventCode(code) || code === LIVE_EVENT_CODES.AUTO_POSITION) {
if (event.gpsLat && event.gpsLng) { if (event.gpsLat && event.gpsLng) {
const label = code === LIVE_EVENT_CODES.AUTO_POSITION const label = code === LIVE_EVENT_CODES.AUTO_POSITION
? t('logs.live_auto_position') ? t('logs.live_auto_position')
: t('logs.live_fix') : t('logs.live_position')
return `${label} ${event.gpsLat}, ${event.gpsLng}` return `${label} ${event.gpsLat}, ${event.gpsLng}`
} }
return code === LIVE_EVENT_CODES.AUTO_POSITION return code === LIVE_EVENT_CODES.AUTO_POSITION
? t('logs.live_auto_position') ? t('logs.live_auto_position')
: t('logs.live_fix') : t('logs.live_position')
} }
if (code === LIVE_EVENT_CODES.COURSE && event.mgk) { if (code === LIVE_EVENT_CODES.COURSE && event.mgk) {
+1 -4
View File
@@ -7,7 +7,4 @@ export function computeFuelPerMotorHour(
return Number((fuelConsumptionL / motorHours).toFixed(2)) return Number((fuelConsumptionL / motorHours).toFixed(2))
} }
export function formatFuelPerMotorHour(value: number | null | undefined): string { export { formatFuelPerMotorHour } from './numberFormat.js'
if (value == null) return '—'
return Number.isInteger(value) ? String(value) : value.toFixed(2)
}
+46 -3
View File
@@ -1,12 +1,29 @@
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { import {
classifyGpsAccuracyMeters,
formatGpsAccuracyMeters,
geolocationErrorI18nKey,
GEOLOCATION_LIVE_INTRO_STORAGE_KEY,
getCurrentPosition, getCurrentPosition,
getGeolocationErrorReason,
hasSeenGeolocationLiveIntro,
markGeolocationLiveIntroSeen,
normalizeGpsCoordinates, normalizeGpsCoordinates,
parseGpsCoordinate, parseGpsCoordinate,
queryGeolocationPermission queryGeolocationPermission
} from './geolocation.js' } from './geolocation.js'
describe('geolocation helpers', () => { describe('geolocation helpers', () => {
beforeEach(() => {
localStorage.removeItem(GEOLOCATION_LIVE_INTRO_STORAGE_KEY)
})
it('tracks Live-Log geolocation intro in localStorage', () => {
expect(hasSeenGeolocationLiveIntro()).toBe(false)
markGeolocationLiveIntroSeen()
expect(hasSeenGeolocationLiveIntro()).toBe(true)
})
it('parses coordinates with comma decimals', () => { it('parses coordinates with comma decimals', () => {
expect(parseGpsCoordinate('54,123')).toBeCloseTo(54.123) expect(parseGpsCoordinate('54,123')).toBeCloseTo(54.123)
}) })
@@ -50,7 +67,7 @@ describe('geolocation helpers', () => {
geolocation: { geolocation: {
getCurrentPosition: (success: PositionCallback) => { getCurrentPosition: (success: PositionCallback) => {
success({ success({
coords: { latitude: 59.91, longitude: 10.75, speed: 2.5 } coords: { latitude: 59.91, longitude: 10.75, speed: 2.5, accuracy: 12 }
} as GeolocationPosition) } as GeolocationPosition)
} }
} }
@@ -59,10 +76,36 @@ describe('geolocation helpers', () => {
await expect(getCurrentPosition({ timeoutMs: 1000, enableHighAccuracy: false })).resolves.toEqual({ await expect(getCurrentPosition({ timeoutMs: 1000, enableHighAccuracy: false })).resolves.toEqual({
lat: '59.910000', lat: '59.910000',
lng: '10.750000', lng: '10.750000',
speedKn: 4.9 speedKn: 4.9,
accuracyM: 12,
signalQuality: 'excellent'
}) })
}) })
it('formats GPS accuracy for display', () => {
expect(formatGpsAccuracyMeters(12.4)).toBe('12')
expect(formatGpsAccuracyMeters(87)).toBe('87')
expect(formatGpsAccuracyMeters(105)).toBe('110')
expect(formatGpsAccuracyMeters(247)).toBe('250')
})
it('classifies GPS accuracy into signal quality', () => {
expect(classifyGpsAccuracyMeters(8)).toBe('excellent')
expect(classifyGpsAccuracyMeters(30)).toBe('good')
expect(classifyGpsAccuracyMeters(80)).toBe('fair')
expect(classifyGpsAccuracyMeters(250)).toBe('poor')
expect(classifyGpsAccuracyMeters(null)).toBe('unknown')
})
it('maps GeolocationPositionError codes to reasons', () => {
expect(getGeolocationErrorReason({ code: 1 } as GeolocationPositionError)).toBe('permission_denied')
expect(getGeolocationErrorReason({ code: 2 } as GeolocationPositionError)).toBe('position_unavailable')
expect(getGeolocationErrorReason({ code: 3 } as GeolocationPositionError)).toBe('timeout')
expect(getGeolocationErrorReason(new Error('geolocation_timeout'))).toBe('timeout')
expect(getGeolocationErrorReason(new Error('geolocation_unavailable'))).toBe('unavailable')
expect(geolocationErrorI18nKey('permission_denied')).toBe('logs.gps_permission_denied')
})
it('reads permission state when supported', async () => { it('reads permission state when supported', async () => {
vi.stubGlobal('navigator', { vi.stubGlobal('navigator', {
geolocation: {}, geolocation: {},
+94 -8
View File
@@ -1,17 +1,80 @@
import {
formatAppCoordinate,
formatCanonicalCoordinate,
formatGpsAccuracyMeters,
parseAppDecimal
} from './numberFormat.js'
const MPS_TO_KNOTS = 1.9438444924406 const MPS_TO_KNOTS = 1.9438444924406
/** Extra ms beyond the native timeout so hung browsers still reject. */ /** Extra ms beyond the native timeout so hung browsers still reject. */
const TIMEOUT_GRACE_MS = 750 const TIMEOUT_GRACE_MS = 750
/** Estimated fix quality from browser accuracy (metres). Real satellite count is not exposed to web apps. */
export type GpsSignalQuality = 'excellent' | 'good' | 'fair' | 'poor' | 'unknown'
export interface GeoCoordinates { export interface GeoCoordinates {
lat: string lat: string
lng: string lng: string
/** SOG from GPS when available (kn), otherwise null. */ /** SOG from GPS when available (kn), otherwise null. */
speedKn: number | null speedKn: number | null
/** Estimated horizontal accuracy in metres, when reported by the browser. */
accuracyM: number | null
/** Derived signal quality indicator for UI hints. */
signalQuality: GpsSignalQuality
}
/** Classifies GPS fix quality from reported accuracy (lower metres = better). */
export function classifyGpsAccuracyMeters(accuracyM: number | null | undefined): GpsSignalQuality {
if (accuracyM == null || !Number.isFinite(accuracyM) || accuracyM < 0) return 'unknown'
if (accuracyM <= 15) return 'excellent'
if (accuracyM <= 40) return 'good'
if (accuracyM <= 100) return 'fair'
return 'poor'
}
export function gpsQualityI18nKey(quality: GpsSignalQuality): string {
return `logs.gps_quality_${quality}`
} }
export type GeolocationPermissionState = PermissionState | 'unsupported' export type GeolocationPermissionState = PermissionState | 'unsupported'
export type GeolocationErrorReason =
| 'unavailable'
| 'timeout'
| 'permission_denied'
| 'position_unavailable'
| 'unknown'
/** Maps browser / wrapper errors to a stable reason for i18n. */
export function getGeolocationErrorReason(error: unknown): GeolocationErrorReason {
if (error instanceof Error) {
if (error.message === 'geolocation_unavailable') return 'unavailable'
if (error.message === 'geolocation_timeout') return 'timeout'
}
const code = (error as GeolocationPositionError | undefined)?.code
if (code === 1) return 'permission_denied'
if (code === 2) return 'position_unavailable'
if (code === 3) return 'timeout'
return 'unknown'
}
/** i18n key (full path, e.g. logs.gps_timeout) for a geolocation failure reason. */
export function geolocationErrorI18nKey(reason: GeolocationErrorReason): string {
switch (reason) {
case 'unavailable':
return 'logs.gps_unavailable'
case 'timeout':
return 'logs.gps_timeout'
case 'permission_denied':
return 'logs.gps_permission_denied'
case 'position_unavailable':
return 'logs.gps_position_unavailable'
default:
return 'logs.gps_failed'
}
}
export interface GetPositionOptions { export interface GetPositionOptions {
timeoutMs?: number timeoutMs?: number
/** Manual fixes may use high accuracy; background auto-position should not. */ /** Manual fixes may use high accuracy; background auto-position should not. */
@@ -19,11 +82,10 @@ export interface GetPositionOptions {
maximumAge?: number maximumAge?: number
} }
export { formatGpsAccuracyMeters }
export function parseGpsCoordinate(value: string): number | null { export function parseGpsCoordinate(value: string): number | null {
const trimmed = value.trim() return parseAppDecimal(value.trim())
if (!trimmed) return null
const n = parseFloat(trimmed.replace(',', '.'))
return Number.isFinite(n) ? n : null
} }
/** Validates lat/lng and returns normalized strings for storage, or null. */ /** Validates lat/lng and returns normalized strings for storage, or null. */
@@ -35,7 +97,26 @@ export function normalizeGpsCoordinates(
const lngN = parseGpsCoordinate(lng) const lngN = parseGpsCoordinate(lng)
if (latN == null || lngN == null) return null if (latN == null || lngN == null) return null
if (latN < -90 || latN > 90 || lngN < -180 || lngN > 180) return null if (latN < -90 || latN > 90 || lngN < -180 || lngN > 180) return null
return { lat: latN.toFixed(6), lng: lngN.toFixed(6) } return { lat: formatCanonicalCoordinate(latN), lng: formatCanonicalCoordinate(lngN) }
}
/** localStorage: user has seen the Live-Log geolocation intro (allow or dismiss). */
export const GEOLOCATION_LIVE_INTRO_STORAGE_KEY = 'kdb_geolocation_live_intro_seen'
export function hasSeenGeolocationLiveIntro(): boolean {
try {
return localStorage.getItem(GEOLOCATION_LIVE_INTRO_STORAGE_KEY) === '1'
} catch {
return false
}
}
export function markGeolocationLiveIntroSeen(): void {
try {
localStorage.setItem(GEOLOCATION_LIVE_INTRO_STORAGE_KEY, '1')
} catch {
// Private mode / quota — non-fatal
}
} }
export async function queryGeolocationPermission(): Promise<GeolocationPermissionState> { export async function queryGeolocationPermission(): Promise<GeolocationPermissionState> {
@@ -65,10 +146,15 @@ function positionFromGeolocationPosition(pos: GeolocationPosition): GeoCoordinat
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed) const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1)) ? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
: null : null
const accuracyM = pos.coords.accuracy != null && Number.isFinite(pos.coords.accuracy)
? pos.coords.accuracy
: null
return { return {
lat: pos.coords.latitude.toFixed(6), lat: formatAppCoordinate(pos.coords.latitude),
lng: pos.coords.longitude.toFixed(6), lng: formatAppCoordinate(pos.coords.longitude),
speedKn speedKn,
accuracyM,
signalQuality: classifyGpsAccuracyMeters(accuracyM)
} }
} }
+21 -14
View File
@@ -4,7 +4,7 @@ export const LIVE_EVENT_CODES = {
MOTOR_STOP: '__live:motor_stop', MOTOR_STOP: '__live:motor_stop',
CAST_OFF: '__live:cast_off', CAST_OFF: '__live:cast_off',
MOOR: '__live:moor', MOOR: '__live:moor',
FIX: '__live:fix', POSITION: '__live:position',
AUTO_POSITION: '__live:auto_position', AUTO_POSITION: '__live:auto_position',
COURSE: '__live:course', COURSE: '__live:course',
WIND: '__live:wind', WIND: '__live:wind',
@@ -13,6 +13,9 @@ export const LIVE_EVENT_CODES = {
VISIBILITY: '__live:visibility' VISIBILITY: '__live:visibility'
} as const } as const
/** @deprecated Stored in older log entries; still recognized when reading events. */
export const LEGACY_LIVE_POSITION_REMARK = '__live:fix'
export type LiveEventCode = (typeof LIVE_EVENT_CODES)[keyof typeof LIVE_EVENT_CODES] export type LiveEventCode = (typeof LIVE_EVENT_CODES)[keyof typeof LIVE_EVENT_CODES]
export function liveSailsRemark(sails: string): string { export function liveSailsRemark(sails: string): string {
@@ -148,27 +151,31 @@ export function getLastAutoPositionMs(
return null return null
} }
/** Max age of a logged GPS fix for OpenWeatherMap lookups in live log. */ /** Max age of a logged position for OpenWeatherMap lookups in live log. */
export const LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS = 6 * 60 * 60 * 1000 export const LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS = 6 * 60 * 60 * 1000
export type LiveLogPositionSource = 'fix' | 'auto_position' export type LiveLogPositionSource = 'position' | 'auto_position'
export interface LiveLogPositionFix { export interface LiveLogPosition {
lat: string lat: string
lng: string lng: string
loggedAtMs: number loggedAtMs: number
source: LiveLogPositionSource source: LiveLogPositionSource
} }
function isPositionEventCode(code: string): boolean { export function isManualPositionEventCode(code: string): boolean {
return code === LIVE_EVENT_CODES.FIX || code === LIVE_EVENT_CODES.AUTO_POSITION return code === LIVE_EVENT_CODES.POSITION || code === LEGACY_LIVE_POSITION_REMARK
} }
/** Latest FIX or auto-position event with GPS coordinates (any age). */ function isPositionEventCode(code: string): boolean {
export function getLatestPositionFix( return isManualPositionEventCode(code) || code === LIVE_EVENT_CODES.AUTO_POSITION
}
/** Latest manual or auto-position event with GPS coordinates (any age). */
export function getLatestLoggedPosition(
events: Array<{ remarks: string; time: string; gpsLat?: string; gpsLng?: string }>, events: Array<{ remarks: string; time: string; gpsLat?: string; gpsLng?: string }>,
entryDate: string entryDate: string
): LiveLogPositionFix | null { ): LiveLogPosition | null {
for (let i = events.length - 1; i >= 0; i--) { for (let i = events.length - 1; i >= 0; i--) {
const event = events[i] const event = events[i]
const code = event.remarks.trim() const code = event.remarks.trim()
@@ -182,20 +189,20 @@ export function getLatestPositionFix(
lat, lat,
lng, lng,
loggedAtMs, loggedAtMs,
source: code === LIVE_EVENT_CODES.FIX ? 'fix' : 'auto_position' source: isManualPositionEventCode(code) ? 'position' : 'auto_position'
} }
} }
return null return null
} }
/** GPS fix for weather if logged within `maxAgeMs` (default 6 h). */ /** Logged position for weather if recorded within `maxAgeMs` (default 6 h). */
export function getLastPositionFixWithin( export function getLastLoggedPositionWithin(
events: Array<{ remarks: string; time: string; gpsLat?: string; gpsLng?: string }>, events: Array<{ remarks: string; time: string; gpsLat?: string; gpsLng?: string }>,
entryDate: string, entryDate: string,
maxAgeMs: number = LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, maxAgeMs: number = LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS,
nowMs: number = Date.now() nowMs: number = Date.now()
): LiveLogPositionFix | null { ): LiveLogPosition | null {
const latest = getLatestPositionFix(events, entryDate) const latest = getLatestLoggedPosition(events, entryDate)
if (!latest) return null if (!latest) return null
if (nowMs - latest.loggedAtMs > maxAgeMs) return null if (nowMs - latest.loggedAtMs > maxAgeMs) return null
return latest return latest
+37 -24
View File
@@ -1,54 +1,67 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
getLastPositionFixWithin, getLastLoggedPositionWithin,
getLatestPositionFix, getLatestLoggedPosition,
LEGACY_LIVE_POSITION_REMARK,
LIVE_EVENT_CODES, LIVE_EVENT_CODES,
LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS
} from './liveEventCodes.js' } from './liveEventCodes.js'
const entryDate = '2026-06-01' describe('live log position', () => {
it('returns latest position with coordinates', () => {
describe('live log position fix', () => { const entryDate = '2026-06-01'
it('returns latest fix with coordinates', () => {
const events = [ const events = [
{ remarks: LIVE_EVENT_CODES.FIX, time: '08:00', gpsLat: '54.1', gpsLng: '10.2' }, { remarks: LIVE_EVENT_CODES.POSITION, time: '08:00', gpsLat: '54.1', gpsLng: '10.2' },
{ remarks: LIVE_EVENT_CODES.FIX, time: '12:30', gpsLat: '54.2', gpsLng: '10.3' } { remarks: LIVE_EVENT_CODES.POSITION, time: '12:30', gpsLat: '54.2', gpsLng: '10.3' }
] ]
const fix = getLatestPositionFix(events, entryDate) const position = getLatestLoggedPosition(events, entryDate)
expect(fix?.lat).toBe('54.2') expect(position?.lat).toBe('54.2')
expect(fix?.source).toBe('fix') expect(position?.source).toBe('position')
}) })
it('accepts auto-position with GPS', () => { it('reads legacy __live:fix remarks', () => {
const entryDate = '2026-06-01'
const events = [
{ remarks: LEGACY_LIVE_POSITION_REMARK, time: '09:00', gpsLat: '54.5', gpsLng: '10.5' }
]
const position = getLatestLoggedPosition(events, entryDate)
expect(position?.lat).toBe('54.5')
expect(position?.source).toBe('position')
})
it('prefers auto-position source when applicable', () => {
const entryDate = '2026-06-01'
const events = [ const events = [
{ {
remarks: LIVE_EVENT_CODES.AUTO_POSITION, remarks: LIVE_EVENT_CODES.AUTO_POSITION,
time: '14:00', time: '14:00',
gpsLat: '55.0', gpsLat: '54.3',
gpsLng: '11.0' gpsLng: '10.4'
} }
] ]
expect(getLatestPositionFix(events, entryDate)?.source).toBe('auto_position') expect(getLatestLoggedPosition(events, entryDate)?.source).toBe('auto_position')
}) })
it('rejects fix older than max age for weather', () => { it('rejects position older than max age for weather', () => {
const noon = new Date(`${entryDate}T12:00:00`).getTime() const entryDate = '2026-06-01'
const noon = new Date('2026-06-01T12:00:00').getTime()
const events = [ const events = [
{ remarks: LIVE_EVENT_CODES.FIX, time: '05:00', gpsLat: '54.0', gpsLng: '10.0' } { remarks: LIVE_EVENT_CODES.POSITION, time: '05:00', gpsLat: '54.0', gpsLng: '10.0' }
] ]
expect( expect(
getLastPositionFixWithin(events, entryDate, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, noon) getLastLoggedPositionWithin(events, entryDate, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, noon)
).toBeNull() ).toBeNull()
expect(getLatestPositionFix(events, entryDate)).not.toBeNull() expect(getLatestLoggedPosition(events, entryDate)).not.toBeNull()
}) })
it('accepts fix within six hours', () => { it('accepts position within six hours', () => {
const noon = new Date(`${entryDate}T12:00:00`).getTime() const entryDate = '2026-06-01'
const noon = new Date('2026-06-01T12:00:00').getTime()
const events = [ const events = [
{ remarks: LIVE_EVENT_CODES.FIX, time: '07:00', gpsLat: '54.0', gpsLng: '10.0' } { remarks: LIVE_EVENT_CODES.POSITION, time: '07:00', gpsLat: '54.0', gpsLng: '10.0' }
] ]
expect( expect(
getLastPositionFixWithin(events, entryDate, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, noon) getLastLoggedPositionWithin(events, entryDate, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, noon)
).not.toBeNull() ).not.toBeNull()
}) })
}) })
+9
View File
@@ -3,6 +3,7 @@ import {
buildLogEntryPayload, buildLogEntryPayload,
hasUnsavedEventDraft, hasUnsavedEventDraft,
isLogEventDraftEmpty, isLogEventDraftEmpty,
localDateString,
normalizeLogEvent, normalizeLogEvent,
type LogEventPayload type LogEventPayload
} from './logEntryPayload.js' } from './logEntryPayload.js'
@@ -13,6 +14,14 @@ const emptyDraft = (): LogEventPayload =>
const filledDraft = (): LogEventPayload => const filledDraft = (): LogEventPayload =>
normalizeLogEvent({ time: '12:34', remarks: 'Wind dreht' }) normalizeLogEvent({ time: '12:34', remarks: 'Wind dreht' })
describe('localDateString', () => {
it('uses local calendar date, not UTC', () => {
const date = new Date(2026, 5, 4, 1, 30, 0)
expect(localDateString(date)).toBe('2026-06-04')
expect(date.toISOString().substring(0, 10)).toBe('2026-06-03')
})
})
describe('logEntryPayload event drafts', () => { describe('logEntryPayload event drafts', () => {
it('treats time-only draft as empty', () => { it('treats time-only draft as empty', () => {
expect(isLogEventDraftEmpty(emptyDraft())).toBe(true) expect(isLogEventDraftEmpty(emptyDraft())).toBe(true)
+14 -4
View File
@@ -22,6 +22,15 @@ export interface LogEventPayload {
gpsLat: string gpsLat: string
gpsLng: string gpsLng: string
remarks: string remarks: string
creatorId?: string
}
/** Calendar date YYYY-MM-DD in local timezone (matches logbook entry `date` field). */
export function localDateString(date: Date = new Date()): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
} }
/** Local time as HH:MM (24-hour). */ /** Local time as HH:MM (24-hour). */
@@ -77,7 +86,7 @@ export function joinTimeHHMM(hours: string, minutes: string): string {
const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [ const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [
'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState', 'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState',
'visibility', 'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance', 'visibility', 'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
'gpsLat', 'gpsLng', 'remarks' 'gpsLat', 'gpsLng', 'remarks', 'creatorId'
] ]
/** Normalize partial/legacy events so all fields are strings (safe for form + save). */ /** Normalize partial/legacy events so all fields are strings (safe for form + save). */
@@ -101,10 +110,11 @@ export function normalizeLogEvent(event: Partial<LogEventPayload> | Record<strin
distance: '', distance: '',
gpsLat: '', gpsLat: '',
gpsLng: '', gpsLng: '',
remarks: '' remarks: '',
creatorId: e.creatorId ? String(e.creatorId).trim() : undefined
} }
for (const key of LOG_EVENT_FIELDS) { for (const key of LOG_EVENT_FIELDS) {
if (key === 'time' || key === 'mgk' || key === 'rwk' || key === 'windDirection') continue if (key === 'time' || key === 'mgk' || key === 'rwk' || key === 'windDirection' || key === 'creatorId') continue
normalized[key] = String(e[key] ?? '').trim() normalized[key] = String(e[key] ?? '').trim()
} }
return normalized return normalized
@@ -114,7 +124,7 @@ export function logEventsEqual(a: LogEventPayload, b: LogEventPayload): boolean
return LOG_EVENT_FIELDS.every((key) => a[key] === b[key]) return LOG_EVENT_FIELDS.every((key) => a[key] === b[key])
} }
const LOG_EVENT_CONTENT_FIELDS = LOG_EVENT_FIELDS.filter((key) => key !== 'time') const LOG_EVENT_CONTENT_FIELDS = LOG_EVENT_FIELDS.filter((key) => key !== 'time' && key !== 'creatorId')
/** Draft with only a time (or empty fields) — not an unsaved log entry change. */ /** Draft with only a time (or empty fields) — not an unsaved log entry change. */
export function isLogEventDraftEmpty(event: LogEventPayload): boolean { export function isLogEventDraftEmpty(event: LogEventPayload): boolean {
+1 -4
View File
@@ -56,10 +56,7 @@ export function emptyTankLevels(morning = 0): TankLevels {
return { morning, refilled: 0, evening: 0, consumption: 0 } return { morning, refilled: 0, evening: 0, consumption: 0 }
} }
export function formatTankLiters(liters: number): string { export { formatTankLiters } from './numberFormat.js'
if (!Number.isFinite(liters) || liters <= 0) return '0'
return Number.isInteger(liters) ? String(liters) : liters.toFixed(1)
}
export function getClosingGreywaterLevel(greywater?: { level?: number } | null): number { export function getClosingGreywaterLevel(greywater?: { level?: number } | null): number {
return Number(greywater?.level) || 0 return Number(greywater?.level) || 0
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import {
formatAppCoordinate,
formatAppDecimal,
formatGpsAccuracyMeters,
formatTankLiters,
getNumberFormatSymbols,
parseAppDecimal,
resolveDeviceLocale
} from './numberFormat.js'
describe('numberFormat (device locale)', () => {
it('resolveDeviceLocale returns a non-empty BCP 47 tag', () => {
expect(resolveDeviceLocale().length).toBeGreaterThan(0)
})
it('reads decimal separator from Intl for de-DE and en-US', () => {
expect(getNumberFormatSymbols('de-DE').decimal).toBe(',')
expect(getNumberFormatSymbols('en-US').decimal).toBe('.')
})
it('formats decimals per locale without grouping', () => {
expect(formatAppDecimal(12.5, { maximumFractionDigits: 1, locale: 'de-DE' })).toBe('12,5')
expect(formatAppDecimal(12.5, { maximumFractionDigits: 1, locale: 'en-US' })).toBe('12.5')
expect(formatAppDecimal(1234.5, { maximumFractionDigits: 1, locale: 'de-DE' })).toBe('1234,5')
})
it('parses device-locale decimals and tolerates the other separator', () => {
expect(parseAppDecimal('12,5', 'de-DE')).toBe(12.5)
expect(parseAppDecimal('12.5', 'en-US')).toBe(12.5)
expect(parseAppDecimal('12,5', 'en-US')).toBe(12.5)
expect(parseAppDecimal('1.234,5', 'de-DE')).toBe(1234.5)
expect(parseAppDecimal('', 'de-DE')).toBeNull()
})
it('formats coordinates for form display', () => {
expect(formatAppCoordinate(59.912345, 'de-DE')).toBe('59,912345')
expect(formatTankLiters(12.5)).toBe(formatAppDecimal(12.5, { minimumFractionDigits: 1, maximumFractionDigits: 1 }))
})
it('formats GPS accuracy with coarse step from 100 m', () => {
expect(formatGpsAccuracyMeters(12.4)).toBe(formatAppDecimal(12, { maximumFractionDigits: 0 }))
expect(formatGpsAccuracyMeters(105)).toBe(formatAppDecimal(110, { maximumFractionDigits: 0 }))
})
})
+139
View File
@@ -0,0 +1,139 @@
/**
* Number formatting and parsing follow the device (browser) locale from Intl,
* not the app UI language e.g. de-DE phone with English UI still uses comma decimals.
*/
export function resolveDeviceLocale(): string {
try {
const locale = new Intl.NumberFormat().resolvedOptions().locale
if (locale) return locale
} catch {
// ignore
}
if (typeof navigator !== 'undefined' && navigator.language) {
return navigator.language
}
return 'en-GB'
}
interface NumberSymbols {
decimal: string
group: string
}
const symbolCache = new Map<string, NumberSymbols>()
export function getNumberFormatSymbols(locale = resolveDeviceLocale()): NumberSymbols {
const cached = symbolCache.get(locale)
if (cached) return cached
const parts = new Intl.NumberFormat(locale).formatToParts(1234567.89)
const symbols: NumberSymbols = {
decimal: parts.find((p) => p.type === 'decimal')?.value ?? '.',
group: parts.find((p) => p.type === 'group')?.value ?? ''
}
symbolCache.set(locale, symbols)
return symbols
}
export interface FormatAppDecimalOptions {
minimumFractionDigits?: number
maximumFractionDigits?: number
locale?: string
}
/** User-visible decimal without thousands grouping. */
export function formatAppDecimal(value: number, options: FormatAppDecimalOptions = {}): string {
if (!Number.isFinite(value)) return ''
const locale = options.locale ?? resolveDeviceLocale()
const min = options.minimumFractionDigits ?? 0
const max = options.maximumFractionDigits ?? min
return new Intl.NumberFormat(locale, {
minimumFractionDigits: min,
maximumFractionDigits: max,
useGrouping: false
}).format(value)
}
/**
* Parses a decimal typed by the user for the device locale.
* Also accepts the other common separator for simple values (e.g. 12,5 on en-US).
*/
export function parseAppDecimal(input: string, locale = resolveDeviceLocale()): number | null {
const trimmed = input.trim()
if (!trimmed) return null
const { decimal, group } = getNumberFormatSymbols(locale)
const simpleComma = /^-?\d+,\d+$/.test(trimmed)
const simpleDot = /^-?\d+\.\d+$/.test(trimmed)
// Values without grouping: accept locale decimal and the other common separator.
if (simpleComma && decimal === ',') {
return Number(trimmed.replace(',', '.'))
}
if (simpleDot && decimal === '.') {
return Number(trimmed)
}
if (simpleComma && decimal === '.') {
return Number(trimmed.replace(',', '.'))
}
if (simpleDot && decimal === ',') {
return Number(trimmed)
}
let normalized = trimmed
if (group) {
normalized = normalized.split(group).join('')
}
if (decimal !== '.') {
normalized = normalized.replace(decimal, '.')
}
const n = Number(normalized)
return Number.isFinite(n) ? n : null
}
export function parseAppDecimalOrZero(input: string, locale?: string): number {
return parseAppDecimal(input, locale) ?? 0
}
/** Canonical storage/API coordinate string (always dot, 6 decimals). */
export function formatCanonicalCoordinate(value: number): string {
return value.toFixed(6)
}
/** Coordinate string for form fields (device decimal separator). */
export function formatAppCoordinate(value: number, locale?: string): string {
return formatAppDecimal(value, { minimumFractionDigits: 6, maximumFractionDigits: 6, locale })
}
export function formatNm(value: number): string {
return formatAppDecimal(value, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export function formatLiters(value: number): string {
return Number.isInteger(value)
? formatAppDecimal(value, { maximumFractionDigits: 0 })
: formatAppDecimal(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
}
export function formatHours(value: number): string {
return formatLiters(value)
}
export function formatTankLiters(liters: number): string {
if (!Number.isFinite(liters) || liters <= 0) return formatAppDecimal(0, { maximumFractionDigits: 0 })
return formatLiters(liters)
}
export function formatFuelPerMotorHour(value: number | null | undefined): string {
if (value == null) return '—'
return Number.isInteger(value)
? formatAppDecimal(value, { maximumFractionDigits: 0 })
: formatAppDecimal(value, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
/** GPS accuracy for i18n (±{{accuracy}} m): 1 m below 100 m, 10 m from 100 m upward. */
export function formatGpsAccuracyMeters(accuracyM: number): string {
const rounded = accuracyM < 100 ? Math.round(accuracyM) : Math.round(accuracyM / 10) * 10
return formatAppDecimal(rounded, { maximumFractionDigits: 0 })
}
+3 -2
View File
@@ -1,4 +1,5 @@
import { degreesToCardinal } from './courseAngle.js' import { degreesToCardinal } from './courseAngle.js'
import { formatAppDecimal } from './numberFormat.js'
import { formatVisibilityMeters } from './weatherMetrics.js' import { formatVisibilityMeters } from './weatherMetrics.js'
/** @deprecated Use formatVisibilityMeters */ /** @deprecated Use formatVisibilityMeters */
@@ -33,7 +34,7 @@ export function mpsToBeaufort(mps: number): number {
export function formatWindStrengthBeaufort(mps: number): string { export function formatWindStrengthBeaufort(mps: number): string {
const bft = mpsToBeaufort(mps) const bft = mpsToBeaufort(mps)
return `${bft} Bft (${mps.toFixed(1)} m/s)` return `${bft} Bft (${formatAppDecimal(mps, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} m/s)`
} }
export function parseOwmCurrentWeather(data: Record<string, unknown>): ParsedOwmCurrent { export function parseOwmCurrentWeather(data: Record<string, unknown>): ParsedOwmCurrent {
@@ -49,7 +50,7 @@ export function parseOwmCurrentWeather(data: Record<string, unknown>): ParsedOwm
let tempC: string | null = null let tempC: string | null = null
if (main?.temp != null && Number.isFinite(main.temp)) { if (main?.temp != null && Number.isFinite(main.temp)) {
tempC = Number(main.temp).toFixed(1) tempC = formatAppDecimal(main.temp, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
} }
let precipText: string | null = null let precipText: string | null = null
+7 -7
View File
@@ -1,4 +1,4 @@
import { formatTankLiters } from './logEntryTankLevels.js' import { formatTankLiters, parseAppDecimal } from './numberFormat.js'
export interface VesselTankCapacities { export interface VesselTankCapacities {
freshwaterCapacityL?: number freshwaterCapacityL?: number
@@ -7,10 +7,10 @@ export interface VesselTankCapacities {
} }
export function parseOptionalTankLiters(input: string): number | undefined { export function parseOptionalTankLiters(input: string): number | undefined {
const trimmed = input.trim().replace(',', '.') const trimmed = input.trim()
if (!trimmed) return undefined if (!trimmed) return undefined
const parsed = Number(trimmed) const parsed = parseAppDecimal(trimmed)
if (!Number.isFinite(parsed) || parsed < 0) { if (parsed == null || parsed < 0) {
throw new Error('invalid_tank_liters') throw new Error('invalid_tank_liters')
} }
return parsed return parsed
@@ -24,10 +24,10 @@ function capacityFromStored(value: unknown): number | undefined {
if (value == null || value === '') return undefined if (value == null || value === '') return undefined
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value
if (typeof value === 'string') { if (typeof value === 'string') {
const trimmed = value.trim().replace(',', '.') const trimmed = value.trim()
if (!trimmed) return undefined if (!trimmed) return undefined
const parsed = Number(trimmed) const parsed = parseAppDecimal(trimmed)
if (Number.isFinite(parsed) && parsed >= 0) return parsed if (parsed != null && parsed >= 0) return parsed
} }
return undefined return undefined
} }
+10 -3
View File
@@ -1,4 +1,5 @@
import type { TrackWaypoint } from '../services/trackUpload.js' import type { TrackWaypoint } from '../services/trackUpload.js'
import { formatAppDecimal } from './numberFormat.js'
const NM_IN_METERS = 1852 const NM_IN_METERS = 1852
const MAX_PLAUSIBLE_KNOTS = 50 const MAX_PLAUSIBLE_KNOTS = 50
@@ -100,8 +101,14 @@ export function formatTrackStats(stats: TrackStats): {
speedAvgKn: string speedAvgKn: string
} { } {
return { return {
distanceNm: stats.distanceNm.toFixed(2), distanceNm: formatAppDecimal(stats.distanceNm, { minimumFractionDigits: 2, maximumFractionDigits: 2 }),
speedMaxKn: stats.speedMaxKn > 0 ? stats.speedMaxKn.toFixed(1) : '', speedMaxKn:
speedAvgKn: stats.speedAvgKn > 0 ? stats.speedAvgKn.toFixed(1) : '' stats.speedMaxKn > 0
? formatAppDecimal(stats.speedMaxKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
: '',
speedAvgKn:
stats.speedAvgKn > 0
? formatAppDecimal(stats.speedAvgKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
: ''
} }
} }
+7 -4
View File
@@ -1,18 +1,21 @@
import { parseOptionalTankLiters, tankCapacityInputFromStored } from './tankCapacity.js' import { parseOptionalTankLiters, tankCapacityInputFromStored } from './tankCapacity.js'
import { formatAppDecimal, parseAppDecimal } from './numberFormat.js'
import type { VesselData } from '../types/vessel.js' import type { VesselData } from '../types/vessel.js'
export function metricInputFromStored(value: unknown): string { export function metricInputFromStored(value: unknown): string {
if (value == null || value === '') return '' if (value == null || value === '') return ''
if (typeof value === 'number' && Number.isFinite(value)) return String(value) if (typeof value === 'number' && Number.isFinite(value)) {
return formatAppDecimal(value, { maximumFractionDigits: 6 })
}
if (typeof value === 'string') return value.trim() if (typeof value === 'string') return value.trim()
return '' return ''
} }
export function parseOptionalMetricMeters(input: string): number | undefined { export function parseOptionalMetricMeters(input: string): number | undefined {
const trimmed = input.trim().replace(',', '.') const trimmed = input.trim()
if (!trimmed) return undefined if (!trimmed) return undefined
const parsed = Number(trimmed) const parsed = parseAppDecimal(trimmed)
if (!Number.isFinite(parsed) || parsed < 0) { if (parsed == null || parsed < 0) {
throw new Error('invalid_metric') throw new Error('invalid_metric')
} }
return parsed return parsed
+5 -1
View File
@@ -1,3 +1,5 @@
import { formatAppDecimal } from './numberFormat.js'
/** Barometric pressure (hPa), typical marine range. */ /** Barometric pressure (hPa), typical marine range. */
export const PRESSURE_MIN_HPA = 960 export const PRESSURE_MIN_HPA = 960
export const PRESSURE_MAX_HPA = 1050 export const PRESSURE_MAX_HPA = 1050
@@ -90,7 +92,9 @@ export function formatVisibilityMeters(meters: number): string {
if (meters >= 1000) { if (meters >= 1000) {
const km = meters / 1000 const km = meters / 1000
const rounded = Math.round(km * 10) / 10 const rounded = Math.round(km * 10) / 10
return Number.isInteger(rounded) ? `${rounded} km` : `${rounded.toFixed(1)} km` return Number.isInteger(rounded)
? `${formatAppDecimal(rounded, { maximumFractionDigits: 0 })} km`
: `${formatAppDecimal(rounded, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} km`
} }
return `${Math.round(meters)} m` return `${Math.round(meters)} m`
} }
+29
View File
@@ -33,8 +33,36 @@ function versionJsonPlugin(version: string): Plugin {
} }
} }
function readPlausibleConfig(): { plausibleEnabled: boolean; plausibleHost: string } {
const host = (process.env.PLAUSIBLE_HOST || 'https://plausible.elpatron.me').replace(/\/$/, '')
const flag = (process.env.PLAUSIBLE_ENABLED ?? 'true').trim().toLowerCase()
const plausibleEnabled = !['false', '0', 'no'].includes(flag)
return { plausibleEnabled, plausibleHost: host }
}
function runtimeConfigPlugin(): Plugin {
return {
name: 'runtime-config',
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (req.url !== '/runtime-config.json') return next()
res.setHeader('Content-Type', 'application/json')
res.end(`${JSON.stringify(readPlausibleConfig())}\n`)
})
},
writeBundle(options) {
const outDir = options.dir ?? resolve(__dirname, 'dist')
writeFileSync(
resolve(outDir, 'runtime-config.json'),
`${JSON.stringify(readPlausibleConfig(), null, 2)}\n`
)
}
}
}
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
envDir: resolve(__dirname, '..'),
test: { test: {
environment: 'happy-dom', environment: 'happy-dom',
include: ['src/**/*.test.ts'] include: ['src/**/*.test.ts']
@@ -59,6 +87,7 @@ export default defineConfig({
plugins: [ plugins: [
react(), react(),
versionJsonPlugin(readAppVersion()), versionJsonPlugin(readAppVersion()),
runtimeConfigPlugin(),
VitePWA({ VitePWA({
strategies: 'injectManifest', strategies: 'injectManifest',
srcDir: 'src', srcDir: 'src',
+71
View File
@@ -0,0 +1,71 @@
services:
db:
image: postgres:16-alpine
container_name: daagbox-staging-db
restart: always
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
POSTGRES_DB: ${POSTGRES_DB:-daagbox_staging}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
interval: 5s
timeout: 5s
retries: 5
backend:
build:
context: ./server
dockerfile: Dockerfile
container_name: daagbox-staging-backend
restart: always
environment:
PORT: 5000
DATABASE_URL: "postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-daagbox_staging}?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}
OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-}
OpenRouterAPIKey: ${OpenRouterAPIKey:-}
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
SESSION_SECRET: ${SESSION_SECRET:-}
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
NTFY_TOPIC: ${NTFY_TOPIC:-}
NTFY_TOKEN: ${NTFY_TOKEN:-}
command: sh -c "npx prisma db push && node dist/index.js"
healthcheck:
test: ["CMD", "node", "-e", "const http = require('http'); http.get('http://localhost:5000/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1));"]
interval: 15s
timeout: 5s
start_period: 60s
retries: 5
depends_on:
db:
condition: service_healthy
frontend:
build:
context: .
dockerfile: client/Dockerfile
args:
APP_VERSION: ${APP_VERSION:-0.1.0.0-dev}
container_name: daagbox-staging-frontend
restart: always
environment:
PLAUSIBLE_ENABLED: ${PLAUSIBLE_ENABLED:-false}
PLAUSIBLE_HOST: ${PLAUSIBLE_HOST:-https://plausible.elpatron.me}
ports:
- "80:80"
depends_on:
backend:
condition: service_healthy
volumes:
pgdata:
name: daagbox-staging-pgdata
+10
View File
@@ -35,10 +35,17 @@ services:
OpenRouterAPIKey: ${OpenRouterAPIKey:-} OpenRouterAPIKey: ${OpenRouterAPIKey:-}
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku} OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
SESSION_SECRET: ${SESSION_SECRET:-} SESSION_SECRET: ${SESSION_SECRET:-}
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh} NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
NTFY_TOPIC: ${NTFY_TOPIC:-} NTFY_TOPIC: ${NTFY_TOPIC:-}
NTFY_TOKEN: ${NTFY_TOKEN:-} NTFY_TOKEN: ${NTFY_TOKEN:-}
command: sh -c "npx prisma db push && node dist/index.js" command: sh -c "npx prisma db push && node dist/index.js"
healthcheck:
test: ["CMD", "node", "-e", "const http = require('http'); http.get('http://localhost:5000/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1));"]
interval: 15s
timeout: 5s
start_period: 60s
retries: 5
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -51,6 +58,9 @@ services:
APP_VERSION: ${APP_VERSION:-0.1.0.0-dev} APP_VERSION: ${APP_VERSION:-0.1.0.0-dev}
container_name: daagbox-prod-frontend container_name: daagbox-prod-frontend
restart: always restart: always
environment:
PLAUSIBLE_ENABLED: ${PLAUSIBLE_ENABLED:-true}
PLAUSIBLE_HOST: ${PLAUSIBLE_HOST:-https://plausible.elpatron.me}
ports: ports:
- "80:80" - "80:80"
depends_on: depends_on:
+111
View File
@@ -0,0 +1,111 @@
# Server-Backup (Produktion)
Automatische und manuelle Sicherung von PostgreSQL, `.env`, `docker-compose.yml` und App-Code (Git-Archiv) auf der Prod-VM.
**Staging:** Kein automatisches Backup — Daten sind bewusst wegwerfbar. Deploy via `update-remotes.sh -dest stage` legt kein Backup an. Zum manuellen Testen auf Staging: `-dest stage` (oder Auto-Fallback, wenn nur `daagbox-staging-db` läuft).
## Was wird gesichert?
| Inhalt | Beschreibung |
|--------|--------------|
| `database.sql.gz` | `pg_dump` aus dem laufenden DB-Container |
| `.env` | Server-Secrets (Sessions, DB-Passwort, VAPID, …) |
| `docker-compose.yml` | Aktive Compose-Datei |
| `app.tar.gz` | `git archive HEAD` — Code-Snapshot |
| `manifest.json` | Timestamp, Git-Tag, SHA, Grund (`cron` / `pre-deploy` / `manual`) |
Backups liegen in `/var/backups/kapteins-daagbok/` (mode 700, root-only). Es werden **maximal 5** Archive aufbewahrt.
## Einmalige Einrichtung (Prod-Server)
```bash
ssh root@10.0.0.25
mkdir -p /var/backups/kapteins-daagbok
chmod 700 /var/backups/kapteins-daagbok
cd /opt/kapteins-daagbok
git pull
chmod +x scripts/backup.sh scripts/restore-backup.sh
./scripts/backup.sh --reason manual
```
## Manuell sichern
```bash
cd /opt/kapteins-daagbok
./scripts/backup.sh
./scripts/backup.sh --reason manual --dry-run # Vorschau ohne Schreiben
```
### Staging (manueller Test)
```bash
cd /opt/kapteins-daagbok-staging
./scripts/backup.sh -dest stage --reason manual
# oder: Auto-Fallback, wenn nur daagbox-staging-db läuft
./scripts/backup.sh --reason manual
```
## Crontab (unbeaufsichtigt)
Beispiel: [`scripts/crontab.prod.example`](../../scripts/crontab.prod.example)
```bash
crontab -e
# Zeile einfügen:
0 3 * * * cd /opt/kapteins-daagbok && ./scripts/backup.sh --reason cron >> /var/log/kapteins-backup.log 2>&1
```
## Pre-Deploy-Backup
Bei `./scripts/update-remotes.sh -dest prod` wird **vor** dem Git-Sync auf dem Server automatisch ein Backup mit Tag `v{VERSION}-predeploy` erstellt. Schlägt das Backup fehl, wird das Deploy abgebrochen.
Staging-Deploys (`-dest stage`) erstellen **kein** Backup.
## Wiederherstellen
Verfügbare Backups anzeigen:
```bash
./scripts/restore-backup.sh --list
```
Vollständige Wiederherstellung (DB + `.env`, optional Git-Tag checkout):
```bash
./scripts/restore-backup.sh --restore /var/backups/kapteins-daagbok/kapteins-daagbok_YYYYMMDD-HHMMSS_vX.Y.Z.tar.gz
```
Nur Datenbank:
```bash
./scripts/restore-backup.sh --restore PATH --db-only
```
Nur `.env`:
```bash
./scripts/restore-backup.sh --restore PATH --env-only
```
Ohne Rückfragen (Notfall):
```bash
./scripts/restore-backup.sh --restore PATH --full --yes
```
## Vor Passwort-Rotation
Vor [`rotate-postgres-password.sh`](../../scripts/rotate-postgres-password.sh) ein Backup anlegen — siehe auch [postgres-password.md](postgres-password.md):
```bash
./scripts/backup.sh --reason manual
```
## Umgebungsvariablen
| Variable | Prod (default) | Staging (`-dest stage`) |
|----------|----------------|-------------------------|
| `COMPOSE_FILE` | `docker-compose.yml` | `docker-compose.staging.yml` |
| `DB_CONTAINER` | `daagbox-prod-db` | `daagbox-staging-db` |
| `BACKUP_DIR` | `/var/backups/kapteins-daagbok` | gleich |
| `RETENTION` | `5` | `5` |
+13 -6
View File
@@ -1,14 +1,14 @@
# Deployment: Nginx Proxy Manager & Security (Sprint 1) # 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`). Kapteins Daagbok läuft öffentlich unter **https://kapteins-daagbok.eu/** (Produktion) und **https://staging.kapteins-daagbok.eu/** (Staging) hinter **Nginx Proxy Manager** (NPM, z. B. `172.16.10.10`) mit Upstream auf die App-VMs (`10.0.0.25` Prod, `10.0.0.27` Staging).
## NPM Proxy Host ## NPM Proxy Host
| Einstellung | Wert | | Einstellung | Wert |
|-------------|------| |-------------|------|
| Domain | `kapteins-daagbok.eu` | | Domain | `kapteins-daagbok.eu` / `staging.kapteins-daagbok.eu` |
| Scheme | `https` | | Scheme | `https` |
| Forward Hostname / IP | `172.16.10.110` (oder Container-Port auf dem Host) | | Forward Hostname / IP | `10.0.0.25` (Prod) / `10.0.0.27` (Staging) |
| Forward Port | `80` (Frontend-Nginx) | | Forward Port | `80` (Frontend-Nginx) |
| Websockets | an, falls genutzt | | Websockets | an, falls genutzt |
| Block Common Exploits | an | | Block Common Exploits | an |
@@ -40,13 +40,20 @@ TRUST_PROXY=1
## Security-Header ## Security-Header
- **HSTS, CSP (optional restriktiver):** können in NPM unter „Custom Headers“ oder im Advanced-Block gesetzt werden. - **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). - **Basis-Header** für statische Dateien setzt [`client/nginx.conf.template`](../../client/nginx.conf.template) via Container-Entrypoint (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, CSP optional inkl. Plausible).
### Plausible Analytics ### Plausible Analytics
Script-Host: `https://plausible.elpatron.me` — in CSP als `script-src` und `connect-src` erlaubt. Gemessene Site: `data-domain="kapteins-daagbok.eu"`. Konfiguration über `.env` (Frontend-Container):
Optional später: `analytics.kapteins-daagbok.eu` als Alias auf dieselbe Plausible-Instanz. ```env
PLAUSIBLE_ENABLED=true
PLAUSIBLE_HOST=https://plausible.elpatron.me
```
Staging-Default: `PLAUSIBLE_ENABLED=false` in [`docker-compose.staging.yml`](../../docker-compose.staging.yml).
Script-Host wird in CSP (`script-src`, `connect-src`) nur bei `PLAUSIBLE_ENABLED=true` freigegeben. `data-domain` ist immer der aktuelle Hostname (Prod vs. Staging getrennt, wenn Staging aktiviert wird).
## Nach Deploy prüfen ## Nach Deploy prüfen
+1 -1
View File
@@ -8,7 +8,7 @@
## Empfohlene Schritte ## Empfohlene Schritte
1. **Backup/Snapshot** (hast du laut Vorgabe). 1. **Backup/Snapshot** — auf dem Server: `./scripts/backup.sh --reason manual` (Details: [backup.md](backup.md)).
2. Auf dem Server im Repo: 2. Auf dem Server im Repo:
```bash ```bash
cd /opt/kapteins-daagbok cd /opt/kapteins-daagbok
+3 -3
View File
@@ -31,12 +31,12 @@ cd server && npm test
## Nach erfolgreichem Check ## 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). [`scripts/update-remotes.sh`](../../scripts/update-remotes.sh) führt `predeploy-check.sh` **automatisch** aus (nach Release-Vorbereitung bei Prod, vor dem SSH-Deploy).
```bash ```bash
./scripts/update-prod.sh ./scripts/update-remotes.sh -dest prod
``` ```
Notfall ohne Checks (nur wenn nötig): `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-prod.sh` Notfall ohne Checks (nur wenn nötig): `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-remotes.sh -dest prod`
Manuell auf dem Server: `git pull`, `docker compose build`, `docker compose up -d` (siehe [npm-security.md](npm-security.md)). Manuell auf dem Server: `git pull`, `docker compose build`, `docker compose up -d` (siehe [npm-security.md](npm-security.md)).
+106
View File
@@ -0,0 +1,106 @@
# Staging-Umgebung
Staging läuft auf **VM3** (`10.0.0.27`) unter **https://staging.kapteins-daagbok.eu/** — hinter Nginx Proxy Manager wie Produktion.
## Unterschiede zu Produktion
| | Staging | Produktion |
|---|---------|------------|
| Host | `10.0.0.27` | `10.0.0.25` |
| Verzeichnis | `/opt/kapteins-daagbok-staging` | `/opt/kapteins-daagbok` |
| Compose | `docker-compose.staging.yml` | `docker-compose.yml` |
| Deploy-Skript | `./scripts/update-remotes.sh -dest stage` | `./scripts/update-remotes.sh -dest prod` |
| Release-Tag | nein | ja (`v*`) |
| Datenbank-Volume | `daagbox-staging-pgdata` | `daagbox-prod-pgdata` |
Staging ist **vollständig isoliert**: eigene DB, Session-Secrets, Passkeys (`RP_ID=staging.kapteins-daagbok.eu`) und optional eigene VAPID-/Ntfy-Konfiguration.
## Erstinstallation (VM3)
```bash
ssh root@10.0.0.27
git clone https://gitea.elpatron.me/elpatron/kapteins-daagbok.git /opt/kapteins-daagbok-staging
cd /opt/kapteins-daagbok-staging
git checkout master
# .env anlegen — Secrets neu generieren, nicht von Prod kopieren
openssl rand -hex 24 # POSTGRES_PASSWORD
openssl rand -base64 48 # SESSION_SECRET
nano .env
docker compose -f docker-compose.staging.yml up -d --build
```
### `.env` (Staging)
```env
ORIGIN=https://staging.kapteins-daagbok.eu
RP_ID=staging.kapteins-daagbok.eu
TRUST_PROXY=1
POSTGRES_USER=postgres
POSTGRES_PASSWORD=<generiert>
POSTGRES_DB=daagbox_staging
SESSION_SECRET=<generiert>
NTFY_SERVER=https://ntfy.sh
NTFY_TOPIC=kapteins-daagbok-staging-feedback
# Analytics aus (Staging soll Prod-Statistik nicht verfälschen)
PLAUSIBLE_ENABLED=false
PLAUSIBLE_HOST=https://plausible.elpatron.me
```
Optional: `VAPID_*`, `OpenWeatherMapAPIKey`, `OpenRouterAPIKey`, `ADMIN_USER_IDS`, `NTFY_TOKEN`.
## Deploy vom Entwicklungsrechner
Führt `npm run check` aus, dann SSH-Deploy ohne Release-Tag:
```bash
./scripts/update-remotes.sh -dest stage
```
Konfiguration via Umgebungsvariablen:
```bash
REMOTE_HOST=10.0.0.27 \
REMOTE_DIR=/opt/kapteins-daagbok-staging \
DEPLOY_BRANCH=master \
./scripts/update-remotes.sh -dest stage
```
Notfall ohne Checks: `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-remotes.sh -dest stage`
## NPM (VM1)
| Einstellung | Wert |
|-------------|------|
| Domain | `staging.kapteins-daagbok.eu` |
| Forward Hostname / IP | `10.0.0.27` |
| Forward Port | `80` |
| SSL | Let's Encrypt |
Empfohlen: Custom Header `X-Robots-Tag: noindex, nofollow` (Staging nicht indexieren).
Details zu Proxy-Headern und Security: [npm-security.md](npm-security.md).
## Nach Deploy prüfen
1. https://staging.kapteins-daagbok.eu/api/health — `status: ok`
2. Neuen Test-Account registrieren (Prod-Passkeys funktionieren nicht auf Staging)
3. Passkey Login
4. Cookie `daagbok_session`: `Secure`, `HttpOnly`, `SameSite=Lax`
## Daten zurücksetzen
Staging-Daten sind wegwerfbar:
```bash
cd /opt/kapteins-daagbok-staging
docker compose -f docker-compose.staging.yml down
docker volume rm daagbox-staging-pgdata
docker compose -f docker-compose.staging.yml up -d
```
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

+318
View File
@@ -0,0 +1,318 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Kapteins Daagbok — Sharepic (Portrait)</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
width: 1080px;
height: 1920px;
font-family: 'Plus Jakarta Sans', sans-serif;
color: #e2e8f0;
background: #0f172a;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
padding: 100px 80px;
background:
radial-gradient(circle at 50% 10%, rgba(56, 189, 248, 0.18) 0%, transparent 45%),
radial-gradient(circle at 50% 90%, rgba(134, 59, 255, 0.22) 0%, transparent 45%),
linear-gradient(180deg, #090d16 0%, #111827 50%, #090d16 100%);
position: relative;
}
/* Subtle background grid pattern */
body::after {
content: "";
position: absolute;
inset: 0;
background-image: linear-gradient(rgba(148, 163, 184, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 184, 0.03) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
/* Outer border */
.outer-border {
position: absolute;
inset: 40px;
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 30px;
pointer-events: none;
z-index: 1;
}
.brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
z-index: 2;
text-align: center;
margin-top: 40px;
}
.logo {
width: 140px;
height: 140px;
object-fit: contain;
filter: drop-shadow(0 8px 24px rgba(56, 189, 248, 0.3));
}
.title-group h1 {
font-size: 64px;
font-weight: 800;
letter-spacing: -0.03em;
color: #ffffff;
line-height: 1.1;
background: linear-gradient(135deg, #ffffff 60%, #94a3b8 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.title-group p {
font-size: 26px;
color: #38bdf8;
font-weight: 600;
margin-top: 8px;
letter-spacing: -0.01em;
}
.main-content {
width: 100%;
display: flex;
flex-direction: column;
gap: 50px;
z-index: 2;
}
.intro-text {
font-size: 26px;
line-height: 1.6;
color: #cbd5e1;
font-weight: 400;
text-align: center;
padding: 0 20px;
}
.intro-text strong {
color: #ffffff;
font-weight: 600;
}
.features-card {
background: rgba(30, 41, 59, 0.45);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 24px;
padding: 50px 60px;
box-shadow: 0 30px 60px rgba(0, 0, 0, 0.4);
position: relative;
}
.features-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: 24px;
padding: 1px;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.02));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.card-title {
font-size: 20px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #94a3b8;
margin-bottom: 35px;
display: flex;
align-items: center;
gap: 12px;
}
.card-title::after {
content: "";
flex: 1;
height: 1px;
background: rgba(148, 163, 184, 0.15);
}
.badge-premium {
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
color: #1e293b;
font-size: 16px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 6px 14px;
border-radius: 8px;
}
.features-list {
display: flex;
flex-direction: column;
gap: 30px;
list-style: none;
}
.feature-item {
display: flex;
align-items: flex-start;
gap: 20px;
font-size: 24px;
line-height: 1.4;
color: #cbd5e1;
font-weight: 500;
}
.feature-icon {
color: #38bdf8;
font-size: 26px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 3px;
text-shadow: 0 0 10px rgba(56, 189, 248, 0.6);
}
.bottom-section {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 50px;
z-index: 2;
margin-bottom: 40px;
}
.cta-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
}
.cta-badge {
background: linear-gradient(135deg, #38bdf8 0%, #0284c7 100%);
color: #0f172a;
font-size: 32px;
font-weight: 800;
padding: 20px 45px;
border-radius: 16px;
letter-spacing: -0.02em;
box-shadow: 0 10px 30px rgba(56, 189, 248, 0.25);
}
.qr-code {
width: 160px;
height: 160px;
background: #ffffff;
padding: 10px;
border-radius: 16px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
}
.qr-code img {
width: 100%;
height: 100%;
object-fit: contain;
}
footer {
font-size: 18px;
color: #64748b;
text-align: center;
}
footer strong {
color: #94a3b8;
font-weight: 600;
}
</style>
</head>
<body>
<div class="outer-border"></div>
<div class="brand">
<img class="logo" src="../../client/public/logo.png" alt="Kapteins Daagbok" />
<div class="title-group">
<h1>Kapteins Daagbok</h1>
<p>Digitales Yacht-Logbuch</p>
</div>
</div>
<div class="main-content">
<p class="intro-text">
Führe dein Bordlogbuch modern & digital: Reisetage, GPS-Tracks, Crew- und Schiffsdaten —
<strong>Ende-zu-Ende-verschlüsselt</strong>, als App installierbar und <strong>auch offline</strong> auf See nutzbar.
</p>
<div class="features-card">
<div class="card-title">Top Features <span class="badge-premium">Kostenlos & Werbefrei</span></div>
<ul class="features-list">
<li class="feature-item">
<span class="feature-icon"></span>
<span>Nautisches Logbuch-Format & Streckenstatistik</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Offline-first PWA — läuft auf allen Smartphones & Tablets</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Ende-zu-Ende Verschlüsselung (Zero-Knowledge)</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Einfache passwortlose Passkey-Anmeldung</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>GPS-Track-Upload & automatische NMEA-Erfassung</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Crew-Einladung zur gemeinsamen Logbuch-Arbeit</span>
</li>
</ul>
</div>
</div>
<div class="bottom-section">
<div class="cta-container">
<div class="cta-badge">
kapteins-daagbok.eu
</div>
<div class="qr-code">
<img src="assets/qr-kapteins-daagbok.eu.png" alt="QR Code" />
</div>
</div>
<footer>
<strong>Kapteins Daagbok</strong> ist ein werbefreies, privates Hobbyprojekt.
</footer>
</div>
</body>
</html>
+320
View File
@@ -0,0 +1,320 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Kapteins Daagbok — Sharepic</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
width: 1200px;
height: 630px;
font-family: 'Plus Jakarta Sans', sans-serif;
color: #e2e8f0;
background: #0f172a;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 60px 80px;
background:
radial-gradient(circle at 90% 10%, rgba(56, 189, 248, 0.15) 0%, transparent 45%),
radial-gradient(circle at 10% 90%, rgba(134, 59, 255, 0.18) 0%, transparent 45%),
linear-gradient(165deg, #090d16 0%, #111827 50%, #090d16 100%);
position: relative;
}
/* Subtle background grid pattern */
body::after {
content: "";
position: absolute;
inset: 0;
background-image: linear-gradient(rgba(148, 163, 184, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 184, 0.03) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
/* Outer border */
.outer-border {
position: absolute;
inset: 30px;
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 20px;
pointer-events: none;
z-index: 1;
}
.content-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
height: 100%;
width: 100%;
z-index: 2;
position: relative;
gap: 50px;
}
.left-col {
flex: 1.1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 30px;
}
.brand {
display: flex;
align-items: center;
gap: 20px;
}
.logo {
width: 80px;
height: 80px;
object-fit: contain;
filter: drop-shadow(0 4px 12px rgba(56, 189, 248, 0.3));
}
.title-group h1 {
font-size: 44px;
font-weight: 800;
letter-spacing: -0.03em;
color: #ffffff;
line-height: 1.1;
background: linear-gradient(135deg, #ffffff 60%, #94a3b8 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.title-group p {
font-size: 18px;
color: #38bdf8;
font-weight: 600;
margin-top: 4px;
letter-spacing: -0.01em;
}
.intro-text {
font-size: 20px;
line-height: 1.6;
color: #cbd5e1;
font-weight: 400;
}
.intro-text strong {
color: #ffffff;
font-weight: 600;
}
.cta-container {
display: flex;
align-items: center;
gap: 20px;
}
.cta-badge {
background: linear-gradient(135deg, #38bdf8 0%, #0284c7 100%);
color: #0f172a;
font-size: 22px;
font-weight: 800;
padding: 14px 28px;
border-radius: 12px;
letter-spacing: -0.02em;
box-shadow: 0 4px 20px rgba(56, 189, 248, 0.25);
}
.qr-code {
width: 60px;
height: 60px;
background: #ffffff;
padding: 4px;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
display: flex;
align-items: center;
justify-content: center;
}
.qr-code img {
width: 100%;
height: 100%;
object-fit: contain;
}
.right-col {
flex: 0.9;
display: flex;
flex-direction: column;
justify-content: center;
}
.features-card {
background: rgba(30, 41, 59, 0.45);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
padding: 35px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
position: relative;
}
.features-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: 20px;
padding: 1px;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.02));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.features-list {
display: flex;
flex-direction: column;
gap: 18px;
list-style: none;
}
.feature-item {
display: flex;
align-items: flex-start;
gap: 14px;
font-size: 16px;
line-height: 1.4;
color: #cbd5e1;
font-weight: 500;
}
.feature-icon {
color: #38bdf8;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 2px;
text-shadow: 0 0 8px rgba(56, 189, 248, 0.6);
}
.badge-premium {
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
color: #1e293b;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 4px 10px;
border-radius: 6px;
margin-left: auto;
}
.card-title {
font-size: 14px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #94a3b8;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 8px;
}
.card-title::after {
content: "";
flex: 1;
height: 1px;
background: rgba(148, 163, 184, 0.15);
}
footer {
position: absolute;
bottom: 45px;
left: 80px;
font-size: 12px;
color: #64748b;
z-index: 2;
}
footer strong {
color: #94a3b8;
font-weight: 600;
}
</style>
</head>
<body>
<div class="outer-border"></div>
<div class="content-wrapper">
<div class="left-col">
<div class="brand">
<img class="logo" src="../../client/public/logo.png" alt="Kapteins Daagbok" />
<div class="title-group">
<h1>Kapteins Daagbok</h1>
<p>Digitales Yacht-Logbuch</p>
</div>
</div>
<p class="intro-text">
Führe dein Bordlogbuch modern & digital: Reisetage, GPS-Tracks, Crew- und Schiffsdaten —
<strong>Ende-zu-Ende-verschlüsselt</strong>, als App installierbar und <strong>auch offline</strong> auf See nutzbar.
</p>
<div class="cta-container">
<div class="cta-badge">
kapteins-daagbok.eu
</div>
<div class="qr-code">
<img src="assets/qr-kapteins-daagbok.eu.png" alt="QR Code" />
</div>
</div>
</div>
<div class="right-col">
<div class="features-card">
<div class="card-title">Top Features <span class="badge-premium">Kostenlos & Werbefrei</span></div>
<ul class="features-list">
<li class="feature-item">
<span class="feature-icon"></span>
<span>Nautisches Logbuch-Format & Streckenstatistik</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Offline-first PWA — läuft auf allen Smartphones & Tablets</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Ende-zu-Ende Verschlüsselung (Zero-Knowledge)</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Einfache passwortlose Passkey-Anmeldung</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>GPS-Track-Upload & automatische NMEA-Erfassung</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Crew-Einladung zur gemeinsamen Logbuch-Arbeit</span>
</li>
</ul>
</div>
</div>
</div>
<footer>
<strong>Kapteins Daagbok</strong> ist ein werbefreies, privates Hobbyprojekt.
</footer>
</body>
</html>
+18 -7
View File
@@ -1,12 +1,21 @@
# Plausible Custom Events # Plausible Custom Events
Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Script `script.tagged-events.js` auf der Domain `kapteins-daagbok.eu`. Custom Events werden über `window.plausible()` ausgelöst (siehe `client/src/services/analytics.ts`). Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Script `script.tagged-events.js`. Custom Events werden über `window.plausible()` ausgelöst (siehe `client/src/services/analytics.ts`).
**Konfiguration** (`.env`, Frontend-Container / Vite-Dev):
```env
PLAUSIBLE_ENABLED=true # Staging: false
PLAUSIBLE_HOST=https://plausible.elpatron.me
```
Das Script wird über `plausible-bootstrap.js` geladen; `data-domain` ist der aktuelle Hostname. CSP in Nginx enthält `PLAUSIBLE_HOST` nur wenn aktiviert.
**Datenschutz:** Es werden keine personenbezogenen Daten in Event-Properties übermittelt (keine Nutzernamen, Hafennamen, Koordinaten o.ä.). **Datenschutz:** Es werden keine personenbezogenen Daten in Event-Properties übermittelt (keine Nutzernamen, Hafennamen, Koordinaten o.ä.).
## Setup ## Setup
1. Script in `client/index.html` (bereits eingebunden) 1. `PLAUSIBLE_*` in `.env` setzen (Prod: enabled, Staging: disabled empfohlen)
2. Nach Deploy: Goals im Plausible-Dashboard anlegen — **Namen müssen exakt mit der Event-Spalte „Event name“ übereinstimmen** (Title Case, Leerzeichen) 2. Nach Deploy: Goals im Plausible-Dashboard anlegen — **Namen müssen exakt mit der Event-Spalte „Event name“ übereinstimmen** (Title Case, Leerzeichen)
## Event-Übersicht ## Event-Übersicht
@@ -37,9 +46,8 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — | | CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — |
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — | | CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
| Photo Uploaded | Foto hochgeladen (`photoAttachments.ts`, `PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `live_log` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` | | Photo Uploaded | Foto hochgeladen (`photoAttachments.ts`, `PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `live_log` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
| Live Log Photo Uploaded | Foto im Live-Journal per Kamera gespeichert (`photoAttachments.ts`, `analyticsContext`: `live_log`) | — |
| Voice Memo Uploaded | Sprachnotiz gespeichert (`voiceAttachments.ts`) | `context`: `logbook` \| `live_log` | | Voice Memo Uploaded | Sprachnotiz gespeichert (`voiceAttachments.ts`) | `context`: `logbook` \| `live_log` |
| Live Log Voice Uploaded | Sprachnotiz im Live-Journal gespeichert (`voiceAttachments.ts`, `analyticsContext`: `live_log`) | | | Voice Memo Transcribed | Sprachmemo transkribiert (`LiveLogView.tsx`, `EventRemarksCell.tsx`) | `status`: `success` \| `failed`, `mode`: `auto` (beim Speichern) \| `manual` (nachträglich) |
| OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) | | OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) |
| AI Summary Generated | Erfolgreiche KI-Zusammenfassung eines Reisetags (`aiSummary.ts`) | — | | AI Summary Generated | Erfolgreiche KI-Zusammenfassung eines Reisetags (`aiSummary.ts`) | — |
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes` (Anzahlen/Größe, keine Inhalte) | | Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes` (Anzahlen/Größe, keine Inhalte) |
@@ -47,6 +55,7 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — | | Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
| Push Disabled | Crew-Änderungs-Push deaktiviert (`PushNotificationSettings.tsx`) | — | | Push Disabled | Crew-Änderungs-Push deaktiviert (`PushNotificationSettings.tsx`) | — |
| Footer Link Clicked | Klick auf Autoren-Link im App-Footer (`AppFooter.tsx`) | — | | Footer Link Clicked | Klick auf Autoren-Link im App-Footer (`AppFooter.tsx`) | — |
| Ko-fi Link Clicked | Klick auf Ko-fi-Unterstützen-Badge im App-Footer (`AppFooter.tsx`) | — |
| Profile Opened | Profilseite geöffnet (`UserProfilePage.tsx`, einmal pro Mount) | — | | Profile Opened | Profilseite geöffnet (`UserProfilePage.tsx`, einmal pro Mount) | — |
| Passkey Added | Passkey erfolgreich registriert (`UserProfilePage.tsx`) | `labeled`: `true` \| `false` (optionaler Name gesetzt) | | Passkey Added | Passkey erfolgreich registriert (`UserProfilePage.tsx`) | `labeled`: `true` \| `false` (optionaler Name gesetzt) |
| Passkey Removed | Passkey entfernt, mindestens ein Key verbleibt (`UserProfilePage.tsx`) | — | | Passkey Removed | Passkey entfernt, mindestens ein Key verbleibt (`UserProfilePage.tsx`) | — |
@@ -85,7 +94,7 @@ Property `action` bei **Live Log Event Logged** — stabile englische Schlüssel
| `temp` | Temperatur | | `temp` | Temperatur |
| `precip` | Niederschlag | | `precip` | Niederschlag |
| `sea_state` | Seegang | | `sea_state` | Seegang |
| `fix` | GPS-Fix (manuell) | | `position` | GPS-Position (manuell) |
| `comment` | Kommentar | | `comment` | Kommentar |
| `voice` | Sprachnotiz (Modal gespeichert) | | `voice` | Sprachnotiz (Modal gespeichert) |
| `undo` | Letztes Ereignis rückgängig | | `undo` | Letztes Ereignis rückgängig |
@@ -138,7 +147,7 @@ Empfohlene Goal-Ketten für Auswertung (nur Business!):
7. **Kontosicherheit:** Profile Opened → Passkey Added / Local PIN Set / Recovery Rotated; Last Passkey Remove Hinted → Account Deleted (selten, aber aussagekräftig) 7. **Kontosicherheit:** Profile Opened → Passkey Added / Local PIN Set / Recovery Rotated; Last Passkey Remove Hinted → Account Deleted (selten, aber aussagekräftig)
8. **Internationalisierung:** Language Changed (Verteilung `to`, Pfade mit Übersetzungs-Feedback) 8. **Internationalisierung:** Language Changed (Verteilung `to`, Pfade mit Übersetzungs-Feedback)
9. **NMEA-Import:** NMEA Uploaded → NMEA Imported (Modus, `events`, optional Track; Upload-Funnel vs. Abbruch) 9. **NMEA-Import:** NMEA Uploaded → NMEA Imported (Modus, `events`, optional Track; Upload-Funnel vs. Abbruch)
10. **Live-Journal:** Live Log Opened → Live Log Event Logged (Verteilung `action`; z. B. `fix`, `course`, `motor_start`) → Live Log Photo Uploaded 10. **Live-Journal:** Live Log Opened → Live Log Event Logged (Verteilung `action`; z. B. `position`, `course`, `motor_start`) → Photo Uploaded / Voice Memo Uploaded (Filter `context`: `live_log`)
11. **OpenWeatherMap:** OWM Weather Fetched (Verteilung `source`; Live-Journal vs. Reisetag-Editor) 11. **OpenWeatherMap:** OWM Weather Fetched (Verteilung `source`; Live-Journal vs. Reisetag-Editor)
12. **PWA-Stabilitaet:** PWA Boot Watchdog Soft → PWA Boot Watchdog Hard → PWA Boot Watchdog Fallback → PWA Boot Watchdog Manual Repair 12. **PWA-Stabilitaet:** PWA Boot Watchdog Soft → PWA Boot Watchdog Hard → PWA Boot Watchdog Fallback → PWA Boot Watchdog Manual Repair
@@ -151,7 +160,9 @@ trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' }) trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from: 'de', to: 'da' }) trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from: 'de', to: 'da' })
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'course' }) trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'course' })
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_PHOTO_UPLOADED) trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'live_log' })
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_UPLOADED, { context: 'live_log' })
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, { status: 'success', mode: 'auto' })
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, { source: 'live_log' }) trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, { source: 'live_log' })
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, { lines: 1200, candidates: 8, duplicate: false, has_position: true }) trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, { lines: 1200, candidates: 8, duplicate: false, has_position: true })
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode: 'both', events: 6, track: true }) trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode: 'both', events: 6, track: true })
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env bash
# Create a local backup of PostgreSQL, .env, docker-compose and app (git archive).
#
# Run on the server in repo root (/opt/kapteins-daagbok on production).
#
# Usage:
# ./scripts/backup.sh
# ./scripts/backup.sh -dest stage # Staging-Container (daagbox-staging-db)
# ./scripts/backup.sh --reason cron
# ./scripts/backup.sh --reason pre-deploy --tag v0.1.1.20
# ./scripts/backup.sh --dry-run
#
# Environment overrides:
# BACKUP_DIR, COMPOSE_FILE, DB_CONTAINER, RETENTION, ENV_FILE
set -euo pipefail
BACKUP_DIR="${BACKUP_DIR:-/var/backups/kapteins-daagbok}"
ENV_FILE="${ENV_FILE:-.env}"
RETENTION="${RETENTION:-5}"
DEST="prod"
REASON="manual"
EXPLICIT_TAG=""
DRY_RUN=0
COMPOSE_FILE=""
DB_CONTAINER=""
apply_dest_config() {
local dest="$1"
local force="${2:-0}"
if [[ "$dest" == "stage" ]]; then
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
COMPOSE_FILE="docker-compose.staging.yml"
fi
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
DB_CONTAINER="daagbox-staging-db"
fi
else
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
COMPOSE_FILE="docker-compose.yml"
fi
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
DB_CONTAINER="daagbox-prod-db"
fi
fi
}
usage() {
sed -n '2,14p' "$0"
echo ""
echo "Options:"
echo " -dest prod|stage Target environment (default: prod)"
echo " --reason cron|pre-deploy|manual Backup trigger (default: manual)"
echo " --tag TAG Git tag label (e.g. v0.1.1.20 for pre-deploy)"
echo " --dry-run Show actions without writing backup"
echo " -h, --help Show this help"
}
while [ $# -gt 0 ]; do
case "$1" in
-dest)
DEST="${2:?-dest requires an argument}"
shift 2
;;
-dest=*)
DEST="${1#*=}"
shift
;;
--reason)
REASON="${2:?--reason requires an argument}"
shift 2
;;
--tag)
EXPLICIT_TAG="${2:?--tag requires an argument}"
shift 2
;;
--dry-run)
DRY_RUN=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
esac
done
case "$DEST" in
prod|stage) ;;
*)
echo "Error: invalid -dest '$DEST' (use prod or stage)" >&2
exit 1
;;
esac
apply_dest_config "$DEST"
case "$REASON" in
cron|pre-deploy|manual) ;;
*)
echo "Error: invalid --reason '$REASON' (use cron, pre-deploy, or manual)" >&2
exit 1
;;
esac
if [ ! -f "$COMPOSE_FILE" ]; then
echo "Error: $COMPOSE_FILE not found" >&2
exit 1
fi
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
GIT_SHA="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
GIT_TAG="$EXPLICIT_TAG"
if [ -z "$GIT_TAG" ]; then
GIT_TAG="$(git describe --tags --exact-match HEAD 2>/dev/null || true)"
fi
if [ -z "$GIT_TAG" ] && [ -f VERSION ]; then
GIT_TAG="v$(tr -d '[:space:]' < VERSION)"
fi
if [ -z "$GIT_TAG" ]; then
GIT_TAG="unknown"
fi
APP_VERSION="$(tr -d '[:space:]' < VERSION 2>/dev/null || echo unknown)"
TAG_SLUG="${GIT_TAG}"
if [ "$REASON" = "pre-deploy" ]; then
TAG_SLUG="${GIT_TAG}-predeploy"
fi
TAG_SLUG="${TAG_SLUG//\//-}"
ARCHIVE_NAME="kapteins-daagbok_${TIMESTAMP}_${TAG_SLUG}.tar.gz"
ARCHIVE_PATH="${BACKUP_DIR}/${ARCHIVE_NAME}"
echo "Backup: reason=$REASON tag=$GIT_TAG sha=${GIT_SHA:0:8}$ARCHIVE_PATH"
if [ "$DRY_RUN" -eq 1 ]; then
echo "[dry-run] Would dump database from $DB_CONTAINER"
echo "[dry-run] Would copy $ENV_FILE and $COMPOSE_FILE"
echo "[dry-run] Would create git archive"
echo "[dry-run] Would write manifest and pack to $ARCHIVE_PATH"
echo "[dry-run] Would apply retention (keep $RETENTION)"
exit 0
fi
if [ ! -f "$ENV_FILE" ]; then
echo "Error: $ENV_FILE not found (run from repo root)" >&2
exit 1
fi
# shellcheck disable=SC1090
set -a
source "$ENV_FILE"
set +a
POSTGRES_USER="${POSTGRES_USER:-postgres}"
POSTGRES_DB="${POSTGRES_DB:-daagbox}"
if [ -z "${POSTGRES_PASSWORD:-}" ]; then
echo "Error: POSTGRES_PASSWORD not set in $ENV_FILE" >&2
exit 1
fi
if ! docker inspect "$DB_CONTAINER" >/dev/null 2>&1; then
if [[ "$DEST" == "prod" ]] && docker inspect daagbox-staging-db >/dev/null 2>&1; then
echo "Note: $DB_CONTAINER not found — falling back to staging (daagbox-staging-db). Use -dest stage explicitly."
apply_dest_config stage 1
DEST="stage"
else
echo "Error: DB container '$DB_CONTAINER' not found" >&2
exit 1
fi
fi
if [ "$(docker inspect -f '{{.State.Running}}' "$DB_CONTAINER")" != "true" ]; then
echo "Error: DB container '$DB_CONTAINER' is not running" >&2
exit 1
fi
mkdir -p "$BACKUP_DIR"
chmod 700 "$BACKUP_DIR"
WORK_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
echo "Dumping PostgreSQL ($POSTGRES_DB)..."
export PGPASSWORD="$POSTGRES_PASSWORD"
if ! docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$DB_CONTAINER" \
pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --no-owner --no-acl \
| gzip > "$WORK_DIR/database.sql.gz"; then
echo "Error: pg_dump failed" >&2
exit 1
fi
unset PGPASSWORD
cp "$ENV_FILE" "$WORK_DIR/.env"
chmod 600 "$WORK_DIR/.env"
cp "$COMPOSE_FILE" "$WORK_DIR/docker-compose.yml"
echo "Creating app snapshot (git archive)..."
if git archive --format=tar HEAD | gzip > "$WORK_DIR/app.tar.gz"; then
:
else
echo "Warning: git archive failed — backup continues without app.tar.gz" >&2
rm -f "$WORK_DIR/app.tar.gz"
fi
python3 - "$WORK_DIR/manifest.json" <<PY
import json
import socket
import sys
from datetime import datetime, timezone
manifest = {
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"local_timestamp": "${TIMESTAMP}",
"destination": "${DEST}",
"reason": "${REASON}",
"git_tag": "${GIT_TAG}",
"git_sha": "${GIT_SHA}",
"app_version": "${APP_VERSION}",
"compose_file": "${COMPOSE_FILE}",
"db_container": "${DB_CONTAINER}",
"postgres_db": "${POSTGRES_DB}",
"hostname": socket.gethostname(),
"archive_name": "${ARCHIVE_NAME}",
}
with open(sys.argv[1], "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2)
f.write("\n")
PY
echo "Packing backup archive..."
tar -czf "$ARCHIVE_PATH" -C "$WORK_DIR" \
manifest.json database.sql.gz .env docker-compose.yml \
$( [ -f "$WORK_DIR/app.tar.gz" ] && echo app.tar.gz )
chmod 600 "$ARCHIVE_PATH"
echo "Applying retention (keep last $RETENTION backups)..."
mapfile -t ALL_BACKUPS < <(ls -1t "$BACKUP_DIR"/kapteins-daagbok_*.tar.gz 2>/dev/null || true)
if [ "${#ALL_BACKUPS[@]}" -gt "$RETENTION" ]; then
for ((i = RETENTION; i < ${#ALL_BACKUPS[@]}; i++)); do
echo "Removing old backup: ${ALL_BACKUPS[$i]}"
rm -f "${ALL_BACKUPS[$i]}"
done
fi
echo "Backup complete: $ARCHIVE_PATH"
echo "$ARCHIVE_PATH"
+11
View File
@@ -0,0 +1,11 @@
# Kapteins Daagbok — Production backup cron (install on 10.0.0.25)
#
# Install:
# crontab -e
# (paste the line below)
#
# Ensure log directory exists:
# touch /var/log/kapteins-backup.log && chmod 600 /var/log/kapteins-backup.log
# Daily backup at 03:00 UTC — keeps last 5 in /var/backups/kapteins-daagbok/
0 3 * * * cd /opt/kapteins-daagbok && ./scripts/backup.sh --reason cron >> /var/log/kapteins-backup.log 2>&1
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env node
/**
* Generates the sharepic PNGs (landscape & portrait) from HTML files
*
* Usage:
* node scripts/generate-sharepic.mjs
*/
import { execSync } from 'node:child_process'
import { dirname, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { createRequire } from 'node:module'
const __dirname = dirname(fileURLToPath(import.meta.url))
const repoRoot = resolve(__dirname, '..')
const clientDir = resolve(repoRoot, 'client')
const marketingDir = resolve(repoRoot, 'docs/marketing')
const require = createRequire(resolve(clientDir, 'package.json'))
function isMissingBrowserError(err) {
const msg = err instanceof Error ? err.message : String(err)
return msg.includes("Executable doesn't exist") || msg.includes('browserType.launch')
}
async function ensurePlaywrightChromium(playwright) {
try {
const browser = await playwright.chromium.launch({ headless: true })
await browser.close()
return
} catch (err) {
if (!isMissingBrowserError(err)) throw err
}
console.log('Playwright Chromium fehlt — installiere Browser (einmalig)…')
execSync('npx playwright install chromium', {
cwd: clientDir,
stdio: 'inherit'
})
}
function loadPlaywright() {
try {
return require('playwright')
} catch {
console.error('Fehlende Abhängigkeit: "npm install -D playwright" in client/ ausführen.')
process.exit(1)
}
}
async function renderSharepic(browser, htmlName, pngName, width, height) {
const htmlPath = resolve(marketingDir, htmlName)
const pngPath = resolve(marketingDir, pngName)
console.log(`Generating sharepic (${width}x${height}) from ${htmlName}...`)
const context = await browser.newContext({
viewport: { width, height },
deviceScaleFactor: 2 // High-DPI for crisp text
})
const page = await context.newPage()
try {
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: 'networkidle' })
await page.screenshot({
path: pngPath,
type: 'png'
})
console.log('Successfully wrote:', pngPath)
} finally {
await page.close()
}
}
async function main() {
const playwright = loadPlaywright()
await ensurePlaywrightChromium(playwright)
const browser = await playwright.chromium.launch({ headless: true })
try {
// Landscape 1200x630
await renderSharepic(browser, 'sharepic.html', 'kapteins-daagbok-sharepic.png', 1200, 630)
// Portrait 1080x1920
await renderSharepic(browser, 'sharepic-portrait.html', 'kapteins-daagbok-sharepic-portrait.png', 1080, 1920)
} finally {
await browser.close()
}
}
main().catch((err) => {
console.error('Error generating sharepics:', err)
process.exit(1)
})
+345
View File
@@ -0,0 +1,345 @@
#!/usr/bin/env bash
# Restore server backup created by scripts/backup.sh
#
# Usage:
# ./scripts/restore-backup.sh --list
# ./scripts/restore-backup.sh -dest stage --restore PATH
# ./scripts/restore-backup.sh --restore /var/backups/kapteins-daagbok/kapteins-daagbok_....tar.gz
#
# Environment overrides:
# BACKUP_DIR, COMPOSE_FILE, DB_CONTAINER, BACKEND_CONTAINER, ENV_FILE
set -euo pipefail
BACKUP_DIR="${BACKUP_DIR:-/var/backups/kapteins-daagbok}"
ENV_FILE="${ENV_FILE:-.env}"
MAX_WAIT=90
DEST="prod"
COMPOSE_FILE=""
DB_CONTAINER=""
BACKEND_CONTAINER=""
apply_dest_config() {
local dest="$1"
local force="${2:-0}"
if [[ "$dest" == "stage" ]]; then
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
COMPOSE_FILE="docker-compose.staging.yml"
fi
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
DB_CONTAINER="daagbox-staging-db"
fi
if [[ "$force" == "1" || -z "${BACKEND_CONTAINER}" ]]; then
BACKEND_CONTAINER="daagbox-staging-backend"
fi
else
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
COMPOSE_FILE="docker-compose.yml"
fi
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
DB_CONTAINER="daagbox-prod-db"
fi
if [[ "$force" == "1" || -z "${BACKEND_CONTAINER}" ]]; then
BACKEND_CONTAINER="daagbox-prod-backend"
fi
fi
}
MODE="full"
RESTORE_PATH=""
LIST=0
ASSUME_YES=0
usage() {
sed -n '2,10p' "$0"
echo ""
echo "Options:"
echo " -dest prod|stage Target environment (default: prod)"
echo " --list List available backups"
echo " --restore PATH Backup archive to restore"
echo " --full Restore DB + .env (default)"
echo " --db-only Restore database only"
echo " --env-only Restore .env only"
echo " --yes Skip confirmation prompts"
echo " -h, --help Show this help"
}
confirm() {
local prompt="$1"
if [ "$ASSUME_YES" -eq 1 ]; then
return 0
fi
read -r -p "$prompt [y/N] " answer
[[ "$answer" =~ ^[yY]$ ]]
}
while [ $# -gt 0 ]; do
case "$1" in
-dest)
DEST="${2:?-dest requires an argument}"
shift 2
;;
-dest=*)
DEST="${1#*=}"
shift
;;
--list)
LIST=1
shift
;;
--restore)
RESTORE_PATH="${2:?--restore requires a path}"
shift 2
;;
--full)
MODE="full"
shift
;;
--db-only)
MODE="db-only"
shift
;;
--env-only)
MODE="env-only"
shift
;;
--yes)
ASSUME_YES=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
esac
done
case "$DEST" in
prod|stage) ;;
*)
echo "Error: invalid -dest '$DEST' (use prod or stage)" >&2
exit 1
;;
esac
apply_dest_config "$DEST"
list_backups() {
if [ ! -d "$BACKUP_DIR" ]; then
echo "No backup directory: $BACKUP_DIR"
return 0
fi
local found=0
while IFS= read -r archive; do
found=1
echo "=== $archive ==="
tar -xOf "$archive" manifest.json 2>/dev/null | python3 -m json.tool 2>/dev/null || echo "(no manifest)"
echo ""
done < <(ls -1t "$BACKUP_DIR"/kapteins-daagbok_*.tar.gz 2>/dev/null || true)
if [ "$found" -eq 0 ]; then
echo "No backups found in $BACKUP_DIR"
fi
}
if [ "$LIST" -eq 1 ]; then
list_backups
exit 0
fi
if [ -z "$RESTORE_PATH" ]; then
echo "Error: --restore PATH or --list required" >&2
usage >&2
exit 1
fi
if [ ! -f "$RESTORE_PATH" ]; then
echo "Error: backup archive not found: $RESTORE_PATH" >&2
exit 1
fi
WORK_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
echo "Extracting $RESTORE_PATH..."
tar -xzf "$RESTORE_PATH" -C "$WORK_DIR"
if [ ! -f "$WORK_DIR/manifest.json" ]; then
echo "Error: manifest.json missing in backup archive" >&2
exit 1
fi
MANIFEST="$WORK_DIR/manifest.json"
echo "Backup manifest:"
python3 -m json.tool "$MANIFEST"
echo ""
read_manifest_field() {
python3 -c "import json; print(json.load(open('$MANIFEST')).get('$1', '') or '')"
}
MANIFEST_COMPOSE="$(read_manifest_field compose_file)"
MANIFEST_DB="$(read_manifest_field db_container)"
MANIFEST_DEST="$(read_manifest_field destination)"
if [ -n "$MANIFEST_COMPOSE" ]; then
COMPOSE_FILE="$MANIFEST_COMPOSE"
fi
if [ -n "$MANIFEST_DB" ]; then
DB_CONTAINER="$MANIFEST_DB"
fi
if [ -n "$MANIFEST_DEST" ]; then
if [[ "$MANIFEST_DEST" == "stage" ]]; then
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-staging-backend}"
else
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-prod-backend}"
fi
fi
if ! docker inspect "$DB_CONTAINER" >/dev/null 2>&1; then
if [[ "$DEST" == "prod" ]] && docker inspect daagbox-staging-db >/dev/null 2>&1; then
echo "Note: $DB_CONTAINER not found — falling back to staging (daagbox-staging-db). Use -dest stage explicitly."
apply_dest_config stage 1
DEST="stage"
else
echo "Error: DB container '$DB_CONTAINER' not found" >&2
exit 1
fi
fi
GIT_TAG="$(read_manifest_field git_tag)"
GIT_SHA="$(read_manifest_field git_sha)"
BACKUP_TS="$(read_manifest_field local_timestamp)"
if ! confirm "Restore backup from $BACKUP_TS (tag: $GIT_TAG)? Mode: $MODE"; then
echo "Aborted."
exit 1
fi
restore_env() {
if [ ! -f "$WORK_DIR/.env" ]; then
echo "Error: .env missing in backup archive" >&2
exit 1
fi
if [ -f "$ENV_FILE" ]; then
BAK="${ENV_FILE}.bak-restore.$(date +%Y%m%d-%H%M%S)"
cp "$ENV_FILE" "$BAK"
echo "Current $ENV_FILE saved to $BAK"
fi
cp "$WORK_DIR/.env" "$ENV_FILE"
chmod 600 "$ENV_FILE"
echo "Restored $ENV_FILE"
}
restore_db() {
if [ ! -f "$WORK_DIR/database.sql.gz" ]; then
echo "Error: database.sql.gz missing in backup archive" >&2
exit 1
fi
if [ ! -f "$ENV_FILE" ]; then
echo "Error: $ENV_FILE required for database restore" >&2
exit 1
fi
# shellcheck disable=SC1090
set -a
source "$ENV_FILE"
set +a
POSTGRES_USER="${POSTGRES_USER:-postgres}"
POSTGRES_DB="${POSTGRES_DB:-daagbox}"
if [ -z "${POSTGRES_PASSWORD:-}" ]; then
echo "Error: POSTGRES_PASSWORD not set in $ENV_FILE" >&2
exit 1
fi
if ! docker inspect "$DB_CONTAINER" >/dev/null 2>&1; then
echo "Error: DB container '$DB_CONTAINER' not found" >&2
exit 1
fi
echo "Stopping backend before database restore..."
docker compose -f "$COMPOSE_FILE" stop backend || true
echo "Resetting public schema..."
export PGPASSWORD="$POSTGRES_PASSWORD"
docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$DB_CONTAINER" \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 <<SQL
DROP SCHEMA IF EXISTS public CASCADE;
CREATE SCHEMA public;
GRANT ALL ON SCHEMA public TO "${POSTGRES_USER}";
GRANT ALL ON SCHEMA public TO public;
SQL
echo "Importing database dump..."
gunzip -c "$WORK_DIR/database.sql.gz" | docker exec -i -e PGPASSWORD="$POSTGRES_PASSWORD" "$DB_CONTAINER" \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1
unset PGPASSWORD
echo "Database restore complete."
}
wait_for_healthy() {
echo "Starting stack and waiting for health..."
docker compose -f "$COMPOSE_FILE" up -d
local counter=0
while [ "$counter" -lt "$MAX_WAIT" ]; do
local status
status="$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$BACKEND_CONTAINER" 2>/dev/null || true)"
if [ "$status" = "healthy" ]; then
echo "Backend is healthy."
return 0
fi
if curl -sf "http://127.0.0.1/api/health" | grep -q '"status":"ok"'; then
echo "API health check OK."
return 0
fi
sleep 1
counter=$((counter + 1))
printf "."
done
echo ""
echo "Warning: backend did not become healthy in time." >&2
return 1
}
case "$MODE" in
env-only)
restore_env
;;
db-only)
restore_db
wait_for_healthy || exit 1
;;
full)
restore_env
restore_db
wait_for_healthy || exit 1
if [ -f "$WORK_DIR/app.tar.gz" ] && [ "$GIT_TAG" != "unknown" ]; then
if confirm "Checkout app code at tag $GIT_TAG? (git fetch + checkout)"; then
git fetch --tags origin
git checkout "$GIT_TAG"
echo "Checked out $GIT_TAG"
fi
fi
;;
*)
echo "Error: unknown mode $MODE" >&2
exit 1
;;
esac
echo "Restore finished (mode: $MODE, tag: $GIT_TAG, sha: ${GIT_SHA:0:8})."

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